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
62 lines
2.7 KiB
Go
62 lines
2.7 KiB
Go
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)
|
|
}
|
|
}
|