Recommendation relevance, the rollback unit, and a version that names what shipped #131

Merged
bvandeusen merged 11 commits from dev into main 2026-09-10 23:37:57 -04:00
11 changed files with 504 additions and 29 deletions
Showing only changes of commit f367eeaa9d - Show all commits
+6 -4
View File
@@ -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())
@@ -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.
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 {
+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.
// 5-way SQL UNION (LB-similar / similar-artist tracks / MB-tag overlap /
// 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
// 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)
@@ -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;
};
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<WeightProfileScope, WeightProfile>;
taste: TasteTuning;
discover: DiscoverTuning;
shipped: {
profiles: Record<'radio' | 'daily_mix', WeightProfile>;
profiles: Record<WeightProfileScope, WeightProfile>;
taste: TasteTuning;
discover: DiscoverTuning;
};
+11 -3
View File
@@ -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<TuningSnapshot | null>(null);
@@ -64,10 +71,11 @@
const f: Record<string, Record<string, string>> = {
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]);
+33 -3
View File
@@ -55,11 +55,19 @@ const discover = (over: Partial<Record<string, number>> = {}) => ({
// new scopes to BOTH this fixture and `shipped`.
function snapshot(over: Partial<TuningSnapshot> = {}): 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<typeof vi.fn>).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<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 () => {
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
(patchTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());