65dd132b3d
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>
58 lines
2.0 KiB
Go
58 lines
2.0 KiB
Go
package recommendation
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestContextAffinity(t *testing.T) {
|
|
const baseline = 0.4 // 40% of the user's plays fall in the current cell
|
|
const k = contextAffinityShrinkage
|
|
|
|
// Heavy history, over-represented in the current cell → positive.
|
|
if a := contextAffinity(80, 100, baseline, k); a <= 0 {
|
|
t.Errorf("over-represented artist affinity = %.3f, want positive", a)
|
|
}
|
|
// Heavy history, under-represented → negative.
|
|
if a := contextAffinity(10, 100, baseline, k); a >= 0 {
|
|
t.Errorf("under-represented artist affinity = %.3f, want negative", a)
|
|
}
|
|
// A sparse artist (1/1) shrinks toward the baseline, so its affinity is
|
|
// smaller than a heavily-played artist with the same raw cell-share.
|
|
sparse := contextAffinity(1, 1, baseline, k)
|
|
heavy := contextAffinity(100, 100, baseline, k)
|
|
if sparse >= heavy {
|
|
t.Errorf("sparse (%.3f) should shrink below heavy (%.3f)", sparse, heavy)
|
|
}
|
|
// No plays → neutral.
|
|
if a := contextAffinity(0, 0, baseline, k); a != 0 {
|
|
t.Errorf("no plays affinity = %.3f, want 0", a)
|
|
}
|
|
// Result stays within [-1, 1].
|
|
for _, tc := range [][2]float64{{100, 100}, {0, 100}, {50, 50}} {
|
|
a := contextAffinity(tc[0], tc[1], baseline, k)
|
|
if a < -1 || a > 1 {
|
|
t.Errorf("affinity out of [-1,1]: %.3f", a)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestScore_ContextTermAddsAndSubtracts(t *testing.T) {
|
|
now := time.Now()
|
|
zeroJitter := func() float64 { return 0.5 } // (0.5*2-1)=0 with any magnitude
|
|
w := ScoringWeights{ContextTimeWeight: 2.0} // all other weights 0
|
|
|
|
pos := Score(ScoringInputs{ContextAffinityScore: 1.0}, w, now, zeroJitter)
|
|
if !almostEq(pos, 2.0) {
|
|
t.Errorf("positive context affinity: Score = %.3f, want 2.0", pos)
|
|
}
|
|
neg := Score(ScoringInputs{ContextAffinityScore: -1.0}, w, now, zeroJitter)
|
|
if !almostEq(neg, -2.0) {
|
|
t.Errorf("negative context affinity: Score = %.3f, want -2.0 (demotes)", neg)
|
|
}
|
|
off := Score(ScoringInputs{ContextAffinityScore: 1.0}, ScoringWeights{}, now, zeroJitter)
|
|
if !almostEq(off, 0.0) {
|
|
t.Errorf("ContextTimeWeight 0: Score = %.3f, want 0 (no effect)", off)
|
|
}
|
|
}
|