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 {
|
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'));
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
Reference in New Issue
Block a user