Files
SkinbaseNova/app/Services/Ranking/ArtworkRankingService.php

533 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Services\Ranking;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
/**
* ArtworkRankingService — Skinbase Nova Ranking Engine V2
*
* Intelligent scoring system combining:
* 1. Base engagement (views, downloads, favourites, comments, shares)
* 2. Author authority multiplier (followers + favourites received)
* 3. Recency decay (half-life configurable, default 48h)
* 4. Engagement velocity (last-24h activity burst)
*
* Final formula:
* ranking_score = (base_score × authority_multiplier × decay_factor) + velocity_boost
*
* The service processes artworks in chunks and writes ranking_score +
* engagement_velocity to the artwork_stats table.
*
* Designed to run via `php artisan nova:recalculate-rankings` every 30 minutes.
*/
final class ArtworkRankingService
{
// ── Weight configuration keys ──────────────────────────────────────────
/**
* Default V2 weights — overridable via config('ranking.v2.weights.*')
*/
private const DEFAULT_WEIGHTS = [
'views' => 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', [])
);
}
}