test-go / test (push) Successful in 1m10s
test-go / integration (push) Successful in 3m35s
release / Build signed APK (releases and dev) (push) Successful in 5m6s
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
Fixes the integration failure from f367eeaa:
"same-day rebuild produced different track lists".
Cutting RandomFill 30→10 for Songs-like broke
TestBuildSystemPlaylists_DailyNonceDeterminism, and the reason is worth
stating because the number is not the bug.
`likes_overlap` and `random_fill` end in a bare `ORDER BY random()` with no
daily seed (recommendation.sql:118, :161). Such an arm returns a STABLE set
only while its LIMIT exceeds the rows eligible for it — then it returns all
of them, and the random order stops mattering because scoreAndSortCandidates
sorts by track id before drawing jitter. Below that threshold the arm
returns a random SUBSET, and two builds on the same day draw different ones.
So the test was green by accident. It seeds ~20 tracks against a default
RandomFill of 30; the limit exceeded the library, so the arm returned
everything. Determinism held for a reason unrelated to the code being right.
Which means it does NOT hold in production. Any real library is larger than
30, so same-day rebuilds have been drawing different mixes since that arm
was written — invisible, because a mix changing after a refresh looks like a
feature. Filed as #3889; the fix is a seeded ordering per (user, day), which
needs a .sql change and sqlc regeneration and so cannot land from here.
The correction: grow an arm freely, never shrink one whose ordering is
unseeded random. LikesOverlap and RandomFill go back to the defaults;
TasteOverlap stays halved because it sorts by `tpa.weight DESC, t.id` and is
genuinely deterministic. Guarded by a test that names the reasoning, so the
next person to trim these has to read why first — and it should be DELETED
once #3889 lands rather than worked around.
The cost is honest: the seed-independent share of the Songs-like pool falls
from 29% to 20% instead of the intended cut. That matters less than it
sounds. The pool only biases the draw; the songs_like WEIGHTS are what
actually demote sim_score-0 candidates, and they are untouched here — a
perfect match still scores 5.00 against an unrelated favourite's 2.00.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
100 lines
4.3 KiB
Go
100 lines
4.3 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)
|
|
}
|
|
}
|
|
|
|
// The constraint that is invisible in the numbers, and that this file exists
|
|
// to keep visible.
|
|
//
|
|
// `likes_overlap` and `random_fill` end in a bare `ORDER BY random()` with no
|
|
// daily seed (recommendation.sql:118, :161). Such an arm returns a stable set
|
|
// only while its LIMIT exceeds the eligible rows; below that it returns a
|
|
// random SUBSET that differs between two builds on the same day, and the
|
|
// daily-determinism promise quietly stops holding.
|
|
//
|
|
// This is not hypothetical — it is how this change first failed CI. Cutting
|
|
// RandomFill to 10 broke TestBuildSystemPlaylists_DailyNonceDeterminism,
|
|
// whose library is smaller than the default limit and whose determinism was
|
|
// therefore an accident of the limit exceeding the library.
|
|
//
|
|
// Growing these arms is always safe. Only shrinking is, and the fix that
|
|
// would make shrinking safe is a seeded ordering (#3889), not a smaller
|
|
// number here.
|
|
func TestSongsLikeLimits_DoNotShrinkTheUnseededRandomArms(t *testing.T) {
|
|
d := DefaultCandidateSourceLimits()
|
|
s := SongsLikeCandidateSourceLimits()
|
|
|
|
for _, tc := range []struct {
|
|
arm string
|
|
songsLike, dflt int
|
|
}{
|
|
{"RandomFill", s.RandomFill, d.RandomFill},
|
|
{"LikesOverlap", s.LikesOverlap, d.LikesOverlap},
|
|
} {
|
|
if tc.songsLike < tc.dflt {
|
|
t.Errorf("%s cut from %d to %d. That arm is ordered by unseeded random(), "+
|
|
"so a smaller limit makes pool membership vary between same-day "+
|
|
"rebuilds — it breaks daily determinism rather than merely narrowing "+
|
|
"the mix. Fix the ordering (#3889) before trimming this.",
|
|
tc.arm, tc.dflt, tc.songsLike)
|
|
}
|
|
}
|
|
}
|