test-go / test (push) Successful in 1m18s
test-go / integration (push) Successful in 4m52s
release / Build signed APK (releases and dev) (push) Successful in 6m9s
release / Build + push container image (push) Successful in 2m5s
release / Verify release artifacts (tag releases only) (push) Skipped
Four arms of the candidate query ended in a bare `ORDER BY random()` with no seed: similar_artists, likes_overlap, coplay_artists and random_fill. Such an arm returns a STABLE set only while its LIMIT exceeds the rows eligible for it — at that point it returns all of them and the order stops mattering, because scoreAndSortCandidates sorts by track id before drawing jitter. Below that threshold it returns a random SUBSET, and two builds on the same day draw different ones. So daily determinism held BY ACCIDENT, and only for libraries smaller than the limits. Any real library is larger, which means same-day rebuilds have been producing different mixes since those arms were written — invisible, because a mix that changes after a refresh looks like a feature rather than a broken promise. Found by breaking it: cutting RandomFill to 10 while tuning Songs-like turned TestBuildSystemPlaylists_DailyNonceDeterminism red. That test seeds ~20 tracks against a default RandomFill of 30, so its determinism came from the limit exceeding the library, not from the code being right. It is now a real guard. The arms order by md5(id || $12) instead. The CALLER decides what that means, which is the point: system mixes pass a per-(user, day) seed and get the determinism they promise, radio passes a fresh value per request and keeps varying, which is what a radio should do. Same shape the browse queries in this file already use (`md5(id::text || current_date::text)`) — existing idiom, not a new one. This also unblocks the trim that #3881 wanted and could not have. Shrinking a randomly-ordered arm was what broke membership; a seeded one takes a smaller but REPRODUCIBLE slice. Songs-like's seed-independent share drops from 29% to 12%, which was the original intent before determinism forced it back to 20%. TestSongsLikeLimits_DoNotShrinkTheUnseededRandomArms is DELETED rather than kept passing. It existed to stop anyone trimming those arms while the ordering was broken; the ordering is fixed, so the constraint is gone and a guard enforcing it would now forbid correct code. Was filed as blocked on tooling. It was not: `make generate-go` runs sqlc as a pinned Go tool and is the same path CI takes. One thing worth knowing for next time: three files in internal/db/dbq are owned by root, left by `make generate` running sqlc in Docker. sqlc errored on the first it could not write. They are untouched by this change and the regeneration of recommendation.sql.go completed, but `make generate` will keep failing until they are chowned. 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)
|
|
}
|
|
}
|