Upload beautify

This commit is contained in:
2026-02-14 15:14:12 +01:00
parent e129618910
commit 79192345e3
249 changed files with 24436 additions and 1021 deletions

View 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);
});

View 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');
});

View 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);
});