test-web / test (push) Successful in 1m7s
test-go / test (push) Successful in 1m31s
test-go / integration (push) Failing after 4m21s
release / Build signed APK (releases and dev) (push) Successful in 5m11s
release / Build + push container image (push) Successful in 1m52s
release / Verify release artifacts (tag releases only) (push) Skipped
Operator, 2026-09-10: "when I play it I'm expecting to get a consistent
sound and style from the experience... I was getting a seeming wide variety
of music from each one when I was hoping to stay in a certain neighborhood."
Songs-like shared the `daily_mix` weight profile with For-You, and that
sharing WAS the bug. The two surfaces want opposite things: For-You answers
"what will they enjoy today" and is supposed to roam; Songs-like answers
"what sounds like THIS". Under one profile the broad answer wins.
The arithmetic, from the shared weights:
unrelated track, liked, not played recently → 1.0 + 2.0 + 1.0 = 4.0
PERFECT similarity match, not liked → 1.0 + 1.5 = 2.5
Liking something outranked sounding like the seed, because LikeBoost (2.0)
exceeded SimilarityWeight's whole range (1.5) and TasteWeight (1.5, and
seed-INDEPENDENT) matched it outright. Under the new profile the same pair
scores 5.00 vs 2.00.
Two levers, because either alone leaves the other's failure intact:
POOL. Songs-like now takes its own CandidateSourceLimits. The default gave
~29% of candidates a sim_score of literally zero — `taste_overlap` and
`random_fill` are both `0.0::float8` in recommendation.sql, seed-independent
by construction. Same total pool size; composition shifts to arms that
measure distance from the seed, LBSimilar doubled.
WEIGHTS. A third profile beside radio and daily_mix, DB-backed and live per
rule 25, with the property that similarity's range exceeds the combined
range of every seed-independent differentiator — so a closer match cannot
be beaten on likes, freshness and taste alone, while tracks within ~0.39
similarity of each other still get ordered by what the user likes.
Rule 131 changed the pool design mid-way and for the better. Zeroing the
two seed-independent arms was the first instinct and is exactly the
vanish-or-nothing shape that rule forbids: a seed with thin ListenBrainz
coverage would yield a short mix or none. They are the tier-3 FLOOR — cut
hard, never removed — and the weights keep them at the bottom of the
ranking rather than out of the pool. "A few tracks further from the seed
than we'd like" beats "no playlist".
Caught while wiring it: switching only pickTopN's final Score would have
been nearly INERT. scoreAndSortCandidates does the selection sort, and the
caller caps and truncates in that order — so the playlist would still have
been chosen by daily_mix and merely relabelled with songs_like numbers. It
now takes the profile as a parameter, and each surface passes its own.
Also corrects the daily_mix card's blurb, which claimed Songs-like as one
of its surfaces and no longer is.
Guards pin behaviour rather than the numbers, since numbers get retuned:
that similarity beats an unrelated liked track, that daily_mix still
DOESN'T (or the split buys nothing), that the tier-3 floor is non-zero,
and that the UI card shows its own values rather than falling back. Each
falsified against its named regression first.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
131 lines
5.4 KiB
Go
131 lines
5.4 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|