feat: Ranking Engine V2 — intelligent scoring with shares, authority, decay & velocity\n\n- Add ArtworkRankingService with V2 formula:\n ranking_score = (base × authority × decay) + velocity_boost\n Base: views×0.2 + downloads×1.5 + favourites×2.5 + comments×3.0 + shares×4.0\n Authority: 1 + (log10(1+followers) + fav_received/1000) × 0.05\n Decay: 1 / (1 + hours/48)\n Velocity: 24h signals × velocity_weights × 0.5\n\n- Add nova:recalculate-rankings command (--chunk, --sync-rank-scores, --skip-index)\n- Add migration: ranking_score, engagement_velocity, shares/comments counts to artwork_stats\n- Upgrade RankingService.computeScores() with shares + comments + velocity\n- Update Meilisearch sortableAttributes: ranking_score, shares_count, engagement_velocity, comments_count\n- Update toSearchableArray() to expose V2 fields\n- Schedule every 30 min with overlap protection\n- Verified: 49733 artworks scored successfully"

This commit is contained in:
2026-02-28 16:41:15 +01:00
parent 90f244f264
commit de3ec22ee5
10 changed files with 837 additions and 14 deletions

View File

@@ -0,0 +1,532 @@
<?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', [])
);
}
}