From 752906b05406f984f954aff78a90e15ca5d13f4c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 11 Jun 2026 19:56:13 -0400 Subject: [PATCH] fix(playlists): pin candidate order before jitter to make daily builds deterministic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scoreAndSortCandidates drew per-candidate jitter by slice position, but the candidate query (LoadRadioCandidatesV2) has ORDER BY random() arms and no stable outer ordering, so DB row order varies call-to-call. When the recency spread between candidates is smaller than the ±jitter (small or recency-clustered libraries), two same-day rebuilds assigned jitter to different tracks and reordered near-ties — so the build was not actually deterministic-within-a-day as documented. Pre-existing latent flake in TestBuildSystemPlaylists_DailyNonceDeterminism (passed in isolation / by luck in CI; deterministically reproduced when the system-build tests run in sequence). Confirmed independent of the You-might-like change by neutralizing buildYouMightLike — the flake persisted. Fix: sort the candidate slice by track id before assigning jitter, so the jitter for a track is a function of (track, day) alone, independent of DB return order. Verified: full playlists package green 4/4 and the build-test sequence green 5/5 (was 0/4 before). Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/playlists/system.go | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/internal/playlists/system.go b/internal/playlists/system.go index 8a2915f4..87bf5595 100644 --- a/internal/playlists/system.go +++ b/internal/playlists/system.go @@ -5,6 +5,7 @@ package playlists import ( + "bytes" "context" "crypto/sha256" "encoding/binary" @@ -180,8 +181,21 @@ func scoreAndSortCandidates(cands []recommendation.Candidate, userID pgtype.UUID c recommendation.Candidate score float64 } - pairs := make([]scored, len(cands)) - for i, c := range cands { + // Pin candidate order by track id before drawing jitter. The candidate + // query (LoadRadioCandidatesV2) has ORDER BY random() arms and no + // stable outer ordering, so DB row order varies call-to-call. Since + // the i-th candidate gets the i-th seeded jitter draw, an unstable + // input order would assign different jitter to the same track across + // same-day rebuilds and reorder near-ties — breaking the daily + // determinism this function promises. Sorting by id first makes the + // jitter assignment a function of (track, day) alone. + ordered := make([]recommendation.Candidate, len(cands)) + copy(ordered, cands) + sort.SliceStable(ordered, func(i, j int) bool { + return uuidLessPL(ordered[i].Track.ID, ordered[j].Track.ID) + }) + pairs := make([]scored, len(ordered)) + for i, c := range ordered { pairs[i] = scored{c: c, score: recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64)} } sort.SliceStable(pairs, func(i, j int) bool { @@ -709,6 +723,12 @@ func insertSystemPlaylist(ctx context.Context, qtx *dbq.Queries, userID pgtype.U // (pgtype.UUID's String method exists on some pgx versions but not others; // also the playlists package needs this independent of test helpers.) // Suffix `PL` distinguishes it from any test helper named uuidString. +// uuidLessPL reports whether a sorts before b by raw 16-byte value. Used +// to pin candidate order deterministically before jitter assignment. +func uuidLessPL(a, b pgtype.UUID) bool { + return bytes.Compare(a.Bytes[:], b.Bytes[:]) < 0 +} + func uuidStringPL(u pgtype.UUID) string { if !u.Valid { return ""