fix(recommendation): make the candidate draw reproducible, not accidentally so
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
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
This commit is contained in:
@@ -59,41 +59,3 @@ func TestSongsLikeLimits_KeepThePoolRoughlyTheSameSize(t *testing.T) {
|
||||
"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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user