onQueue($queue); } } public function handle(): void { $favCap = (int) config('recommendations.similarity.user_favourites_cap', 50); // ── Pre-compute per-artwork total favourite counts for cosine normalization ── $this->artworkLikeCounts = DB::table('artwork_favourites') ->select('artwork_id', DB::raw('COUNT(*) as cnt')) ->groupBy('artwork_id') ->pluck('cnt', 'artwork_id') ->all(); // ── Accumulate co-occurrence counts across all users ── $coOccurrenceCounts = []; DB::table('artwork_favourites') ->select('user_id') ->groupBy('user_id') ->orderBy('user_id') ->chunk($this->userBatchSize, function ($userRows) use ($favCap, &$coOccurrenceCounts) { foreach ($userRows as $row) { $pairs = $this->pairsForUser((int) $row->user_id, $favCap); foreach ($pairs as $pair) { $key = $pair[0] . ':' . $pair[1]; $coOccurrenceCounts[$key] = ($coOccurrenceCounts[$key] ?? 0) + 1; } } }); // ── Normalize to cosine-like scores and flush ── $normalized = []; foreach ($coOccurrenceCounts as $key => $count) { [$a, $b] = explode(':', $key); $likesA = $this->artworkLikeCounts[(int) $a] ?? 1; $likesB = $this->artworkLikeCounts[(int) $b] ?? 1; $normalized[$key] = $count / sqrt($likesA * $likesB); } $this->flushPairs($normalized); } /** @var array artwork_id => total favourite count */ private array $artworkLikeCounts = []; /** * Collect pairs from a single user's last N favourites. * * @return list */ public function pairsForUser(int $userId, int $cap): array { $artworkIds = DB::table('artwork_favourites') ->where('user_id', $userId) ->orderByDesc('created_at') ->limit($cap) ->pluck('artwork_id') ->map(fn ($id) => (int) $id) ->all(); $count = count($artworkIds); if ($count < 2) { return []; } $pairs = []; // Cap max pairs per user to avoid explosion: C(50,2) = 1225 worst case = acceptable for ($i = 0; $i < $count - 1; $i++) { for ($j = $i + 1; $j < $count; $j++) { $a = min($artworkIds[$i], $artworkIds[$j]); $b = max($artworkIds[$i], $artworkIds[$j]); $pairs[] = [$a, $b]; } } return $pairs; } /** * Upsert normalized pair weights into rec_item_pairs. * * Uses Laravel's DB-agnostic upsert (works on MySQL, Postgres, SQLite). * * @param array $upserts key = "a:b", value = cosine-normalized weight */ private function flushPairs(array $upserts): void { if ($upserts === []) { return; } $now = now(); foreach (array_chunk($upserts, 500, preserve_keys: true) as $chunk) { $rows = []; foreach ($chunk as $key => $weight) { [$a, $b] = explode(':', $key); $rows[] = [ 'a_artwork_id' => (int) $a, 'b_artwork_id' => (int) $b, 'weight' => $weight, 'updated_at' => $now, ]; } DB::table('rec_item_pairs')->upsert( $rows, ['a_artwork_id', 'b_artwork_id'], ['weight', 'updated_at'], ); } } }