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', []) ); } }