fix(recommendation): Songs-like gets its own profile so it stops wandering
test-web / test (push) Successful in 1m7s
test-go / test (push) Successful in 1m31s
test-go / integration (push) Failing after 4m21s
release / Build signed APK (releases and dev) (push) Successful in 5m11s
release / Build + push container image (push) Successful in 1m52s
release / Verify release artifacts (tag releases only) (push) Skipped

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
This commit is contained in:
2026-09-10 20:51:15 -04:00
co-authored by Claude Opus 5
parent 270ad7a71b
commit f367eeaa9d
11 changed files with 504 additions and 29 deletions
+6 -4
View File
@@ -97,14 +97,16 @@ type tuningSnapshot struct {
func (h *handlers) tuningSnapshot() tuningSnapshot { func (h *handlers) tuningSnapshot() tuningSnapshot {
var out tuningSnapshot var out tuningSnapshot
out.Profiles = map[string]weightsResp{ out.Profiles = map[string]weightsResp{
recsettings.ScopeRadio: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeRadio)), recsettings.ScopeRadio: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeRadio)),
recsettings.ScopeDailyMix: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeDailyMix)), recsettings.ScopeDailyMix: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeDailyMix)),
recsettings.ScopeSongsLike: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeSongsLike)),
} }
out.Taste = tasteRespFrom(h.recSettings.Taste()) out.Taste = tasteRespFrom(h.recSettings.Taste())
out.Discover = discoverRespFrom(h.recSettings.Discover()) out.Discover = discoverRespFrom(h.recSettings.Discover())
out.Shipped.Profiles = map[string]weightsResp{ out.Shipped.Profiles = map[string]weightsResp{
recsettings.ScopeRadio: weightsRespFrom(recsettings.ShippedRadioWeights()), recsettings.ScopeRadio: weightsRespFrom(recsettings.ShippedRadioWeights()),
recsettings.ScopeDailyMix: weightsRespFrom(recsettings.ShippedDailyMixWeights()), recsettings.ScopeDailyMix: weightsRespFrom(recsettings.ShippedDailyMixWeights()),
recsettings.ScopeSongsLike: weightsRespFrom(recsettings.ShippedSongsLikeWeights()),
} }
out.Shipped.Taste = tasteRespFrom(recsettings.ShippedTasteTuning()) out.Shipped.Taste = tasteRespFrom(recsettings.ShippedTasteTuning())
out.Shipped.Discover = discoverRespFrom(recsettings.ShippedDiscoverTuning()) out.Shipped.Discover = discoverRespFrom(recsettings.ShippedDiscoverTuning())
@@ -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'));
@@ -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'));
+62 -7
View File
@@ -219,6 +219,24 @@ var (
// uniform with radio pending trend data. // uniform with radio pending trend data.
ContextTimeWeight: 1.0, 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() systemTasteConfig = taste.DefaultConfig()
) )
@@ -230,6 +248,20 @@ func SetSystemMixWeights(w recommendation.ScoringWeights) {
systemMixWeights = w 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 // SetTasteConfig installs the taste-profile build configuration
// (half-life + engagement curve, #1250). Same push model as // (half-life + engagement curve, #1250). Same push model as
// SetSystemMixWeights. // SetSystemMixWeights.
@@ -239,6 +271,12 @@ func SetTasteConfig(c taste.Config) {
systemTasteConfig = c systemTasteConfig = c
} }
func currentSongsLikeWeights() recommendation.ScoringWeights {
systemTuningMu.RLock()
defer systemTuningMu.RUnlock()
return songsLikeWeights
}
func currentSystemMixWeights() recommendation.ScoringWeights { func currentSystemMixWeights() recommendation.ScoringWeights {
systemTuningMu.RLock() systemTuningMu.RLock()
defer systemTuningMu.RUnlock() 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 // tieBreakHash). The scoring RNG is seeded by userIDHash so jitter is
// deterministic per (user, day) but rotates across days. Pure — no // deterministic per (user, day) but rotates across days. Pure — no
// truncation, no cap. // 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)))) rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
type scored struct { type scored struct {
c recommendation.Candidate c recommendation.Candidate
@@ -411,7 +455,6 @@ func scoreAndSortCandidates(cands []recommendation.Candidate, userID pgtype.UUID
sort.SliceStable(ordered, func(i, j int) bool { sort.SliceStable(ordered, func(i, j int) bool {
return uuidLessPL(ordered[i].Track.ID, ordered[j].Track.ID) return uuidLessPL(ordered[i].Track.ID, ordered[j].Track.ID)
}) })
weights := currentSystemMixWeights()
pairs := make([]scored, len(ordered)) pairs := make([]scored, len(ordered))
for i, c := range ordered { for i, c := range ordered {
pairs[i] = scored{c: c, score: recommendation.Score(c.Inputs, weights, now, rng.Float64)} pairs[i] = scored{c: c, score: recommendation.Score(c.Inputs, weights, now, rng.Float64)}
@@ -647,9 +690,13 @@ func produceSeedMixes(
continue continue
} }
zeroVec := recommendation.SessionVector{Seed: true} 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( cands, cerr := recommendation.LoadCandidatesFromSimilarity(
ctx, q, userID, seedTrack, 1, zeroVec, []pgtype.UUID{seedTrack}, ctx, q, userID, seedTrack, 1, zeroVec, []pgtype.UUID{seedTrack},
recommendation.DefaultCandidateSourceLimits(), recommendation.SongsLikeCandidateSourceLimits(),
) )
if cerr != nil { if cerr != nil {
logger.Warn("system playlist: seed candidates load failed; skipping", 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 // truncates to n. Used by Songs-like-X (and as the fallback inside
// pickHeadAndTail for small pools). // pickHeadAndTail for small pools).
func pickTopN(cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time, n int) []rankedCandidate { 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) capped := capCandidatesByAlbumAndArtist(sorted)
if len(capped) > n { if len(capped) > n {
capped = capped[:n] capped = capped[:n]
} }
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr)))) rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
weights := currentSystemMixWeights()
out := make([]rankedCandidate, len(capped)) out := make([]rankedCandidate, len(capped))
for i, c := range capped { for i, c := range capped {
out[i] = rankedCandidate{ out[i] = rankedCandidate{
@@ -873,10 +927,11 @@ func pickHeadAndTail(
cands []recommendation.Candidate, seedOf map[pgtype.UUID]int, numSeeds int, cands []recommendation.Candidate, seedOf map[pgtype.UUID]int, numSeeds int,
userID pgtype.UUID, dateStr string, now time.Time, headN, tailN int, userID pgtype.UUID, dateStr string, now time.Time, headN, tailN int,
) []rankedCandidate { ) []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) capped := capCandidatesByAlbumAndArtist(sorted)
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr)))) rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
weights := currentSystemMixWeights()
total := headN + tailN total := headN + tailN
if len(capped) <= total { if len(capped) <= total {
+53
View File
@@ -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 12 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. // LoadCandidatesFromSimilarity is M4c's primary candidate-pool loader.
// 5-way SQL UNION (LB-similar / similar-artist tracks / MB-tag overlap / // 5-way SQL UNION (LB-similar / similar-artist tracks / MB-tag overlap /
// likes-overlap / random fill) + dedup-by-max sim_score. Returns // likes-overlap / random fill) + dedup-by-max sim_score. Returns
@@ -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)
}
}
+89 -9
View File
@@ -40,6 +40,13 @@ const (
// never be read as taste signal (#2374) — filing it under taste would put // never be read as taste signal (#2374) — filing it under taste would put
// it one careless join from the leak that design forbids. // it one careless join from the leak that design forbids.
ScopeDiscover = "discover" 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 // 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). // DiscoverTuning is the tunable set for the Discover request surface (#2377).
type DiscoverTuning struct { type DiscoverTuning struct {
// TagOverlapWeight scales the taste-tag term: score × (1 + w × overlap). // 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 { func (s *Service) reconcile(ctx context.Context) error {
q := dbq.New(s.pool) q := dbq.New(s.pool)
for profile, w := range map[string]recommendation.ScoringWeights{ for profile, w := range map[string]recommendation.ScoringWeights{
ScopeRadio: ShippedRadioWeights(), ScopeRadio: ShippedRadioWeights(),
ScopeDailyMix: ShippedDailyMixWeights(), ScopeDailyMix: ShippedDailyMixWeights(),
ScopeSongsLike: ShippedSongsLikeWeights(),
} { } {
if err := q.UpsertWeightProfileDefaults(ctx, upsertParams(profile, w)); err != nil { if err := q.UpsertWeightProfileDefaults(ctx, upsertParams(profile, w)); err != nil {
return fmt.Errorf("seed profile %q: %w", profile, err) 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. // reads Weights(ScopeRadio) per request.
func (s *Service) push() { func (s *Service) push() {
playlists.SetSystemMixWeights(s.Weights(ScopeDailyMix)) playlists.SetSystemMixWeights(s.Weights(ScopeDailyMix))
playlists.SetSongsLikeWeights(s.Weights(ScopeSongsLike))
playlists.SetTasteConfig(s.TasteConfig()) playlists.SetTasteConfig(s.TasteConfig())
} }
@@ -297,7 +376,7 @@ type fieldChange struct {
// Unknown fields and out-of-range values reject the whole patch. A // Unknown fields and out-of-range values reject the whole patch. A
// no-op patch (all values equal to current) writes no audit row. // 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 { 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) return fmt.Errorf("%w: %q", ErrUnknownScope, profile)
} }
current := s.Weights(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 // 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. // carrying the full diff. A scope already at defaults is a no-op.
func (s *Service) Reset(ctx context.Context, scope string) error { func (s *Service) Reset(ctx context.Context, scope string) error {
switch scope { if shipped, ok := shippedWeightsFor(scope); ok {
case ScopeRadio, ScopeDailyMix: // Every weight profile resets the same way; the per-scope defaults
shipped := ShippedRadioWeights() // come from one place so a new profile cannot be seedable but not
if scope == ScopeDailyMix { // resettable. This was an if/else over two hard-coded scopes until
shipped = ShippedDailyMixWeights() // songs_like made it three.
}
changes := diffWeights(s.Weights(scope), shipped) changes := diffWeights(s.Weights(scope), shipped)
if len(changes) == 0 { if len(changes) == 0 {
return nil return nil
} }
return s.persistProfile(ctx, scope, shipped, "reset", changes) return s.persistProfile(ctx, scope, shipped, "reset", changes)
}
switch scope {
case ScopeTaste: case ScopeTaste:
shipped := ShippedTasteTuning() shipped := ShippedTasteTuning()
changes := diffTaste(s.Taste(), shipped) changes := diffTaste(s.Taste(), shipped)
@@ -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)
}
}
}
+6 -3
View File
@@ -34,14 +34,17 @@ export type DiscoverTuning = {
snooze_days: number; 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 = { export type TuningSnapshot = {
profiles: Record<'radio' | 'daily_mix', WeightProfile>; profiles: Record<WeightProfileScope, WeightProfile>;
taste: TasteTuning; taste: TasteTuning;
discover: DiscoverTuning; discover: DiscoverTuning;
shipped: { shipped: {
profiles: Record<'radio' | 'daily_mix', WeightProfile>; profiles: Record<WeightProfileScope, WeightProfile>;
taste: TasteTuning; taste: TasteTuning;
discover: DiscoverTuning; discover: DiscoverTuning;
}; };
+11 -3
View File
@@ -6,6 +6,7 @@
resetTuning, resetTuning,
getTrends, getTrends,
type TuningScope, type TuningScope,
type WeightProfileScope,
type TuningSnapshot, type TuningSnapshot,
type WeightProfile, type WeightProfile,
type TasteTuning, 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.' } { 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: '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<TuningSnapshot | null>(null); let snapshot = $state<TuningSnapshot | null>(null);
@@ -64,10 +71,11 @@
const f: Record<string, Record<string, string>> = { const f: Record<string, Record<string, string>> = {
radio: {}, radio: {},
daily_mix: {}, daily_mix: {},
songs_like: {},
taste: {}, taste: {},
discover: {} 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 weightFields) f[p][key] = String(snap.profiles[p][key]);
} }
for (const { key } of tasteFields) f.taste[key] = String(snap.taste[key]); for (const { key } of tasteFields) f.taste[key] = String(snap.taste[key]);
+33 -3
View File
@@ -55,11 +55,19 @@ const discover = (over: Partial<Record<string, number>> = {}) => ({
// new scopes to BOTH this fixture and `shipped`. // new scopes to BOTH this fixture and `shipped`.
function snapshot(over: Partial<TuningSnapshot> = {}): TuningSnapshot { function snapshot(over: Partial<TuningSnapshot> = {}): TuningSnapshot {
return { 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(), taste: taste(),
discover: discover(), discover: discover(),
shipped: { 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(), taste: taste(),
discover: discover() discover: discover()
}, },
@@ -75,11 +83,12 @@ beforeEach(() => {
}); });
describe('Admin tuning page', () => { 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<typeof vi.fn>).mockResolvedValue(snapshot()); (getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
render(TuningPage); render(TuningPage);
await waitFor(() => expect(screen.getByText('Radio')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('Radio')).toBeInTheDocument());
expect(screen.getByText('Daily mixes')).toBeInTheDocument(); expect(screen.getByText('Daily mixes')).toBeInTheDocument();
expect(screen.getByText('Songs like…')).toBeInTheDocument();
expect(screen.getByText('Taste profile build')).toBeInTheDocument(); expect(screen.getByText('Taste profile build')).toBeInTheDocument();
const radioTaste = screen.getByLabelText(/taste weight/i, { const radioTaste = screen.getByLabelText(/taste weight/i, {
selector: '#radio-taste_weight' selector: '#radio-taste_weight'
@@ -89,6 +98,27 @@ describe('Admin tuning page', () => {
expect(halfLife.value).toBe('75'); 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<typeof vi.fn>).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 () => { test('save sends only the changed fields for the scope', async () => {
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot()); (getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
(patchTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot()); (patchTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());