diff --git a/app/Console/Commands/RecalculateRankingsCommand.php b/app/Console/Commands/RecalculateRankingsCommand.php new file mode 100644 index 00000000..7a8b6ddf --- /dev/null +++ b/app/Console/Commands/RecalculateRankingsCommand.php @@ -0,0 +1,81 @@ +option('chunk'); + $syncRankScores = (bool) $this->option('sync-rank-scores'); + $skipIndex = (bool) $this->option('skip-index'); + + // ── Step 1: Recalculate ranking_score + engagement_velocity ───── + $this->info('Ranking V2: recalculating scores …'); + $start = microtime(true); + $updated = $this->ranking->recalculateAll($chunkSize); + $elapsed = round(microtime(true) - $start, 2); + $this->info(" ✓ {$updated} artworks scored in {$elapsed}s"); + + // ── Step 2 (optional): Sync to rank_artwork_scores ───────────── + if ($syncRankScores) { + $this->info('Syncing to rank_artwork_scores …'); + $start2 = microtime(true); + $synced = $this->ranking->syncToRankScores($chunkSize); + $elapsed2 = round(microtime(true) - $start2, 2); + $this->info(" ✓ {$synced} rank scores synced in {$elapsed2}s"); + } + + // ── Step 3 (optional): Trigger Meilisearch re-index ──────────── + if (! $skipIndex) { + $this->info('Dispatching Meilisearch index jobs …'); + $this->dispatchIndexJobs(); + $this->info(' ✓ Index jobs dispatched'); + } + + return self::SUCCESS; + } + + /** + * Dispatch IndexArtworkJob for artworks updated in the last 24 hours + * (or recently scored). Keeps the search index current. + */ + private function dispatchIndexJobs(): void + { + \App\Models\Artwork::query() + ->select('id') + ->where('is_public', true) + ->where('is_approved', true) + ->whereNull('deleted_at') + ->whereNotNull('published_at') + ->where('published_at', '>=', now()->subDays(30)->toDateTimeString()) + ->chunkById(500, function ($artworks): void { + foreach ($artworks as $artwork) { + \App\Jobs\IndexArtworkJob::dispatch($artwork->id); + } + }); + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 9a7026b9..ef97a632 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -13,6 +13,7 @@ use App\Console\Commands\EvaluateFeedWeightsCommand; use App\Console\Commands\AiTagArtworksCommand; use App\Console\Commands\CompareFeedAbCommand; use App\Console\Commands\RecalculateTrendingCommand; +use App\Console\Commands\RecalculateRankingsCommand; use App\Jobs\RankComputeArtworkScoresJob; use App\Jobs\RankBuildListsJob; use App\Uploads\Commands\CleanupUploadsCommand; @@ -40,6 +41,7 @@ class Kernel extends ConsoleKernel AiTagArtworksCommand::class, \App\Console\Commands\MigrateFollows::class, RecalculateTrendingCommand::class, + RecalculateRankingsCommand::class, ]; /** @@ -59,6 +61,13 @@ class Kernel extends ConsoleKernel $schedule->job(new RankComputeArtworkScoresJob)->hourlyAt(5)->runInBackground(); // Step 2: build ranked lists every hour at :15 (after scores are ready) $schedule->job(new RankBuildListsJob)->hourlyAt(15)->runInBackground(); + + // ── Ranking Engine V2 — runs every 30 min ────────────────────────── + $schedule->command('nova:recalculate-rankings --sync-rank-scores') + ->everyThirtyMinutes() + ->name('ranking-v2') + ->withoutOverlapping() + ->runInBackground(); } /** diff --git a/app/Models/Artwork.php b/app/Models/Artwork.php index 0e2f2b80..13229bcc 100644 --- a/app/Models/Artwork.php +++ b/app/Models/Artwork.php @@ -256,6 +256,11 @@ class Artwork extends Model 'favorites_count' => (int) ($stat?->favorites ?? 0), 'awards_received_count' => (int) ($awardStat?->score_total ?? 0), 'downloads_count' => (int) ($stat?->downloads ?? 0), + // ── Ranking V2 fields ─────────────────────────────────────────────── + 'ranking_score' => (float) ($stat?->ranking_score ?? 0), + 'engagement_velocity' => (float) ($stat?->engagement_velocity ?? 0), + 'shares_count' => (int) ($stat?->shares_count ?? 0), + 'comments_count' => (int) ($stat?->comments_count ?? 0), 'awards' => [ 'gold' => $awardStat?->gold_count ?? 0, 'silver' => $awardStat?->silver_count ?? 0, diff --git a/app/Models/ArtworkStats.php b/app/Models/ArtworkStats.php index 55d4e3eb..a093a314 100644 --- a/app/Models/ArtworkStats.php +++ b/app/Models/ArtworkStats.php @@ -17,6 +17,8 @@ class ArtworkStats extends Model public $incrementing = false; + public $timestamps = false; + protected $fillable = [ 'artwork_id', 'views', @@ -24,6 +26,14 @@ class ArtworkStats extends Model 'favorites', 'rating_avg', 'rating_count', + // V2 ranking columns + 'comments_count', + 'shares_count', + 'ranking_score', + 'engagement_velocity', + 'shares_24h', + 'comments_24h', + 'favourites_24h', ]; public function artwork(): BelongsTo diff --git a/app/Services/Ranking/ArtworkRankingService.php b/app/Services/Ranking/ArtworkRankingService.php new file mode 100644 index 00000000..f07b3910 --- /dev/null +++ b/app/Services/Ranking/ArtworkRankingService.php @@ -0,0 +1,532 @@ + 0.2, + 'downloads' => 1.5, + 'favourites' => 2.5, + 'comments' => 3.0, + 'shares' => 4.0, + ]; + + /** + * Default velocity weights for 24h window signals. + */ + private const DEFAULT_VELOCITY_WEIGHTS = [ + 'views' => 1.0, + 'favourites' => 3.0, + 'comments' => 4.0, + 'shares' => 5.0, + ]; + + private const DEFAULT_HALF_LIFE_HOURS = 48; + private const DEFAULT_VELOCITY_MULTIPLIER = 0.5; + private const DEFAULT_AUTHORITY_FACTOR = 0.05; + private const DEFAULT_AUTHORITY_FAV_DIVISOR = 1000; + + // ── Public scoring methods (per-artwork row) ─────────────────────────── + + /** + * Calculate the base engagement score. + * + * base_score = (views × 0.2) + (downloads × 1.5) + (favourites × 2.5) + * + (comments × 3.0) + (shares × 4.0) + */ + public function calculateBaseScore(object $row): float + { + $w = $this->weights(); + + return ($w['views'] * (float) ($row->views_all ?? 0)) + + ($w['downloads'] * (float) ($row->downloads_all ?? 0)) + + ($w['favourites'] * (float) ($row->favourites_all ?? 0)) + + ($w['comments'] * (float) ($row->comments_count ?? 0)) + + ($w['shares'] * (float) ($row->shares_count ?? 0)); + } + + /** + * Calculate the author authority multiplier. + * + * authority = log10(1 + followers_count) + (favourites_received / 1000) + * multiplier = 1 + (authority × 0.05) + */ + public function calculateAuthorityMultiplier(object $row): float + { + $followersCount = (float) ($row->author_followers_count ?? 0); + $favReceived = (float) ($row->author_favourites_received ?? 0); + + $factor = (float) config('ranking.v2.authority_factor', self::DEFAULT_AUTHORITY_FACTOR); + $favDivisor = (float) config('ranking.v2.authority_fav_divisor', self::DEFAULT_AUTHORITY_FAV_DIVISOR); + + $authority = log10(1 + $followersCount) + ($favReceived / $favDivisor); + + return 1.0 + ($authority * $factor); + } + + /** + * Calculate the recency decay factor. + * + * decay = 1 / (1 + (hours_since_upload / half_life)) + */ + public function calculateDecayFactor(object $row): float + { + $hours = max(0.0, (float) ($row->age_hours ?? 0)); + $halfLife = (float) config('ranking.v2.half_life', self::DEFAULT_HALF_LIFE_HOURS); + + return 1.0 / (1.0 + ($hours / $halfLife)); + } + + /** + * Calculate the 24h engagement velocity boost. + * + * velocity = (views_24h × 1) + (favourites_24h × 3) + (comments_24h × 4) + (shares_24h × 5) + * boost = velocity × 0.5 + */ + public function calculateVelocityBoost(object $row): float + { + $vw = $this->velocityWeights(); + + $velocity = ($vw['views'] * (float) ($row->views_24h ?? 0)) + + ($vw['favourites'] * (float) ($row->favourites_24h ?? 0)) + + ($vw['comments'] * (float) ($row->comments_24h ?? 0)) + + ($vw['shares'] * (float) ($row->shares_24h ?? 0)); + + $multiplier = (float) config('ranking.v2.velocity_multiplier', self::DEFAULT_VELOCITY_MULTIPLIER); + + return $velocity * $multiplier; + } + + /** + * Calculate the final ranking score for a single artwork row. + * + * ranking_score = (base_score × authority_multiplier × decay_factor) + velocity_boost + * + * @return array{ranking_score: float, engagement_velocity: float} + */ + public function calculateFinalScore(object $row): array + { + $base = $this->calculateBaseScore($row); + $authority = $this->calculateAuthorityMultiplier($row); + $decay = $this->calculateDecayFactor($row); + $velocity = $this->calculateVelocityBoost($row); + + $recentScore = $base * $authority * $decay; + $rankingScore = $recentScore + $velocity; + + return [ + 'ranking_score' => max(0.0, $rankingScore), + 'engagement_velocity' => max(0.0, $velocity), + ]; + } + + // ── Bulk recalculation ───────────────────────────────────────────────── + + /** + * Recalculate ranking_score and engagement_velocity for all public artworks. + * + * Uses chunked processing with a single pre-aggregated JOIN query. + * Author authority data is cached per-author to avoid redundant lookups. + * + * @param int $chunkSize DB chunk size (default 500) + * @return int Number of artworks updated + */ + public function recalculateAll(int $chunkSize = 500): int + { + $total = 0; + + // Pre-load author authority data into memory (one query) + $authorityCache = $this->loadAuthorAuthorityMap(); + + $this->artworkSignalsQuery() + ->orderBy('a.id') + ->chunk($chunkSize, function ($rows) use ($authorityCache, &$total): void { + $rows = collect($rows); + if ($rows->isEmpty()) { + return; + } + + $upserts = []; + + foreach ($rows as $row) { + /** @var object $row */ + // Inject cached author authority data + $userId = (int) $row->user_id; + $row->author_followers_count = $authorityCache[$userId]['followers'] ?? 0; + $row->author_favourites_received = $authorityCache[$userId]['fav_received'] ?? 0; + + $scores = $this->calculateFinalScore($row); + + $upserts[] = [ + 'artwork_id' => (int) $row->id, + 'ranking_score' => round($scores['ranking_score'], 4), + 'engagement_velocity' => round($scores['engagement_velocity'], 4), + 'comments_count' => (int) ($row->comments_count ?? 0), + 'shares_count' => (int) ($row->shares_count ?? 0), + ]; + } + + // Bulk upsert into artwork_stats + if (! empty($upserts)) { + DB::table('artwork_stats')->upsert( + $upserts, + ['artwork_id'], + ['ranking_score', 'engagement_velocity', 'comments_count', 'shares_count'] + ); + } + + $total += count($upserts); + }); + + Log::info('ArtworkRankingService V2: recalculation complete', [ + 'total_updated' => $total, + ]); + + return $total; + } + + /** + * Also update the existing rank_artwork_scores with V2 scores + * so the RankBuildListsJob benefits from the new formula. + */ + public function syncToRankScores(int $chunkSize = 500): int + { + $modelVersion = config('ranking.model_version', 'rank_v2'); + $total = 0; + $now = now()->toDateTimeString(); + + // Re-use the existing RankingService to compute the 3 scores, + // but inject V2 signals (shares, comments, velocity) into the existing formula + $this->artworkSignalsQuery() + ->orderBy('a.id') + ->chunk($chunkSize, function ($rows) use ($modelVersion, $now, &$total): void { + $rows = collect($rows); + if ($rows->isEmpty()) { + return; + } + + $upserts = $rows->map(function ($row) use ($modelVersion, $now): array { + // Compute V2-enhanced scores for the 3 list types + $scores = $this->computeListScores($row); + + return [ + 'artwork_id' => (int) $row->id, + 'score_trending' => $scores['score_trending'], + 'score_new_hot' => $scores['score_new_hot'], + 'score_best' => $scores['score_best'], + 'model_version' => $modelVersion, + 'computed_at' => $now, + ]; + })->all(); + + DB::table('rank_artwork_scores')->upsert( + $upserts, + ['artwork_id'], + ['score_trending', 'score_new_hot', 'score_best', 'model_version', 'computed_at'] + ); + + $total += count($upserts); + }); + + return $total; + } + + /** + * Compute the three list-type scores (trending, new_hot, best) + * using V2 engagement formula that includes shares and comments. + */ + public function computeListScores(object $row): array + { + $cfg = config('ranking'); + $w = $this->weights(); + + // V2 engagement base (includes shares + comments) + $E = ($w['views'] * log(1 + (float) ($row->views_7d ?? 0))) + + ($w['downloads'] * log(1 + (float) ($row->downloads_7d ?? 0))) + + ($w['favourites'] * log(1 + (float) ($row->favourites_7d ?? 0))) + + ($w['comments'] * log(1 + (float) ($row->comments_24h ?? 0) * 7)) + + ($w['shares'] * log(1 + (float) ($row->shares_24h ?? 0) * 7)); + + $E_all = ($w['views'] * log(1 + (float) ($row->views_all ?? 0))) + + ($w['downloads'] * log(1 + (float) ($row->downloads_all ?? 0))) + + ($w['favourites'] * log(1 + (float) ($row->favourites_all ?? 0))) + + ($w['comments'] * log(1 + (float) ($row->comments_count ?? 0))) + + ($w['shares'] * log(1 + (float) ($row->shares_count ?? 0))); + + // Freshness decay + $ageH = max(0.0, (float) ($row->age_hours ?? 0)); + $decayTrending = exp(-$ageH / (float) ($cfg['half_life']['trending'] ?? 72)); + $decayNewHot = exp(-$ageH / (float) ($cfg['half_life']['new_hot'] ?? 36)); + $decayBest = exp(-$ageH / (float) ($cfg['half_life']['best'] ?? 720)); + + // Quality modifier + $tagCount = (int) ($row->tag_count ?? 0); + $hasThumb = (bool) ($row->has_thumbnail ?? false); + $isVisible = (bool) ($row->is_public ?? false) && (bool) ($row->is_approved ?? false); + + $Q = 1.0; + if ($tagCount > 0) { $Q += (float) ($cfg['quality']['has_tags'] ?? 0.05); } + if ($hasThumb) { $Q += (float) ($cfg['quality']['has_thumbnail'] ?? 0.02); } + $Q += (float) ($cfg['quality']['tag_count_bonus'] ?? 0.01) + * (min($tagCount, (int) ($cfg['quality']['tag_count_max'] ?? 10)) + / max((float) ($cfg['quality']['tag_count_max'] ?? 10), 1.0)); + if (! $isVisible) { $Q -= (float) ($cfg['quality']['penalty_hidden'] ?? 0.50); } + + // Novelty boost (New & Hot) + $noveltyW = (float) ($cfg['novelty_weight'] ?? 0.35); + $novelty = 1.0 + $noveltyW * exp(-$ageH / 24.0); + + // Velocity boost for trending + $velocityBoost = $this->calculateVelocityBoost($row); + + // Anti-spam + $spamFactor = 1.0; + $spam = $cfg['spam'] ?? []; + if ( + (float) ($row->views_24h ?? 0) > (float) ($spam['views_24h_threshold'] ?? 2000) + && (float) ($row->views_24h ?? 0) > 0 + ) { + $rF = (float) ($row->favourites_24h ?? 0) / (float) ($row->views_24h ?? 1); + $rD = (float) ($row->downloads_24h ?? 0) / (float) ($row->views_24h ?? 1); + if ($rF < (float) ($spam['fav_ratio_threshold'] ?? 0.002) + && $rD < (float) ($spam['dl_ratio_threshold'] ?? 0.001) + ) { + $spamFactor = (float) ($spam['trending_penalty_factor'] ?? 0.5); + } + } + + $scoreTrending = ($E * $decayTrending * (1.0 + $Q) * $spamFactor) + $velocityBoost; + $scoreNewHot = ($E * $decayNewHot * $novelty * (1.0 + $Q)) + ($velocityBoost * 0.7); + $scoreBest = $E_all * $decayBest * (1.0 + $Q); + + return [ + 'score_trending' => max(0.0, $scoreTrending), + 'score_new_hot' => max(0.0, $scoreNewHot), + 'score_best' => max(0.0, $scoreBest), + ]; + } + + // ── Signal query ─────────────────────────────────────────────────────── + + /** + * Build the query that selects all artwork signals needed for V2 scoring. + * + * Columns returned: + * id, user_id, published_at, is_public, is_approved, has_thumbnail, + * views_all, downloads_all, favourites_all, comments_count, shares_count, + * views_7d, downloads_7d, favourites_7d, + * views_24h, downloads_24h, favourites_24h, comments_24h, shares_24h, + * tag_count, age_hours + */ + public function artworkSignalsQuery(): \Illuminate\Database\Query\Builder + { + $hasSharesTable = Schema::hasTable('artwork_shares'); + $hasCommentsTable = Schema::hasTable('artwork_comments'); + + $query = DB::table('artworks as a') + ->select([ + 'a.id', + 'a.user_id', + 'a.published_at', + 'a.is_public', + 'a.is_approved', + DB::raw('(a.thumb_ext IS NOT NULL AND a.thumb_ext != "") AS has_thumbnail'), + // All-time counters + DB::raw('COALESCE(ast.views, 0) AS views_all'), + DB::raw('COALESCE(ast.downloads, 0) AS downloads_all'), + DB::raw('COALESCE(ast.favorites, 0) AS favourites_all'), + // Comments count (precomputed or subquery) + DB::raw( + $hasCommentsTable + ? 'COALESCE(cc.cnt, 0) AS comments_count' + : '0 AS comments_count' + ), + // Shares count + DB::raw( + $hasSharesTable + ? 'COALESCE(sc.cnt, 0) AS shares_count' + : '0 AS shares_count' + ), + // 7-day windowed + DB::raw('COALESCE(ast.views_7d, 0) AS views_7d'), + DB::raw('COALESCE(ast.downloads_7d, 0) AS downloads_7d'), + DB::raw('COALESCE(fav7.cnt, 0) AS favourites_7d'), + // 24-hour windowed + DB::raw('COALESCE(ast.views_24h, 0) AS views_24h'), + DB::raw('COALESCE(ast.downloads_24h, 0) AS downloads_24h'), + DB::raw('COALESCE(fav1.cnt, 0) AS favourites_24h'), + // 24h comments & shares + DB::raw( + $hasCommentsTable + ? 'COALESCE(cc24.cnt, 0) AS comments_24h' + : '0 AS comments_24h' + ), + DB::raw( + $hasSharesTable + ? 'COALESCE(sc24.cnt, 0) AS shares_24h' + : '0 AS shares_24h' + ), + // Tag count + DB::raw('COALESCE(tc.tag_count, 0) AS tag_count'), + // Age in hours + DB::raw('GREATEST(TIMESTAMPDIFF(HOUR, a.published_at, NOW()), 0) AS age_hours'), + ]) + ->leftJoin('artwork_stats as ast', 'ast.artwork_id', '=', 'a.id') + // Favourites (7 days) + ->leftJoinSub( + DB::table('artwork_favourites') + ->select('artwork_id', DB::raw('COUNT(*) as cnt')) + ->where('created_at', '>=', DB::raw('DATE_SUB(NOW(), INTERVAL 7 DAY)')) + ->groupBy('artwork_id'), + 'fav7', + 'fav7.artwork_id', '=', 'a.id' + ) + // Favourites (24 hours) + ->leftJoinSub( + DB::table('artwork_favourites') + ->select('artwork_id', DB::raw('COUNT(*) as cnt')) + ->where('created_at', '>=', DB::raw('DATE_SUB(NOW(), INTERVAL 1 DAY)')) + ->groupBy('artwork_id'), + 'fav1', + 'fav1.artwork_id', '=', 'a.id' + ) + // Tag count + ->leftJoinSub( + DB::table('artwork_tag') + ->select('artwork_id', DB::raw('COUNT(*) as tag_count')) + ->groupBy('artwork_id'), + 'tc', + 'tc.artwork_id', '=', 'a.id' + ) + ->where('a.is_public', 1) + ->where('a.is_approved', 1) + ->whereNull('a.deleted_at') + ->whereNotNull('a.published_at'); + + // Comments count (all-time) + if ($hasCommentsTable) { + $query->leftJoinSub( + DB::table('artwork_comments') + ->select('artwork_id', DB::raw('COUNT(*) as cnt')) + ->whereNull('deleted_at') + ->groupBy('artwork_id'), + 'cc', + 'cc.artwork_id', '=', 'a.id' + ); + // Comments (24h) + $query->leftJoinSub( + DB::table('artwork_comments') + ->select('artwork_id', DB::raw('COUNT(*) as cnt')) + ->whereNull('deleted_at') + ->where('created_at', '>=', DB::raw('DATE_SUB(NOW(), INTERVAL 1 DAY)')) + ->groupBy('artwork_id'), + 'cc24', + 'cc24.artwork_id', '=', 'a.id' + ); + } + + // Shares count (all-time) + if ($hasSharesTable) { + $query->leftJoinSub( + DB::table('artwork_shares') + ->select('artwork_id', DB::raw('COUNT(*) as cnt')) + ->groupBy('artwork_id'), + 'sc', + 'sc.artwork_id', '=', 'a.id' + ); + // Shares (24h) + $query->leftJoinSub( + DB::table('artwork_shares') + ->select('artwork_id', DB::raw('COUNT(*) as cnt')) + ->where('created_at', '>=', DB::raw('DATE_SUB(NOW(), INTERVAL 1 DAY)')) + ->groupBy('artwork_id'), + 'sc24', + 'sc24.artwork_id', '=', 'a.id' + ); + } + + return $query; + } + + // ── Author authority pre-loading ─────────────────────────────────────── + + /** + * Load author authority data for all users who have published artworks. + * Returns an array keyed by user_id: + * [ userId => ['followers' => int, 'fav_received' => int] ] + */ + private function loadAuthorAuthorityMap(): array + { + $map = []; + + // Get all author user IDs with public artworks + $rows = DB::table('artworks as a') + ->select([ + 'a.user_id', + DB::raw('COALESCE(us.followers_count, 0) AS followers_count'), + DB::raw('COALESCE(us.favorites_received_count, 0) AS favourites_received_count'), + ]) + ->leftJoin('user_statistics as us', 'us.user_id', '=', 'a.user_id') + ->where('a.is_public', 1) + ->where('a.is_approved', 1) + ->whereNull('a.deleted_at') + ->groupBy('a.user_id', 'us.followers_count', 'us.favorites_received_count') + ->get(); + + foreach ($rows as $row) { + $map[(int) $row->user_id] = [ + 'followers' => (int) $row->followers_count, + 'fav_received' => (int) $row->favourites_received_count, + ]; + } + + return $map; + } + + // ── Config helpers ───────────────────────────────────────────────────── + + private function weights(): array + { + return array_merge( + self::DEFAULT_WEIGHTS, + (array) config('ranking.v2.weights', []) + ); + } + + private function velocityWeights(): array + { + return array_merge( + self::DEFAULT_VELOCITY_WEIGHTS, + (array) config('ranking.v2.velocity_weights', []) + ); + } +} diff --git a/app/Services/RankingService.php b/app/Services/RankingService.php index 1edc753c..9f90b113 100644 --- a/app/Services/RankingService.php +++ b/app/Services/RankingService.php @@ -5,20 +5,23 @@ declare(strict_types=1); namespace App\Services; use App\Models\Artwork; -use App\Models\RankArtworkScore; use App\Models\RankList; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; /** - * RankingService — Skinbase Nova rank_v1 + * RankingService — Skinbase Nova rank_v2 * * Responsibilities: * 1. Score computation — turn raw artwork signals into three float scores. * 2. Diversity filtering — cap items per author while keeping rank order. * 3. List read / cache — serve ranked lists from Redis, falling back to DB, * and ultimately to latest-first if no list is built yet. + * + * V2 enhancements: + * - Shares and comments are included in engagement scoring + * - Engagement velocity (24h burst) boosts trending artworks */ final class RankingService { @@ -27,10 +30,13 @@ final class RankingService /** * Compute all three ranking scores for a single artwork data row. * + * V2: includes shares, comments, and velocity boost. + * * @param object $row stdClass with fields: * views_7d, favourites_7d, downloads_7d, * views_all, favourites_all, downloads_all, * views_24h, favourites_24h, downloads_24h, + * comments_count, shares_count, comments_24h, shares_24h, * age_hours, tag_count, has_thumbnail (bool 0/1), * is_public, is_approved * @return array{score_trending: float, score_new_hot: float, score_best: float} @@ -43,15 +49,23 @@ final class RankingService $wF = (float) $cfg['weights']['favourites']; $wD = (float) $cfg['weights']['downloads']; - // 3.1 Base engagement (7-day window) + // V2 weights for shares + comments (from v2 config, with defaults) + $wC = (float) ($cfg['v2']['weights']['comments'] ?? 3.0); + $wS = (float) ($cfg['v2']['weights']['shares'] ?? 4.0); + + // 3.1 Base engagement (7-day window) — V2: includes shares & comments $E = ($wV * log(1 + (float) $row->views_7d)) + ($wF * log(1 + (float) $row->favourites_7d)) - + ($wD * log(1 + (float) $row->downloads_7d)); + + ($wD * log(1 + (float) $row->downloads_7d)) + + ($wC * log(1 + (float) ($row->comments_24h ?? 0) * 7)) + + ($wS * log(1 + (float) ($row->shares_24h ?? 0) * 7)); // Base engagement (all-time, for "best" score) $E_all = ($wV * log(1 + (float) $row->views_all)) + ($wF * log(1 + (float) $row->favourites_all)) - + ($wD * log(1 + (float) $row->downloads_all)); + + ($wD * log(1 + (float) $row->downloads_all)) + + ($wC * log(1 + (float) ($row->comments_count ?? 0))) + + ($wS * log(1 + (float) ($row->shares_count ?? 0))); // 3.2 Freshness decay $ageH = max(0.0, (float) $row->age_hours); @@ -77,6 +91,14 @@ final class RankingService $noveltyW = (float) $cfg['novelty_weight']; $novelty = 1.0 + $noveltyW * exp(-$ageH / 24.0); + // 3.5 Velocity boost (V2) — 24h engagement burst + $vw = $cfg['v2']['velocity_weights'] ?? ['views' => 1, 'favourites' => 3, 'comments' => 4, 'shares' => 5]; + $velocityRaw = ((float) ($vw['views'] ?? 1) * (float) ($row->views_24h ?? 0)) + + ((float) ($vw['favourites'] ?? 3) * (float) ($row->favourites_24h ?? 0)) + + ((float) ($vw['comments'] ?? 4) * (float) ($row->comments_24h ?? 0)) + + ((float) ($vw['shares'] ?? 5) * (float) ($row->shares_24h ?? 0)); + $velocityBoost = $velocityRaw * (float) ($cfg['v2']['velocity_multiplier'] ?? 0.5); + // Anti-spam damping on trending score only $spamFactor = 1.0; $spam = $cfg['spam']; @@ -84,8 +106,8 @@ final class RankingService (float) $row->views_24h > (float) $spam['views_24h_threshold'] && (float) $row->views_24h > 0 ) { - $rF = (float) $row->favourites_24h / (float) $row->views_24h; - $rD = (float) $row->downloads_24h / (float) $row->views_24h; + $rF = (float) ($row->favourites_24h ?? 0) / (float) $row->views_24h; + $rD = (float) ($row->downloads_24h ?? 0) / (float) $row->views_24h; if ($rF < (float) $spam['fav_ratio_threshold'] && $rD < (float) $spam['dl_ratio_threshold'] ) { @@ -93,8 +115,8 @@ final class RankingService } } - $scoreTrending = $E * $decayTrending * (1.0 + $Q) * $spamFactor; - $scoreNewHot = $E * $decayNewHot * $novelty * (1.0 + $Q); + $scoreTrending = ($E * $decayTrending * (1.0 + $Q) * $spamFactor) + $velocityBoost; + $scoreNewHot = ($E * $decayNewHot * $novelty * (1.0 + $Q)) + ($velocityBoost * 0.7); $scoreBest = $E_all * $decayBest * (1.0 + $Q); return [ @@ -276,18 +298,22 @@ final class RankingService * Return a query builder that selects all artwork signals needed for score * computation. Results are NOT paginated — callers chunk them. * - * Columns returned: + * V2 columns returned: * id, user_id, published_at, is_public, is_approved, - * thumb_ext (→ has_thumbnail), + * has_thumbnail, * views_7d, downloads_7d, views_24h, downloads_24h, * views_all, downloads_all, favourites_all, - * favourites_7d, favourites_24h, downloads_24h, + * favourites_7d, favourites_24h, + * comments_count, shares_count, comments_24h, shares_24h, * tag_count, * age_hours */ public function artworkSignalsQuery(): \Illuminate\Database\Query\Builder { - return DB::table('artworks as a') + $hasSharesTable = \Illuminate\Support\Facades\Schema::hasTable('artwork_shares'); + $hasCommentsTable = \Illuminate\Support\Facades\Schema::hasTable('artwork_comments'); + + $query = DB::table('artworks as a') ->select([ 'a.id', 'a.user_id', @@ -304,6 +330,27 @@ final class RankingService DB::raw('COALESCE(ast.favorites, 0) AS favourites_all'), DB::raw('COALESCE(fav7.cnt, 0) AS favourites_7d'), DB::raw('COALESCE(fav1.cnt, 0) AS favourites_24h'), + // V2: comments + shares + DB::raw( + $hasCommentsTable + ? 'COALESCE(cc_all.cnt, 0) AS comments_count' + : '0 AS comments_count' + ), + DB::raw( + $hasSharesTable + ? 'COALESCE(sc_all.cnt, 0) AS shares_count' + : '0 AS shares_count' + ), + DB::raw( + $hasCommentsTable + ? 'COALESCE(cc24.cnt, 0) AS comments_24h' + : '0 AS comments_24h' + ), + DB::raw( + $hasSharesTable + ? 'COALESCE(sc24.cnt, 0) AS shares_24h' + : '0 AS shares_24h' + ), DB::raw('COALESCE(tc.tag_count, 0) AS tag_count'), DB::raw('GREATEST(TIMESTAMPDIFF(HOUR, a.published_at, NOW()), 0) AS age_hours'), ]) @@ -338,5 +385,47 @@ final class RankingService ->where('a.is_approved', 1) ->whereNull('a.deleted_at') ->whereNotNull('a.published_at'); + + // V2: Comments (all-time + 24h) + if ($hasCommentsTable) { + $query->leftJoinSub( + DB::table('artwork_comments') + ->select('artwork_id', DB::raw('COUNT(*) as cnt')) + ->whereNull('deleted_at') + ->groupBy('artwork_id'), + 'cc_all', + 'cc_all.artwork_id', '=', 'a.id' + ); + $query->leftJoinSub( + DB::table('artwork_comments') + ->select('artwork_id', DB::raw('COUNT(*) as cnt')) + ->whereNull('deleted_at') + ->where('created_at', '>=', DB::raw('DATE_SUB(NOW(), INTERVAL 1 DAY)')) + ->groupBy('artwork_id'), + 'cc24', + 'cc24.artwork_id', '=', 'a.id' + ); + } + + // V2: Shares (all-time + 24h) + if ($hasSharesTable) { + $query->leftJoinSub( + DB::table('artwork_shares') + ->select('artwork_id', DB::raw('COUNT(*) as cnt')) + ->groupBy('artwork_id'), + 'sc_all', + 'sc_all.artwork_id', '=', 'a.id' + ); + $query->leftJoinSub( + DB::table('artwork_shares') + ->select('artwork_id', DB::raw('COUNT(*) as cnt')) + ->where('created_at', '>=', DB::raw('DATE_SUB(NOW(), INTERVAL 1 DAY)')) + ->groupBy('artwork_id'), + 'sc24', + 'sc24.artwork_id', '=', 'a.id' + ); + } + + return $query; } } diff --git a/config/ranking.php b/config/ranking.php index 71ad4a05..7a069b0c 100644 --- a/config/ranking.php +++ b/config/ranking.php @@ -11,7 +11,7 @@ declare(strict_types=1); return [ // ── Model versioning ──────────────────────────────────────────────────── - 'model_version' => 'rank_v1', + 'model_version' => 'rank_v2', // ── Engagement signal weights (log-scaled) ────────────────────────────── 'weights' => [ @@ -20,6 +20,36 @@ return [ 'downloads' => 2.5, ], + // ── V2 ranking formula weights ────────────────────────────────────────── + 'v2' => [ + // Base engagement weights (linear, applied to raw counts) + 'weights' => [ + 'views' => 0.2, + 'downloads' => 1.5, + 'favourites' => 2.5, + 'comments' => 3.0, + 'shares' => 4.0, // highest — viral intent + ], + + // 24h velocity weights + 'velocity_weights' => [ + 'views' => 1.0, + 'favourites' => 3.0, + 'comments' => 4.0, + 'shares' => 5.0, + ], + + // Velocity multiplier applied to the 24h velocity sum + 'velocity_multiplier' => 0.5, + + // Recency decay half-life in hours + 'half_life' => 48, + + // Author authority: multiplier = 1 + (authority * factor) + 'authority_factor' => 0.05, + 'authority_fav_divisor' => 1000, + ], + // ── Time-decay half-lives (hours) ─────────────────────────────────────── 'half_life' => [ 'trending' => 72, // Explore / global trending diff --git a/config/scout.php b/config/scout.php index 76a3be21..2df583a9 100644 --- a/config/scout.php +++ b/config/scout.php @@ -111,6 +111,10 @@ return [ 'favorites_count', 'awards_received_count', 'downloads_count', + 'ranking_score', + 'shares_count', + 'engagement_velocity', + 'comments_count', ], 'rankingRules' => [ 'words', diff --git a/database/migrations/2026_02_28_100000_add_ranking_v2_columns_to_artwork_stats.php b/database/migrations/2026_02_28_100000_add_ranking_v2_columns_to_artwork_stats.php new file mode 100644 index 00000000..2584abfa --- /dev/null +++ b/database/migrations/2026_02_28_100000_add_ranking_v2_columns_to_artwork_stats.php @@ -0,0 +1,54 @@ +unsignedBigInteger('comments_count')->default(0)->after('favorites'); + $table->unsignedBigInteger('shares_count')->default(0)->after('comments_count'); + + // Windowed counters for velocity calculation + $table->unsignedBigInteger('shares_24h')->default(0)->after('downloads_7d'); + $table->unsignedBigInteger('comments_24h')->default(0)->after('shares_24h'); + $table->unsignedBigInteger('favourites_24h')->default(0)->after('comments_24h'); + + // V2 computed scores + $table->double('ranking_score', 12, 4)->default(0)->after('rating_count'); + $table->double('engagement_velocity', 10, 4)->default(0)->after('ranking_score'); + + // Indexes for sorting + $table->index('ranking_score'); + $table->index('shares_count'); + }); + } + + public function down(): void + { + Schema::table('artwork_stats', function (Blueprint $table) { + $table->dropIndex(['ranking_score']); + $table->dropIndex(['shares_count']); + $table->dropColumn([ + 'comments_count', + 'shares_count', + 'shares_24h', + 'comments_24h', + 'favourites_24h', + 'ranking_score', + 'engagement_velocity', + ]); + }); + } +}; diff --git a/routes/console.php b/routes/console.php index 228d5b1f..8667d66d 100644 --- a/routes/console.php +++ b/routes/console.php @@ -63,3 +63,12 @@ Schedule::command('skinbase:prune-view-events --days=90') ->at('04:00') ->name('prune-view-events') ->withoutOverlapping(); + +// ── Ranking Engine V2 ────────────────────────────────────────────────────────── +// Recalculate ranking_score + engagement_velocity every 30 minutes. +// Also syncs V2 scores to rank_artwork_scores so list builds benefit. +Schedule::command('nova:recalculate-rankings --sync-rank-scores') + ->everyThirtyMinutes() + ->name('ranking-v2') + ->withoutOverlapping() + ->runInBackground();