package recsettings import ( "testing" "time" "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" ) // The bug, reproduced as a ranking: a track that sounds nothing like the seed // but which the user liked and has not played lately used to OUTRANK a perfect // similarity match. Operator, 2026-09-10: "I was getting a seeming wide variety // of music from each one when I was hoping to stay in a certain neighborhood." // // This is the whole point of the songs_like profile, so it is asserted as // BEHAVIOUR — two candidates, which one wins — rather than by checking the // weight numbers. Numbers get retuned; this property must survive that. // // It also pins the contrast: daily_mix is EXPECTED to fail this. If both // profiles started ranking the same way, the split would have quietly become // pointless and nothing else would notice. func TestSongsLikeWeights_SimilarityBeatsAnUnrelatedLikedTrack(t *testing.T) { now := time.Now().UTC() stale := now.Add(-365 * 24 * time.Hour) // A perfect similarity match the user has never liked and played recently. // Everything except similarity is working against it. perfectMatch := recommendation.ScoringInputs{ SimilarityScore: 1.0, IsGeneralLiked: false, LastPlayedAt: &now, } // Nothing to do with the seed, but liked and long unplayed — every // seed-independent term in its favour. unrelatedFavourite := recommendation.ScoringInputs{ SimilarityScore: 0.0, IsGeneralLiked: true, LastPlayedAt: &stale, TasteMatchScore: 1.0, } // Jitter fixed at its midpoint so the comparison is about the weights. noJitter := func() float64 { return 0.5 } songsLike := ShippedSongsLikeWeights() matchScore := recommendation.Score(perfectMatch, songsLike, now, noJitter) favScore := recommendation.Score(unrelatedFavourite, songsLike, now, noJitter) if matchScore <= favScore { t.Errorf("songs_like ranks an unrelated liked track (%.3f) at or above a "+ "perfect similarity match (%.3f) — the mix will wander", favScore, matchScore) } // The contrast that makes the split worth having. If this ever passes, // daily_mix has been tightened into songs_like and one of them is redundant. daily := ShippedDailyMixWeights() dMatch := recommendation.Score(perfectMatch, daily, now, noJitter) dFav := recommendation.Score(unrelatedFavourite, daily, now, noJitter) if dMatch > dFav { t.Errorf("daily_mix now also puts similarity first (%.3f vs %.3f); the two "+ "profiles no longer differ, so songs_like is buying nothing", dMatch, dFav) } } // The property the songs_like numbers encode, stated independently of them: // the similarity term's range must exceed the combined range of every // seed-INDEPENDENT differentiator, so a closer match cannot be beaten on // likes, freshness and taste alone. // // BaseWeight is excluded deliberately — it is identical for every candidate // and so differentiates nothing. SkipPenalty is excluded because it only ever // pushes a candidate DOWN, and a skipped track should lose however similar. func TestSongsLikeWeights_SimilarityOutrangesEverySeedIndependentTerm(t *testing.T) { w := ShippedSongsLikeWeights() // TasteMatchScore and ContextAffinityScore are in [-1,+1]; recencyDecay is // in [0,1]; LikeBoost is all-or-nothing. seedIndependent := w.LikeBoost + w.RecencyWeight + w.TasteWeight + w.ContextTimeWeight + w.JitterMagnitude if w.SimilarityWeight <= seedIndependent { t.Errorf("SimilarityWeight %.2f does not outrange the seed-independent "+ "terms (%.2f) — likes/recency/taste can outvote sounding like the seed", w.SimilarityWeight, seedIndependent) } } // A scope that is seedable but not resettable is the half-wired shape this // guards: the profile appears in the admin card, the operator turns a knob, // and Reset then 404s on a scope the rest of the service knows about. func TestShippedWeightsFor_CoversEveryWeightProfile(t *testing.T) { for _, scope := range []string{ScopeRadio, ScopeDailyMix, ScopeSongsLike} { if _, ok := shippedWeightsFor(scope); !ok { t.Errorf("scope %q has no shipped defaults; it cannot be seeded or reset", scope) } } // Non-weight scopes must NOT resolve here, or Reset would treat the taste // singleton as a weight profile and write nonsense. for _, scope := range []string{ScopeTaste, ScopeDiscover, "nonsense"} { if _, ok := shippedWeightsFor(scope); ok { t.Errorf("scope %q resolved as a weight profile and is not one", scope) } } } // Guards the sync the comment in playlists/system.go asks for: the pre-push // literal there must match the shipped defaults here, or a build that has not // yet been reconciled ranks differently from one that has. func TestShippedSongsLikeWeights_AreDominatedBySimilarity(t *testing.T) { w := ShippedSongsLikeWeights() daily := ShippedDailyMixWeights() if w.SimilarityWeight <= daily.SimilarityWeight { t.Errorf("songs_like SimilarityWeight %.2f is not above daily_mix's %.2f", w.SimilarityWeight, daily.SimilarityWeight) } for _, tc := range []struct { name string songs, day float64 }{ {"LikeBoost", w.LikeBoost, daily.LikeBoost}, {"TasteWeight", w.TasteWeight, daily.TasteWeight}, {"RecencyWeight", w.RecencyWeight, daily.RecencyWeight}, } { if tc.songs >= tc.day { t.Errorf("songs_like %s (%.2f) is not demoted below daily_mix (%.2f); "+ "these are the seed-independent terms that made the mix wander", tc.name, tc.songs, tc.day) } } }