From f367eeaa9d66f5a48572c041f5b2d3cbc648a672 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 10 Sep 2026 20:51:15 -0400 Subject: [PATCH] fix(recommendation): Songs-like gets its own profile so it stops wandering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator, 2026-09-10: "when I play it 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." Songs-like shared the `daily_mix` weight profile with For-You, 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". Under one profile the broad answer wins. The arithmetic, from the shared weights: 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 whole range (1.5) and TasteWeight (1.5, and seed-INDEPENDENT) matched it outright. Under the new profile the same pair scores 5.00 vs 2.00. Two levers, because either alone leaves the other's failure intact: POOL. Songs-like now takes its own CandidateSourceLimits. The default gave ~29% of candidates a sim_score of literally zero — `taste_overlap` and `random_fill` are both `0.0::float8` in recommendation.sql, seed-independent by construction. Same total pool size; composition shifts to arms that measure distance from the seed, LBSimilar doubled. WEIGHTS. A third profile beside radio and daily_mix, DB-backed and live per rule 25, with the property that similarity's range exceeds the combined range of every seed-independent differentiator — so a closer match cannot be beaten on likes, freshness and taste alone, while tracks within ~0.39 similarity of each other still get ordered by what the user likes. Rule 131 changed the pool design mid-way and for the better. Zeroing the two seed-independent arms was the first instinct and is exactly the vanish-or-nothing shape that rule forbids: a seed with thin ListenBrainz coverage would yield a short mix or none. They are the tier-3 FLOOR — cut hard, never removed — and the weights keep them at the bottom of the ranking rather than out of the pool. "A few tracks further from the seed than we'd like" beats "no playlist". Caught while wiring it: switching only pickTopN's final Score would have been nearly INERT. scoreAndSortCandidates does the selection sort, and the caller caps and truncates in that order — so the playlist would still have been chosen by daily_mix and merely relabelled with songs_like numbers. It now takes the profile as a parameter, and each surface passes its own. Also corrects the daily_mix card's blurb, which claimed Songs-like as one of its surfaces and no longer is. Guards pin behaviour rather than the numbers, since numbers get retuned: that similarity beats an unrelated liked track, that daily_mix still DOESN'T (or the split buys nothing), that the tier-3 floor is non-zero, and that the UI card shows its own values rather than falling back. Each falsified against its named regression first. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH --- internal/api/admin_recommendation_tuning.go | 10 +- .../0057_songs_like_tuning.down.sql | 16 +++ .../migrations/0057_songs_like_tuning.up.sql | 37 +++++ internal/playlists/system.go | 69 +++++++++- internal/recommendation/candidates.go | 53 +++++++ .../recommendation/songs_like_limits_test.go | 61 ++++++++ internal/recsettings/service.go | 98 +++++++++++-- .../recsettings/songs_like_weights_test.go | 130 ++++++++++++++++++ web/src/lib/api/tuning.ts | 9 +- web/src/routes/admin/tuning/+page.svelte | 14 +- web/src/routes/admin/tuning/tuning.test.ts | 36 ++++- 11 files changed, 504 insertions(+), 29 deletions(-) create mode 100644 internal/db/migrations/0057_songs_like_tuning.down.sql create mode 100644 internal/db/migrations/0057_songs_like_tuning.up.sql create mode 100644 internal/recommendation/songs_like_limits_test.go create mode 100644 internal/recsettings/songs_like_weights_test.go 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());