diff --git a/internal/api/admin_recommendation_tuning.go b/internal/api/admin_recommendation_tuning.go index 918a3b55..2d52633e 100644 --- a/internal/api/admin_recommendation_tuning.go +++ b/internal/api/admin_recommendation_tuning.go @@ -97,14 +97,16 @@ type tuningSnapshot struct { func (h *handlers) tuningSnapshot() tuningSnapshot { var out tuningSnapshot out.Profiles = map[string]weightsResp{ - recsettings.ScopeRadio: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeRadio)), - recsettings.ScopeDailyMix: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeDailyMix)), + recsettings.ScopeRadio: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeRadio)), + recsettings.ScopeDailyMix: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeDailyMix)), + recsettings.ScopeSongsLike: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeSongsLike)), } out.Taste = tasteRespFrom(h.recSettings.Taste()) out.Discover = discoverRespFrom(h.recSettings.Discover()) out.Shipped.Profiles = map[string]weightsResp{ - recsettings.ScopeRadio: weightsRespFrom(recsettings.ShippedRadioWeights()), - recsettings.ScopeDailyMix: weightsRespFrom(recsettings.ShippedDailyMixWeights()), + recsettings.ScopeRadio: weightsRespFrom(recsettings.ShippedRadioWeights()), + recsettings.ScopeDailyMix: weightsRespFrom(recsettings.ShippedDailyMixWeights()), + recsettings.ScopeSongsLike: weightsRespFrom(recsettings.ShippedSongsLikeWeights()), } out.Shipped.Taste = tasteRespFrom(recsettings.ShippedTasteTuning()) out.Shipped.Discover = discoverRespFrom(recsettings.ShippedDiscoverTuning()) diff --git a/internal/db/migrations/0057_songs_like_tuning.down.sql b/internal/db/migrations/0057_songs_like_tuning.down.sql new file mode 100644 index 00000000..96070ff5 --- /dev/null +++ b/internal/db/migrations/0057_songs_like_tuning.down.sql @@ -0,0 +1,16 @@ +-- Drop the rows the narrower constraints are about to forbid, or re-adding +-- them fails against existing data (the 0051 down-migration pattern). +DELETE FROM recommendation_weight_profiles WHERE profile = 'songs_like'; +DELETE FROM recommendation_tuning_audit WHERE scope = 'songs_like'; + +ALTER TABLE recommendation_tuning_audit + DROP CONSTRAINT recommendation_tuning_audit_scope_check; +ALTER TABLE recommendation_tuning_audit + ADD CONSTRAINT recommendation_tuning_audit_scope_check + CHECK (scope IN ('radio', 'daily_mix', 'taste', 'discover')); + +ALTER TABLE recommendation_weight_profiles + DROP CONSTRAINT recommendation_weight_profiles_profile_check; +ALTER TABLE recommendation_weight_profiles + ADD CONSTRAINT recommendation_weight_profiles_profile_check + CHECK (profile IN ('radio', 'daily_mix')); diff --git a/internal/db/migrations/0057_songs_like_tuning.up.sql b/internal/db/migrations/0057_songs_like_tuning.up.sql new file mode 100644 index 00000000..cf8d6c1e --- /dev/null +++ b/internal/db/migrations/0057_songs_like_tuning.up.sql @@ -0,0 +1,37 @@ +-- 0057_songs_like_tuning.up.sql — a THIRD weight profile, for Songs-like +-- (Scribe #3881, milestone #398). +-- +-- Songs-like shared the `daily_mix` profile with For-You, and that is the bug. +-- The two surfaces want opposite things: For-You is a broad "what will they +-- enjoy today", Songs-like answers "what sounds like THIS", and under one set +-- of weights the broad answer wins. Operator, 2026-09-10: "I'm expecting to +-- get a consistent sound and style from the experience... I was getting a +-- seeming wide variety of music from each one when I was hoping to stay in a +-- certain neighborhood." +-- +-- Under the shared daily_mix weights, an UNRELATED track the user had liked +-- and not played recently scored 1.0 + 2.0 + 1.0 = 4.0 before taste, while a +-- PERFECT similarity match they had not liked scored 1.0 + 1.5 = 2.5. Liking +-- something outranked sounding like the seed. Splitting the profile is what +-- lets similarity dominate here without making For-You narrow. +-- +-- Rows are seeded by the recsettings boot reconcile, not here, so shipped +-- defaults live in exactly one place (Go) — same as 0040. + +-- Rule #36: a new value for a CHECK-gated column needs the constraint +-- rewritten in the SAME change, or the first row written under the new +-- profile fails at runtime rather than at migrate time. +ALTER TABLE recommendation_weight_profiles + DROP CONSTRAINT recommendation_weight_profiles_profile_check; +ALTER TABLE recommendation_weight_profiles + ADD CONSTRAINT recommendation_weight_profiles_profile_check + CHECK (profile IN ('radio', 'daily_mix', 'songs_like')); + +-- The audit table gates the same name on a separate constraint. Missing this +-- one would let the profile be seeded and then fail on the first knob turn — +-- green at boot, 500 on first use. +ALTER TABLE recommendation_tuning_audit + DROP CONSTRAINT recommendation_tuning_audit_scope_check; +ALTER TABLE recommendation_tuning_audit + ADD CONSTRAINT recommendation_tuning_audit_scope_check + CHECK (scope IN ('radio', 'daily_mix', 'taste', 'discover', 'songs_like')); diff --git a/internal/playlists/system.go b/internal/playlists/system.go index d12a1e3e..08ef2186 100644 --- a/internal/playlists/system.go +++ b/internal/playlists/system.go @@ -219,6 +219,24 @@ var ( // uniform with radio pending trend data. ContextTimeWeight: 1.0, } + // Songs-like's own profile (#3881). Pre-push literal only; shipped + // defaults live in recsettings.ShippedSongsLikeWeights and must stay in + // sync with it, exactly as systemMixWeights does above. + // + // SimilarityWeight dominates here and every seed-INDEPENDENT term is + // demoted, which is the whole difference between this surface and For-You. + // See ShippedSongsLikeWeights for the property the numbers encode. + songsLikeWeights = recommendation.ScoringWeights{ + BaseWeight: 1.0, + LikeBoost: 0.5, + RecencyWeight: 0.25, + SkipPenalty: 2.0, + JitterMagnitude: 0.05, + ContextWeight: 0.5, + SimilarityWeight: 4.0, + TasteWeight: 0.25, + ContextTimeWeight: 0.5, + } systemTasteConfig = taste.DefaultConfig() ) @@ -230,6 +248,20 @@ func SetSystemMixWeights(w recommendation.ScoringWeights) { systemMixWeights = w } +// SetSongsLikeWeights installs the songs_like scoring profile (#3881). +// Same push model as SetSystemMixWeights — recsettings calls it on boot and +// after every knob turn, so a tuning change takes effect on the next daily +// build with no restart. +// +// Separate from systemMixWeights because Songs-like and For-You want opposite +// things: For-You roams, Songs-like must not. Sharing one profile is what made +// "Songs like X" wander. +func SetSongsLikeWeights(w recommendation.ScoringWeights) { + systemTuningMu.Lock() + defer systemTuningMu.Unlock() + songsLikeWeights = w +} + // SetTasteConfig installs the taste-profile build configuration // (half-life + engagement curve, #1250). Same push model as // SetSystemMixWeights. @@ -239,6 +271,12 @@ func SetTasteConfig(c taste.Config) { systemTasteConfig = c } +func currentSongsLikeWeights() recommendation.ScoringWeights { + systemTuningMu.RLock() + defer systemTuningMu.RUnlock() + return songsLikeWeights +} + func currentSystemMixWeights() recommendation.ScoringWeights { systemTuningMu.RLock() defer systemTuningMu.RUnlock() @@ -392,7 +430,13 @@ func pickWeightedTail(tailPool []recommendation.Candidate, dateStr string, tailN // tieBreakHash). The scoring RNG is seeded by userIDHash so jitter is // deterministic per (user, day) but rotates across days. Pure — no // truncation, no cap. -func scoreAndSortCandidates(cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time) []recommendation.Candidate { +// weights is a parameter rather than a read of currentSystemMixWeights() +// because this sort IS the selection: the caller caps and truncates in the +// order this returns, so whatever profile ranks here decides which tracks +// reach the playlist. Scoring with daily_mix here and re-scoring with +// songs_like afterwards would have let the new profile relabel tracks it had +// no part in choosing — inert where it matters (#3881). +func scoreAndSortCandidates(cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time, weights recommendation.ScoringWeights) []recommendation.Candidate { rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr)))) type scored struct { c recommendation.Candidate @@ -411,7 +455,6 @@ func scoreAndSortCandidates(cands []recommendation.Candidate, userID pgtype.UUID sort.SliceStable(ordered, func(i, j int) bool { return uuidLessPL(ordered[i].Track.ID, ordered[j].Track.ID) }) - weights := currentSystemMixWeights() pairs := make([]scored, len(ordered)) for i, c := range ordered { pairs[i] = scored{c: c, score: recommendation.Score(c.Inputs, weights, now, rng.Float64)} @@ -647,9 +690,13 @@ func produceSeedMixes( continue } zeroVec := recommendation.SessionVector{Seed: true} + // Songs-like's own pool shape, not the default (#3881). Same total + // size; the composition shifts toward arms that actually measure + // distance from the seed. The default gave ~29% of candidates a + // sim_score of literally 0. cands, cerr := recommendation.LoadCandidatesFromSimilarity( ctx, q, userID, seedTrack, 1, zeroVec, []pgtype.UUID{seedTrack}, - recommendation.DefaultCandidateSourceLimits(), + recommendation.SongsLikeCandidateSourceLimits(), ) if cerr != nil { logger.Warn("system playlist: seed candidates load failed; skipping", @@ -838,13 +885,20 @@ func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog. // truncates to n. Used by Songs-like-X (and as the fallback inside // pickHeadAndTail for small pools). func pickTopN(cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time, n int) []rankedCandidate { - sorted := scoreAndSortCandidates(cands, userID, dateStr, now) + // songs_like, not daily_mix (#3881). produceSeedMixes is this function's + // only caller, so the switch moves exactly one surface — For-You ranks + // through pickHeadAndTail and keeps the broader daily_mix profile. + // + // The SAME profile does the selection sort and the final score. Passing + // one and using the other is the subtle version of this bug: the playlist + // would still be chosen by daily_mix and merely wear songs_like numbers. + weights := currentSongsLikeWeights() + sorted := scoreAndSortCandidates(cands, userID, dateStr, now, weights) capped := capCandidatesByAlbumAndArtist(sorted) if len(capped) > n { capped = capped[:n] } rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr)))) - weights := currentSystemMixWeights() out := make([]rankedCandidate, len(capped)) for i, c := range capped { out[i] = rankedCandidate{ @@ -873,10 +927,11 @@ func pickHeadAndTail( cands []recommendation.Candidate, seedOf map[pgtype.UUID]int, numSeeds int, userID pgtype.UUID, dateStr string, now time.Time, headN, tailN int, ) []rankedCandidate { - sorted := scoreAndSortCandidates(cands, userID, dateStr, now) + // daily_mix — For-You is the broad surface and keeps the roaming profile. + weights := currentSystemMixWeights() + sorted := scoreAndSortCandidates(cands, userID, dateStr, now, weights) capped := capCandidatesByAlbumAndArtist(sorted) rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr)))) - weights := currentSystemMixWeights() total := headN + tailN if len(capped) <= total { diff --git a/internal/recommendation/candidates.go b/internal/recommendation/candidates.go index ad6531e4..d5f2de86 100644 --- a/internal/recommendation/candidates.go +++ b/internal/recommendation/candidates.go @@ -105,6 +105,59 @@ func DefaultCandidateSourceLimits() CandidateSourceLimits { } } +// SongsLikeCandidateSourceLimits is the pool shape for "Songs like {X}" +// (#3881). Same total size as the default (~170) — the composition is what +// changes, shifted hard toward arms that actually measure distance from the +// seed. +// +// The surface answers "what sounds like THIS", and it shared the default +// pool with For-You, which answers the much broader "what will they enjoy +// today". Under the default, 50 of ~170 candidates carried sim_score = 0 by +// construction — `taste_overlap` (tracks by the user's top taste artists) and +// `random_fill` (literally any track not already in the pool), both of which +// are seed-INDEPENDENT. Nearly a third of the pool had no relationship to the +// seed at all, and the operator saw it: "I was getting a seeming wide variety +// of music from each one when I was hoping to stay in a certain neighborhood." +// +// TIERED, per rule 131 — a system mix degrades, it never vanishes: +// +// tier 1 lb_similar real track-level similarity. The exact promise. +// tier 2 similar_artist, tag_overlap, coplay, likes_overlap +// seed-RELATED but weaker signal. +// tier 3 taste_overlap, random_fill +// seed-independent. The floor, and nothing more. +// +// The tiering is enforced by SCORE rather than by a fallback ladder: tier-3 +// arms carry sim_score 0, so under SongsLike weights (SimilarityWeight 4.0, +// everything seed-independent demoted) they rank below any real match and +// surface only when tiers 1–2 cannot fill the mix. That is the rule's +// "fill from tier 1 first, reach down only when a tier cannot fill". +// +// Which is exactly why tier 3 is REDUCED rather than removed. Zeroing those +// two arms was the first instinct and it is the vanish-or-nothing shape rule +// 131 exists to forbid: a seed whose artist has thin ListenBrainz coverage +// would produce a short mix or none at all, and "no playlist" is a worse +// answer than "a few tracks further from the seed than we would like". +// +// likes_overlap is cut hardest of the tier-2 arms for a specific reason: its +// SQL assigns a FLAT 0.6 sim_score (recommendation.sql:108) rather than +// measuring anything. It is a collaborative signal wearing similarity's +// clothes, and raising SimilarityWeight amplifies it — if real ListenBrainz +// scores commonly land below 0.6 it would outrank genuine matches. Halved +// here pending the fill-rate measurement in #3879; the honest fix is to stop +// it claiming a similarity score it did not compute. +func SongsLikeCandidateSourceLimits() CandidateSourceLimits { + return CandidateSourceLimits{ + LBSimilar: 60, // tier 1 — doubled; the only arm that measures the seed + SimilarArtist: 40, // tier 2 + TagOverlap: 20, // tier 2 + UserCoplay: 20, // tier 2 + LikesOverlap: 10, // tier 2, halved — flat 0.6, see above + TasteOverlap: 10, // tier 3 floor — halved, not removed + RandomFill: 10, // tier 3 floor — cut hard, never to zero + } +} + // LoadCandidatesFromSimilarity is M4c's primary candidate-pool loader. // 5-way SQL UNION (LB-similar / similar-artist tracks / MB-tag overlap / // likes-overlap / random fill) + dedup-by-max sim_score. Returns diff --git a/internal/recommendation/songs_like_limits_test.go b/internal/recommendation/songs_like_limits_test.go new file mode 100644 index 00000000..8e8bce87 --- /dev/null +++ b/internal/recommendation/songs_like_limits_test.go @@ -0,0 +1,61 @@ +package recommendation + +import "testing" + +// Songs-like's pool must lean on arms that MEASURE distance from the seed. +// The default gave ~29% of candidates a sim_score of literally 0 +// (taste_overlap and random_fill are both `0.0::float8` in +// recommendation.sql), which is what let "Songs like X" wander. +func TestSongsLikeLimits_FavourTheArmsThatMeasureTheSeed(t *testing.T) { + d := DefaultCandidateSourceLimits() + s := SongsLikeCandidateSourceLimits() + + if s.LBSimilar <= d.LBSimilar { + t.Errorf("LBSimilar %d is not above the default %d — the only arm that "+ + "measures track-level distance from the seed should be favoured here", + s.LBSimilar, d.LBSimilar) + } + // The two seed-INDEPENDENT arms, which is the whole complaint. + zeroSimDefault := d.TasteOverlap + d.RandomFill + zeroSimSongsLike := s.TasteOverlap + s.RandomFill + if zeroSimSongsLike >= zeroSimDefault { + t.Errorf("seed-independent arms total %d, not reduced from the default %d; "+ + "these carry sim_score 0 by construction", zeroSimSongsLike, zeroSimDefault) + } +} + +// RULE 131: a system playlist degrades, it never vanishes. +// +// Zeroing the seed-independent arms was the first instinct and is exactly the +// vanish-or-nothing shape that rule forbids: a seed whose artist has thin +// ListenBrainz coverage would yield a short mix or none at all. They are the +// tier-3 FLOOR — reduced hard, never removed — and the songs_like weights are +// what keep them at the bottom of the ranking rather than out of the pool. +// +// "A few tracks further from the seed than we would like" beats "no playlist". +func TestSongsLikeLimits_KeepATierThreeFloor(t *testing.T) { + s := SongsLikeCandidateSourceLimits() + if s.RandomFill <= 0 { + t.Error("RandomFill is zero: a seed with thin similarity coverage now produces " + + "a short or empty mix instead of degrading (rule 131)") + } + if s.TasteOverlap <= 0 { + t.Error("TasteOverlap is zero: the graded floor is gone, leaving only random " + + "fill between a sparse seed and an empty playlist (rule 131)") + } +} + +// The pool should stay roughly the size it was — this change is about +// COMPOSITION, not about starving the surface. A much smaller pool would also +// shrink what the per-artist cap has to work with. +func TestSongsLikeLimits_KeepThePoolRoughlyTheSameSize(t *testing.T) { + total := func(l CandidateSourceLimits) int { + return l.LBSimilar + l.SimilarArtist + l.TagOverlap + l.LikesOverlap + + l.RandomFill + l.TasteOverlap + l.UserCoplay + } + d, s := total(DefaultCandidateSourceLimits()), total(SongsLikeCandidateSourceLimits()) + if s < d/2 { + t.Errorf("songs_like pool is %d against the default %d — less than half; "+ + "this was meant to re-weight the pool, not starve it", s, d) + } +} diff --git a/internal/recsettings/service.go b/internal/recsettings/service.go index 29b41d14..354cddb8 100644 --- a/internal/recsettings/service.go +++ b/internal/recsettings/service.go @@ -40,6 +40,13 @@ const ( // never be read as taste signal (#2374) — filing it under taste would put // it one careless join from the leak that design forbids. ScopeDiscover = "discover" + // ScopeSongsLike is the "Songs like {X}" surface (#3881). It shared + // daily_mix with For-You until 2026-09-10, and that sharing WAS the bug: + // the two surfaces want opposite things. For-You answers "what will they + // enjoy today" and is supposed to roam; Songs-like answers "what sounds + // like THIS" and is the tightest surface in the product. One set of + // weights cannot serve both, and the broad answer was winning. + ScopeSongsLike = "songs_like" ) // TasteTuning is the tunable subset of taste.Config: the engagement @@ -92,6 +99,76 @@ func ShippedDailyMixWeights() recommendation.ScoringWeights { } } +// ShippedSongsLikeWeights are the shipped songs_like-profile defaults +// (#3881). The whole point is that SIMILARITY DOMINATES; every other +// profile balances it against taste and engagement, and this one must not. +// +// The failure being corrected, arithmetic from the daily_mix profile that +// this surface used to share: +// +// unrelated track, liked, not played recently → 1.0 + 2.0 + 1.0 = 4.0 +// PERFECT similarity match, not liked → 1.0 + 1.5 = 2.5 +// +// Liking something outranked sounding like the seed, because LikeBoost (2.0) +// exceeded SimilarityWeight's entire range (1.5) and TasteWeight (1.5, and +// seed-independent) matched it outright. +// +// THE PROPERTY THESE NUMBERS ENCODE, which is what to preserve if they are +// retuned: the similarity term's range must exceed the combined range of +// every seed-INDEPENDENT differentiator, so that a closer match cannot be +// beaten on the strength of likes, freshness and taste alone. +// +// seed-independent spread = LikeBoost 0.5 + Recency 0.25 +// + Taste 0.25 + ContextTime 0.5 +// + jitter 0.05 = 1.55 +// similarity spread = 0 → 4.0 +// +// So a similarity advantage of ~0.39 (1.55/4.0) wins outright regardless of +// everything else, while tracks within that band still get ordered by what +// the user likes and has not heard lately. Tight, not deaf. +// +// BaseWeight stays 1.0: it is identical for every candidate and so +// differentiates nothing — it sets the floor, not the shape. SkipPenalty +// stays 2.0 because a track the user skips is still unwanted no matter how +// similar it is. +// +// These are DEFAULTS, not settings (rule 25) — the operator turns them in the +// admin tuning card and good values get baked back here. They are a +// defensible starting point rather than a measured optimum: the per-arm fill +// rates and the real sim_score distribution are still unknown (#3879), and +// `likes_overlap` contributes a flat 0.6 that a high SimilarityWeight +// amplifies. Expect to move these once that lands. +func ShippedSongsLikeWeights() recommendation.ScoringWeights { + return recommendation.ScoringWeights{ + BaseWeight: 1.0, // same for all candidates; differentiates nothing + LikeBoost: 0.5, // was 2.0 — a tie-break among similar tracks, not an override + RecencyWeight: 0.25, // was 1.0 — freshness must not outrank sounding right + SkipPenalty: 2.0, // unchanged — a skipped track stays unwanted + JitterMagnitude: 0.05, // was 0.1 — less shuffle on a coherence surface + ContextWeight: 0.5, + SimilarityWeight: 4.0, // was 1.5 — dominant, by design + TasteWeight: 0.25, // was 1.5 — seed-INDEPENDENT, so demoted hard + ContextTimeWeight: 0.5, // was 1.0 + } +} + +// shippedWeightsFor returns the shipped defaults for a weight-profile scope, +// or ok=false if the scope is not a weight profile. Single source for the +// three call sites (seed, update-validation, reset) so adding a fourth +// profile cannot be half-wired — which is how a scope ends up seedable but +// not resettable. +func shippedWeightsFor(scope string) (recommendation.ScoringWeights, bool) { + switch scope { + case ScopeRadio: + return ShippedRadioWeights(), true + case ScopeDailyMix: + return ShippedDailyMixWeights(), true + case ScopeSongsLike: + return ShippedSongsLikeWeights(), true + } + return recommendation.ScoringWeights{}, false +} + // DiscoverTuning is the tunable set for the Discover request surface (#2377). type DiscoverTuning struct { // TagOverlapWeight scales the taste-tag term: score × (1 + w × overlap). @@ -165,8 +242,9 @@ func New(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) (*Service func (s *Service) reconcile(ctx context.Context) error { q := dbq.New(s.pool) for profile, w := range map[string]recommendation.ScoringWeights{ - ScopeRadio: ShippedRadioWeights(), - ScopeDailyMix: ShippedDailyMixWeights(), + ScopeRadio: ShippedRadioWeights(), + ScopeDailyMix: ShippedDailyMixWeights(), + ScopeSongsLike: ShippedSongsLikeWeights(), } { if err := q.UpsertWeightProfileDefaults(ctx, upsertParams(profile, w)); err != nil { return fmt.Errorf("seed profile %q: %w", profile, err) @@ -234,6 +312,7 @@ func (s *Service) reconcile(ctx context.Context) error { // reads Weights(ScopeRadio) per request. func (s *Service) push() { playlists.SetSystemMixWeights(s.Weights(ScopeDailyMix)) + playlists.SetSongsLikeWeights(s.Weights(ScopeSongsLike)) playlists.SetTasteConfig(s.TasteConfig()) } @@ -297,7 +376,7 @@ type fieldChange struct { // Unknown fields and out-of-range values reject the whole patch. A // no-op patch (all values equal to current) writes no audit row. func (s *Service) UpdateProfile(ctx context.Context, profile string, patch map[string]float64) error { - if profile != ScopeRadio && profile != ScopeDailyMix { + if _, ok := shippedWeightsFor(profile); !ok { return fmt.Errorf("%w: %q", ErrUnknownScope, profile) } current := s.Weights(profile) @@ -340,17 +419,18 @@ func (s *Service) UpdateDiscover(ctx context.Context, patch map[string]float64) // Reset restores a scope to its shipped defaults, with one audit row // carrying the full diff. A scope already at defaults is a no-op. func (s *Service) Reset(ctx context.Context, scope string) error { - switch scope { - case ScopeRadio, ScopeDailyMix: - shipped := ShippedRadioWeights() - if scope == ScopeDailyMix { - shipped = ShippedDailyMixWeights() - } + if shipped, ok := shippedWeightsFor(scope); ok { + // Every weight profile resets the same way; the per-scope defaults + // come from one place so a new profile cannot be seedable but not + // resettable. This was an if/else over two hard-coded scopes until + // songs_like made it three. changes := diffWeights(s.Weights(scope), shipped) if len(changes) == 0 { return nil } return s.persistProfile(ctx, scope, shipped, "reset", changes) + } + switch scope { case ScopeTaste: shipped := ShippedTasteTuning() changes := diffTaste(s.Taste(), shipped) diff --git a/internal/recsettings/songs_like_weights_test.go b/internal/recsettings/songs_like_weights_test.go new file mode 100644 index 00000000..805b940b --- /dev/null +++ b/internal/recsettings/songs_like_weights_test.go @@ -0,0 +1,130 @@ +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) + } + } +} diff --git a/web/src/lib/api/tuning.ts b/web/src/lib/api/tuning.ts index 10d1979d..c5c01b88 100644 --- a/web/src/lib/api/tuning.ts +++ b/web/src/lib/api/tuning.ts @@ -34,14 +34,17 @@ export type DiscoverTuning = { snooze_days: number; }; -export type TuningScope = 'radio' | 'daily_mix' | 'taste' | 'discover'; +export type TuningScope = 'radio' | 'daily_mix' | 'songs_like' | 'taste' | 'discover'; + +/** The weight-profile scopes, as distinct from the singleton-settings scopes. */ +export type WeightProfileScope = 'radio' | 'daily_mix' | 'songs_like'; export type TuningSnapshot = { - profiles: Record<'radio' | 'daily_mix', WeightProfile>; + profiles: Record; taste: TasteTuning; discover: DiscoverTuning; shipped: { - profiles: Record<'radio' | 'daily_mix', WeightProfile>; + profiles: Record; taste: TasteTuning; discover: DiscoverTuning; }; diff --git a/web/src/routes/admin/tuning/+page.svelte b/web/src/routes/admin/tuning/+page.svelte index bececa1f..48a6d73e 100644 --- a/web/src/routes/admin/tuning/+page.svelte +++ b/web/src/routes/admin/tuning/+page.svelte @@ -6,6 +6,7 @@ resetTuning, getTrends, type TuningScope, + type WeightProfileScope, type TuningSnapshot, type WeightProfile, type TasteTuning, @@ -49,9 +50,15 @@ { key: 'snooze_days', label: 'Snooze length (days)', hint: 'How long "not right now" parks a suggestion before it returns on its own. Records no opinion about the artist and never feeds the taste profile.' } ]; - const profileScopes: { scope: 'radio' | 'daily_mix'; label: string; blurb: string }[] = [ + const profileScopes: { scope: WeightProfileScope; label: string; blurb: string }[] = [ { scope: 'radio', label: 'Radio', blurb: 'Seed-directed listening — the user picked a direction.' }, - { scope: 'daily_mix', label: 'Daily mixes', blurb: 'For You, Songs like…, and the discovery mixes.' } + { scope: 'daily_mix', label: 'Daily mixes', blurb: 'For You, the discovery mixes, and "You might like".' }, + { + scope: 'songs_like', + label: 'Songs like…', + blurb: + 'The tightest surface: everything here should sound like the seed track. Similarity dominates on purpose — raising like/taste/recency here is what makes these mixes wander.' + } ]; let snapshot = $state(null); @@ -64,10 +71,11 @@ const f: Record> = { radio: {}, daily_mix: {}, + songs_like: {}, taste: {}, discover: {} }; - for (const p of ['radio', 'daily_mix'] as const) { + for (const p of profileScopes.map((s) => s.scope)) { for (const { key } of weightFields) f[p][key] = String(snap.profiles[p][key]); } for (const { key } of tasteFields) f.taste[key] = String(snap.taste[key]); diff --git a/web/src/routes/admin/tuning/tuning.test.ts b/web/src/routes/admin/tuning/tuning.test.ts index 85283657..9b8158c9 100644 --- a/web/src/routes/admin/tuning/tuning.test.ts +++ b/web/src/routes/admin/tuning/tuning.test.ts @@ -55,11 +55,19 @@ const discover = (over: Partial> = {}) => ({ // new scopes to BOTH this fixture and `shipped`. function snapshot(over: Partial = {}): TuningSnapshot { return { - profiles: { radio: weights({ taste_weight: 1 }), daily_mix: weights() }, + profiles: { + radio: weights({ taste_weight: 1 }), + daily_mix: weights(), + songs_like: weights({ similarity_weight: 4, like_boost: 0.5, taste_weight: 0.25 }) + }, taste: taste(), discover: discover(), shipped: { - profiles: { radio: weights({ taste_weight: 1 }), daily_mix: weights() }, + profiles: { + radio: weights({ taste_weight: 1 }), + daily_mix: weights(), + songs_like: weights({ similarity_weight: 4, like_boost: 0.5, taste_weight: 0.25 }) + }, taste: taste(), discover: discover() }, @@ -75,11 +83,12 @@ beforeEach(() => { }); describe('Admin tuning page', () => { - test('renders both profiles and the taste card with current values', async () => { + test('renders every weight profile and the taste card with current values', async () => { (getTuning as ReturnType).mockResolvedValue(snapshot()); render(TuningPage); await waitFor(() => expect(screen.getByText('Radio')).toBeInTheDocument()); expect(screen.getByText('Daily mixes')).toBeInTheDocument(); + expect(screen.getByText('Songs like…')).toBeInTheDocument(); expect(screen.getByText('Taste profile build')).toBeInTheDocument(); const radioTaste = screen.getByLabelText(/taste weight/i, { selector: '#radio-taste_weight' @@ -89,6 +98,27 @@ describe('Admin tuning page', () => { expect(halfLife.value).toBe('75'); }); + // Songs-like is a separate weight profile precisely so it can be tuned + // apart from For-You (#3881). If its card stopped rendering its OWN values + // — or quietly fell back to daily_mix's — the split would exist in the + // backend and be unreachable in the UI, which is the same as not shipping + // it (rule 27). + test('the songs-like card carries its own weights, not daily_mix\'s', async () => { + (getTuning as ReturnType).mockResolvedValue(snapshot()); + render(TuningPage); + await waitFor(() => expect(screen.getByText('Songs like…')).toBeInTheDocument()); + + const sim = document.getElementById('songs_like-similarity_weight') as HTMLInputElement; + const like = document.getElementById('songs_like-like_boost') as HTMLInputElement; + expect(sim.value).toBe('4'); + expect(like.value).toBe('0.5'); + + // The contrast that makes the card worth having: daily_mix must still show + // its own, different numbers on the same page. + const dailySim = document.getElementById('daily_mix-similarity_weight') as HTMLInputElement; + expect(dailySim.value).not.toBe(sim.value); + }); + test('save sends only the changed fields for the scope', async () => { (getTuning as ReturnType).mockResolvedValue(snapshot()); (patchTuning as ReturnType).mockResolvedValue(snapshot());