Recommendation relevance, the rollback unit, and a version that names what shipped #131
@@ -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'));
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user