Files
minstrel/internal/recommendation/score.go
T
bvandeusen 65dd132b3d
test-go / test (push) Successful in 35s
test-web / test (push) Successful in 42s
test-go / integration (push) Successful in 4m46s
feat(taste): time-of-day / weekday context conditioning — #1531
Milestone #160 Opt 3 (temporal half). A new additive scoring term that
boosts a candidate when its artist's play history concentrates in the
CURRENT daypart × weekday-type cell, in the user's local timezone.

- Migration 0046: recommendation_weight_profiles.context_time_weight
  (per-profile scoring weight, DEFAULT 1.0).
- Query ListArtistContextPlayCountsForUser: per-artist completed-play
  counts split by the current cell (daypart night[22,5)/morning[5,12)/
  afternoon[12,17)/evening[17,22) × weekday-vs-weekend) via
  started_at AT TIME ZONE users.timezone; 365-day window, skips excluded.
- internal/recommendation/context.go: LoadContextAffinity computes each
  artist's shrunk cell-share minus the user's baseline share, clamped to
  [-1,1]; sparse artists shrink toward baseline (pseudo-count 5), unknown
  artists → 0 (cold-start neutral).
- Score() gains context_affinity_score · ContextTimeWeight; both
  candidate loaders set it per candidate.
- Tuning lab: ContextTimeWeight threaded through recsettings + admin API
  + web card ("Time-of-day weight" row) + Go/web tests. Shipped 1.0 both
  profiles (uniform start, re-bakeable).

Device-class axis deferred to #1551 (needs a client_id → device-class
mapping that doesn't exist yet).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 09:31:43 -04:00

108 lines
3.9 KiB
Go
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.
// Package recommendation implements the weighted-shuffle scoring engine
// from spec §6. The Score function is pure and takes an injectable RNG so
// tests can pin jitter to deterministic values.
package recommendation
import (
"time"
)
// ScoringInputs are the per-track facts the score function consumes.
// ContextualMatchScore is in [0, 1] — max similarity between the user's
// current session vector and any non-seed contextual_like row for this
// track. Set by LoadCandidates after a bulk fetch.
// SimilarityScore is in [0, 1]; 0 when no signal (random fill).
type ScoringInputs struct {
IsGeneralLiked bool
LastPlayedAt *time.Time // nil = never played
PlayCount int // total play_events
SkipCount int // play_events with was_skipped=true
ContextualMatchScore float64 // [0, 1]; 0 when no signal
SimilarityScore float64 // [0, 1]; 0 when no signal (random fill)
// TasteMatchScore is the candidate's fit to the user's learned taste
// profile (#796 phase 2), in [-1, +1]: positive draws a track toward the
// user's taste, negative reflects passive avoidance, 0 when there's no
// profile signal (cold start / artist+tags absent from the profile).
TasteMatchScore float64
// ContextAffinityScore is the candidate artist's time-of-day/weekday
// affinity for the CURRENT context (#1531), in [-1, +1]: positive when the
// artist's plays concentrate in the current daypart × weekday-type cell
// more than the user's baseline, negative when under-represented, 0 when
// there's no history (cold-start neutral).
ContextAffinityScore float64
}
// ScoringWeights are the operator-tunable knobs. Defaults live in
// config.RecommendationConfig and are propagated here per request.
type ScoringWeights struct {
BaseWeight float64
LikeBoost float64
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64
SimilarityWeight float64
TasteWeight float64
ContextTimeWeight float64
}
// Score computes the weighted-shuffle score per spec §6:
//
// score = base
// + (is_general_liked ? LikeBoost : 0)
// + recency_decay * RecencyWeight
// - skip_ratio * SkipPenalty
// + contextual_match_score * ContextWeight
// + similarity_score * SimilarityWeight
// + taste_match_score * TasteWeight
// + context_affinity_score * ContextTimeWeight
// + small_random_jitter
//
// Higher score = more likely to surface. rng is a function returning a
// uniform sample in [0,1) — pass math/rand.Float64 in production, a fixed
// value in tests.
func Score(in ScoringInputs, w ScoringWeights, now time.Time, rng func() float64) float64 {
s := w.BaseWeight
if in.IsGeneralLiked {
s += w.LikeBoost
}
s += recencyDecay(in.LastPlayedAt, now) * w.RecencyWeight
s -= skipRatio(in.PlayCount, in.SkipCount) * w.SkipPenalty
s += in.ContextualMatchScore * w.ContextWeight
s += in.SimilarityScore * w.SimilarityWeight
s += in.TasteMatchScore * w.TasteWeight
s += in.ContextAffinityScore * w.ContextTimeWeight
s += (rng()*2 - 1) * w.JitterMagnitude
return s
}
// recencyDecay returns a value in [0, 1]:
// - never played → 1.0 (cold-start tracks compete favorably with stale ones).
// - age < 30 days → linear ramp age_days / 30.
// - age ≥ 30 days → 1.0 (capped).
//
// Negative ages (clock skew) clamp to 0 to avoid math weirdness.
func recencyDecay(lastPlayed *time.Time, now time.Time) float64 {
if lastPlayed == nil {
return 1.0
}
age := now.Sub(*lastPlayed)
days := age.Hours() / 24
if days < 0 {
return 0.0
}
if days >= 30 {
return 1.0
}
return days / 30.0
}
// skipRatio returns skips/plays in [0, 1]; never-played tracks return 0
// rather than dividing by zero, so they aren't penalized.
func skipRatio(plays, skips int) float64 {
if plays == 0 {
return 0.0
}
return float64(skips) / float64(plays)
}