$eventMeta */ public function applyEvent( int $userId, string $eventType, int $artworkId, ?int $categoryId, CarbonInterface $occurredAt, string $eventId, string $algoVersion, array $eventMeta = [] ): void { $profileVersion = (string) config('discovery.profile_version', 'profile-v1'); $halfLifeHours = (float) config('discovery.decay.half_life_hours', 72); $weightMap = (array) config('discovery.weights', []); $eventWeight = (float) ($weightMap[$eventType] ?? 1.0); DB::transaction(function () use ( $userId, $categoryId, $artworkId, $occurredAt, $eventId, $algoVersion, $profileVersion, $halfLifeHours, $eventWeight, $eventMeta ): void { $profile = UserInterestProfile::query() ->where('user_id', $userId) ->where('profile_version', $profileVersion) ->where('algo_version', $algoVersion) ->lockForUpdate() ->first(); $rawScores = $profile !== null ? (array) ($profile->raw_scores_json ?? []) : []; $lastEventAt = $profile?->last_event_at; if ($lastEventAt !== null && $occurredAt->greaterThan($lastEventAt)) { $hours = max(0.0, (float) $lastEventAt->diffInSeconds($occurredAt) / 3600); $rawScores = $this->applyRecencyDecay($rawScores, $hours, $halfLifeHours); } $interestKey = $categoryId !== null ? sprintf('category:%d', $categoryId) : sprintf('artwork:%d', $artworkId); $rawScores[$interestKey] = (float) ($rawScores[$interestKey] ?? 0.0) + $eventWeight; $rawScores = array_filter( $rawScores, static fn (mixed $value): bool => is_numeric($value) && (float) $value > 0.000001 ); $normalizedScores = $this->normalizeScores($rawScores); $totalWeight = array_sum($rawScores); $payload = [ 'user_id' => $userId, 'profile_version' => $profileVersion, 'algo_version' => $algoVersion, 'raw_scores_json' => $rawScores, 'normalized_scores_json' => $normalizedScores, 'total_weight' => $totalWeight, 'event_count' => $profile !== null ? ((int) $profile->event_count + 1) : 1, 'last_event_at' => $lastEventAt === null || $occurredAt->greaterThan($lastEventAt) ? $occurredAt : $lastEventAt, 'half_life_hours' => $halfLifeHours, 'updated_from_event_id' => $eventId, 'updated_at' => now(), ]; if ($profile === null) { $payload['created_at'] = now(); UserInterestProfile::query()->create($payload); return; } $profile->fill($payload); $profile->save(); }, 3); } /** * @param array $scores * @return array */ public function applyRecencyDecay(array $scores, float $hoursElapsed, float $halfLifeHours): array { if ($hoursElapsed <= 0 || $halfLifeHours <= 0) { return $this->castToFloatScores($scores); } $decayFactor = exp(-log(2) * ($hoursElapsed / $halfLifeHours)); $output = []; foreach ($scores as $key => $score) { if (! is_numeric($score)) { continue; } $decayed = (float) $score * $decayFactor; if ($decayed > 0.000001) { $output[(string) $key] = $decayed; } } return $output; } /** * @param array $scores * @return array */ public function normalizeScores(array $scores): array { $typedScores = $this->castToFloatScores($scores); $sum = array_sum($typedScores); if ($sum <= 0.0) { return []; } $normalized = []; foreach ($typedScores as $key => $score) { $normalized[$key] = $score / $sum; } return $normalized; } /** * @param array $scores * @return array */ private function castToFloatScores(array $scores): array { $output = []; foreach ($scores as $key => $score) { if (is_numeric($score) && (float) $score > 0.0) { $output[(string) $key] = (float) $score; } } return $output; } }