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
+89 -9
View File
@@ -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)