Upload beautify
This commit is contained in:
120
tests/Unit/Discovery/FeedOfflineEvaluationServiceTest.php
Normal file
120
tests/Unit/Discovery/FeedOfflineEvaluationServiceTest.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Recommendations\FeedOfflineEvaluationService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
it('evaluates objective metrics for an algo from feed_daily_metrics', function () {
|
||||
$metricDate = now()->subDay()->toDateString();
|
||||
|
||||
DB::table('feed_daily_metrics')->insert([
|
||||
'metric_date' => $metricDate,
|
||||
'algo_version' => 'clip-cosine-v1',
|
||||
'source' => 'personalized',
|
||||
'impressions' => 100,
|
||||
'clicks' => 20,
|
||||
'saves' => 8,
|
||||
'ctr' => 0.2,
|
||||
'save_rate' => 0.4,
|
||||
'dwell_0_5' => 3,
|
||||
'dwell_5_30' => 7,
|
||||
'dwell_30_120' => 6,
|
||||
'dwell_120_plus' => 4,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$result = app(FeedOfflineEvaluationService::class)->evaluateAlgo('clip-cosine-v1', $metricDate, $metricDate);
|
||||
|
||||
expect((string) $result['algo_version'])->toBe('clip-cosine-v1');
|
||||
expect((float) $result['ctr'])->toBe(0.2);
|
||||
expect((float) $result['save_rate'])->toBe(0.4);
|
||||
expect((float) $result['long_dwell_share'])->toBe(0.5);
|
||||
expect((float) $result['bounce_rate'])->toBe(0.15);
|
||||
expect((float) $result['objective_score'])->toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('compares baseline vs candidate with delta and lift', function () {
|
||||
$metricDate = now()->subDay()->toDateString();
|
||||
|
||||
DB::table('feed_daily_metrics')->insert([
|
||||
[
|
||||
'metric_date' => $metricDate,
|
||||
'algo_version' => 'clip-cosine-v1',
|
||||
'source' => 'personalized',
|
||||
'impressions' => 100,
|
||||
'clicks' => 20,
|
||||
'saves' => 6,
|
||||
'ctr' => 0.2,
|
||||
'save_rate' => 0.3,
|
||||
'dwell_0_5' => 4,
|
||||
'dwell_5_30' => 8,
|
||||
'dwell_30_120' => 5,
|
||||
'dwell_120_plus' => 3,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'metric_date' => $metricDate,
|
||||
'algo_version' => 'clip-cosine-v2',
|
||||
'source' => 'personalized',
|
||||
'impressions' => 100,
|
||||
'clicks' => 25,
|
||||
'saves' => 10,
|
||||
'ctr' => 0.25,
|
||||
'save_rate' => 0.4,
|
||||
'dwell_0_5' => 3,
|
||||
'dwell_5_30' => 8,
|
||||
'dwell_30_120' => 8,
|
||||
'dwell_120_plus' => 6,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
$comparison = app(FeedOfflineEvaluationService::class)
|
||||
->compareBaselineCandidate('clip-cosine-v1', 'clip-cosine-v2', $metricDate, $metricDate);
|
||||
|
||||
expect((float) $comparison['delta']['objective_score'])->toBeGreaterThan(0.0);
|
||||
expect((float) $comparison['delta']['ctr'])->toBeGreaterThan(0.0);
|
||||
expect((float) $comparison['delta']['save_rate'])->toBeGreaterThan(0.0);
|
||||
});
|
||||
|
||||
it('treats save_rate as informational when configured', function () {
|
||||
$metricDate = now()->subDay()->toDateString();
|
||||
|
||||
config()->set('discovery.evaluation.objective_weights', [
|
||||
'ctr' => 0.45,
|
||||
'save_rate' => 0.35,
|
||||
'long_dwell_share' => 0.25,
|
||||
'bounce_rate_penalty' => 0.15,
|
||||
]);
|
||||
config()->set('discovery.evaluation.save_rate_informational', true);
|
||||
|
||||
DB::table('feed_daily_metrics')->insert([
|
||||
'metric_date' => $metricDate,
|
||||
'algo_version' => 'clip-cosine-v1',
|
||||
'source' => 'personalized',
|
||||
'impressions' => 100,
|
||||
'clicks' => 20,
|
||||
'saves' => 8,
|
||||
'ctr' => 0.2,
|
||||
'save_rate' => 0.4,
|
||||
'dwell_0_5' => 3,
|
||||
'dwell_5_30' => 7,
|
||||
'dwell_30_120' => 6,
|
||||
'dwell_120_plus' => 4,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$result = app(FeedOfflineEvaluationService::class)->evaluateAlgo('clip-cosine-v1', $metricDate, $metricDate);
|
||||
|
||||
expect((float) $result['save_rate'])->toBe(0.4);
|
||||
expect((float) $result['objective_score'])->toBe(0.226471);
|
||||
});
|
||||
76
tests/Unit/Discovery/PersonalizedFeedServiceTest.php
Normal file
76
tests/Unit/Discovery/PersonalizedFeedServiceTest.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use App\Models\UserRecommendationCache;
|
||||
use App\Services\Recommendations\PersonalizedFeedService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
it('regenerates recommendation cache with items and expiry', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$artworkA = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(2)]);
|
||||
$artworkB = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(1)]);
|
||||
|
||||
DB::table('artwork_stats')->insert([
|
||||
['artwork_id' => $artworkA->id, 'views' => 120, 'downloads' => 30, 'favorites' => 2, 'rating_avg' => 0, 'rating_count' => 0],
|
||||
['artwork_id' => $artworkB->id, 'views' => 100, 'downloads' => 20, 'favorites' => 1, 'rating_avg' => 0, 'rating_count' => 0],
|
||||
]);
|
||||
|
||||
app(PersonalizedFeedService::class)->regenerateCacheForUser($user->id, (string) config('discovery.algo_version'));
|
||||
|
||||
$cache = UserRecommendationCache::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('algo_version', (string) config('discovery.algo_version'))
|
||||
->first();
|
||||
|
||||
expect($cache)->not->toBeNull();
|
||||
expect($cache?->generated_at)->not->toBeNull();
|
||||
expect($cache?->expires_at)->not->toBeNull();
|
||||
|
||||
$items = (array) ($cache?->recommendations_json['items'] ?? []);
|
||||
expect(count($items))->toBeGreaterThan(0);
|
||||
expect((int) ($items[0]['artwork_id'] ?? 0))->toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('uses rollout gate g100 to select candidate algo version', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
config()->set('discovery.rollout.enabled', true);
|
||||
config()->set('discovery.rollout.baseline_algo_version', 'clip-cosine-v1');
|
||||
config()->set('discovery.rollout.candidate_algo_version', 'clip-cosine-v2');
|
||||
config()->set('discovery.rollout.active_gate', 'g100');
|
||||
config()->set('discovery.rollout.gates.g100.percentage', 100);
|
||||
config()->set('discovery.rollout.force_algo_version', '');
|
||||
|
||||
app(PersonalizedFeedService::class)->regenerateCacheForUser($user->id);
|
||||
|
||||
$cache = UserRecommendationCache::query()->where('user_id', $user->id)->first();
|
||||
|
||||
expect($cache)->not->toBeNull();
|
||||
expect((string) $cache?->algo_version)->toBe('clip-cosine-v2');
|
||||
});
|
||||
|
||||
it('forces rollback algo version when force toggle is set', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
config()->set('discovery.rollout.enabled', true);
|
||||
config()->set('discovery.rollout.baseline_algo_version', 'clip-cosine-v1');
|
||||
config()->set('discovery.rollout.candidate_algo_version', 'clip-cosine-v2');
|
||||
config()->set('discovery.rollout.active_gate', 'g100');
|
||||
config()->set('discovery.rollout.gates.g100.percentage', 100);
|
||||
config()->set('discovery.rollout.force_algo_version', 'clip-cosine-v1');
|
||||
|
||||
app(PersonalizedFeedService::class)->regenerateCacheForUser($user->id);
|
||||
|
||||
$cache = UserRecommendationCache::query()->where('user_id', $user->id)->first();
|
||||
|
||||
expect($cache)->not->toBeNull();
|
||||
expect((string) $cache?->algo_version)->toBe('clip-cosine-v1');
|
||||
});
|
||||
86
tests/Unit/Discovery/UserInterestProfileServiceTest.php
Normal file
86
tests/Unit/Discovery/UserInterestProfileServiceTest.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\User;
|
||||
use App\Services\Recommendations\UserInterestProfileService;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
it('applies recency decay and normalizes profile scores', function () {
|
||||
config()->set('discovery.decay.half_life_hours', 72);
|
||||
config()->set('discovery.weights.view', 1.0);
|
||||
|
||||
$service = app(UserInterestProfileService::class);
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$contentType = ContentType::create([
|
||||
'name' => 'Digital Art',
|
||||
'slug' => 'digital-art',
|
||||
'description' => 'Digital artworks',
|
||||
]);
|
||||
|
||||
$categoryA = Category::create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => null,
|
||||
'name' => 'Sci-Fi',
|
||||
'slug' => 'sci-fi',
|
||||
'description' => 'Sci-Fi category',
|
||||
'is_active' => true,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
$categoryB = Category::create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => null,
|
||||
'name' => 'Fantasy',
|
||||
'slug' => 'fantasy',
|
||||
'description' => 'Fantasy category',
|
||||
'is_active' => true,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
$artworkA = Artwork::factory()->create();
|
||||
$artworkB = Artwork::factory()->create();
|
||||
|
||||
$t0 = CarbonImmutable::parse('2026-02-14 00:00:00');
|
||||
|
||||
$service->applyEvent(
|
||||
userId: $user->id,
|
||||
eventType: 'view',
|
||||
artworkId: $artworkA->id,
|
||||
categoryId: $categoryA->id,
|
||||
occurredAt: $t0,
|
||||
eventId: '11111111-1111-1111-1111-111111111111',
|
||||
algoVersion: 'clip-cosine-v1'
|
||||
);
|
||||
|
||||
$service->applyEvent(
|
||||
userId: $user->id,
|
||||
eventType: 'view',
|
||||
artworkId: $artworkB->id,
|
||||
categoryId: $categoryB->id,
|
||||
occurredAt: $t0->addHours(72),
|
||||
eventId: '22222222-2222-2222-2222-222222222222',
|
||||
algoVersion: 'clip-cosine-v1'
|
||||
);
|
||||
|
||||
$profile = \App\Models\UserInterestProfile::query()->where('user_id', $user->id)->firstOrFail();
|
||||
|
||||
expect((int) $profile->event_count)->toBe(2);
|
||||
|
||||
$normalized = (array) $profile->normalized_scores_json;
|
||||
|
||||
expect($normalized)->toHaveKey('category:' . $categoryA->id);
|
||||
expect($normalized)->toHaveKey('category:' . $categoryB->id);
|
||||
|
||||
expect((float) $normalized['category:' . $categoryA->id])->toBeGreaterThan(0.30)->toBeLessThan(0.35);
|
||||
expect((float) $normalized['category:' . $categoryB->id])->toBeGreaterThan(0.65)->toBeLessThan(0.70);
|
||||
});
|
||||
Reference in New Issue
Block a user