Recommendation relevance, the rollback unit, and a version that names what shipped #131

Merged
bvandeusen merged 11 commits from dev into main 2026-09-10 23:37:57 -04:00
2 changed files with 73 additions and 11 deletions
Showing only changes of commit ecfa056d4d - Show all commits
+35 -11
View File
@@ -139,22 +139,46 @@ func DefaultCandidateSourceLimits() CandidateSourceLimits {
// would produce a short mix or none at all, and "no playlist" is a worse
// answer than "a few tracks further from the seed than we would like".
//
// likes_overlap is cut hardest of the tier-2 arms for a specific reason: its
// SQL assigns a FLAT 0.6 sim_score (recommendation.sql:108) rather than
// measuring anything. It is a collaborative signal wearing similarity's
// clothes, and raising SimilarityWeight amplifies it — if real ListenBrainz
// scores commonly land below 0.6 it would outrank genuine matches. Halved
// here pending the fill-rate measurement in #3879; the honest fix is to stop
// it claiming a similarity score it did not compute.
// DO NOT SHRINK AN ARM ORDERED BY UNSEEDED random(). This is the constraint
// that shapes the numbers below, and it is not obvious from reading them.
//
// `likes_overlap` and `random_fill` both end in a bare `ORDER BY random()`
// (recommendation.sql:118, :161) with no daily seed. 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 random order is irrelevant, because
// the caller sorts by id before scoring. Drop the limit below the eligible
// count and the arm starts returning a random SUBSET, which differs between
// two builds on the same day.
//
// That is a real defect (#3889) rather than a quirk of this function, and it
// bit here: cutting RandomFill to 10 broke
// TestBuildSystemPlaylists_DailyNonceDeterminism, whose library is smaller
// than the default limit and whose determinism was therefore accidental.
// Growing an arm is always safe; only shrinking one is.
//
// So the seed-independent arms are trimmed only where the ordering is
// deterministic: `taste_overlap` sorts by `tpa.weight DESC, t.id` and can be
// cut, `random_fill` cannot. The reduction is consequently modest — and it
// matters less than it looks, because the WEIGHTS are what demote sim_score-0
// candidates now. The pool change biases the draw; the songs_like profile is
// what actually keeps unrelated tracks out of the result.
//
// One arm is left alone that arguably should not be: `likes_overlap` assigns
// a FLAT 0.6 sim_score (recommendation.sql:108) rather than measuring
// anything — a collaborative signal wearing similarity's clothes, which a
// raised SimilarityWeight amplifies. If real ListenBrainz scores commonly
// land below 0.6 it will outrank genuine matches. It cannot be trimmed here
// without the determinism fix landing first; the honest repair is to stop it
// claiming a similarity score it never computed (#3879).
func SongsLikeCandidateSourceLimits() CandidateSourceLimits {
return CandidateSourceLimits{
LBSimilar: 60, // tier 1 — doubled; the only arm that measures the seed
SimilarArtist: 40, // tier 2
SimilarArtist: 40, // tier 2 — raised; growing is always safe
TagOverlap: 20, // tier 2
UserCoplay: 20, // tier 2
LikesOverlap: 10, // tier 2, halved — flat 0.6, see above
TasteOverlap: 10, // tier 3 floor — halved, not removed
RandomFill: 10, // tier 3 floor — cut hard, never to zero
LikesOverlap: 20, // tier 2 — NOT trimmed: unseeded random(), see above
TasteOverlap: 10, // tier 3 floor — halved; deterministic ordering, safe
RandomFill: 30, // tier 3 floor — NOT trimmed: unseeded random(), see above
}
}
@@ -59,3 +59,41 @@ 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)
}
}
}