feat(playlists): daily seed rotation + jitter + 12+13 split for system playlists
Five diversity mechanics — applied to both For-You and Songs-Like-X.
1. For-You seed rotates daily across the user's top-5 most-played
tracks. pickForYouSeedForDay uses userIDHash(user, day) mod
len(seeds) so today's mix uses an entirely different similarity
pool than tomorrow's. Within-day determinism preserved.
2. JitterMagnitude bumped 0.0 → 0.1. The scoring RNG is now seeded
by userIDHash(user, day) rather than the no-op, so near-tied
candidates shuffle daily without breaking within-day stability.
3. Head/tail split moves from 20+5 to 12+13. Roughly half the
playlist comes from the tail now (daily-deterministic via
tieBreakHash), giving the user substantially different content
while a 12-track anchor of strong similarity matches keeps the
mix recognizable.
4. Songs-Like-X seed artists shuffle daily across the user's top-5
played artists. pickSeedArtistsForDay applies a userIDHash-seeded
Fisher-Yates and takes 3.
5. scoreAndSortCandidates / pickTopN / pickHeadAndTail gain a userID
parameter so the RNG can be seeded per-user; existing call sites
updated; noopRNG removed.
Test fixtures widened similarity gaps (e.g. float64(50-i) instead of
(50-i)/50) so the new jitter (±0.1) doesn't perturb head ordering in
assertions about the head/tail mechanism. New seed_selection_test
coverage for userIDHash + pickForYouSeedForDay + pickSeedArtistsForDay
spans deterministic-within-day, varies-across-days, and graceful
degradation with small candidate pools.
PickTopPlayedTrackForUser replaced by PickTopPlayedTracksForUser
:many in the prior commit (b4801c2). The For-You seed lookup now
goes through pickForYouSeedForDay over the returned slice.
PickSeedArtists's LIMIT widened to 5 in the same prior commit.
For #392 Half A — system playlist content diversity. Half B
(per-user timezone scheduling) is a separate spec.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,11 +10,20 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||
)
|
||||
|
||||
// testUserID is a stable UUID used to seed the daily-determinism RNGs in
|
||||
// scoreAndSortCandidates / pickHeadAndTail / pickTopN. Tests pass it to
|
||||
// every call so the jitter is reproducible across test runs.
|
||||
var testUserID = pgtype.UUID{Bytes: [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, Valid: true}
|
||||
|
||||
// makeCand constructs a Candidate with distinct track/album/artist IDs
|
||||
// and a SimilarityScore that drives distinguishable scores when passed
|
||||
// through recommendation.Score. Using SimilarityScore alone is sufficient
|
||||
// because systemMixWeights.SimilarityWeight = 1.5 (non-zero), so
|
||||
// different similarity values produce different scores.
|
||||
//
|
||||
// Tests below use similarity values with gaps wide enough that the
|
||||
// systemMixWeights JitterMagnitude (±0.1) cannot perturb the top-of-pool
|
||||
// ordering, so assertions about head order remain meaningful.
|
||||
func makeCand(trackN, albumN, artistN int, similarity float64) recommendation.Candidate {
|
||||
var tID, aID, arID pgtype.UUID
|
||||
tID.Valid, aID.Valid, arID.Valid = true, true, true
|
||||
@@ -90,7 +99,7 @@ func TestPickHeadAndTail_SmallPool(t *testing.T) {
|
||||
makeCand(2, 11, 101, 0.9),
|
||||
makeCand(3, 12, 102, 0.8),
|
||||
}
|
||||
got := pickHeadAndTail(in, "2026-05-07", time.Now(), 5, 2)
|
||||
got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 5, 2)
|
||||
if len(got) != 3 {
|
||||
t.Errorf("len = %d, want 3 (pool too small for head/tail split)", len(got))
|
||||
}
|
||||
@@ -98,11 +107,12 @@ func TestPickHeadAndTail_SmallPool(t *testing.T) {
|
||||
|
||||
func TestPickHeadAndTail_ExactlyTotal(t *testing.T) {
|
||||
// Pool == headN+tailN: no tail to sample from, returns all.
|
||||
// Wide score gaps (1.0 per step) keep jitter from perturbing order.
|
||||
in := make([]recommendation.Candidate, 0, 7)
|
||||
for i := 0; i < 7; i++ {
|
||||
in = append(in, makeCand(i+1, i+1, i+1, float64(7-i)/10.0))
|
||||
in = append(in, makeCand(i+1, i+1, i+1, float64(7-i)))
|
||||
}
|
||||
got := pickHeadAndTail(in, "2026-05-07", time.Now(), 5, 2)
|
||||
got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 5, 2)
|
||||
if len(got) != 7 {
|
||||
t.Errorf("len = %d, want 7 (pool == total)", len(got))
|
||||
}
|
||||
@@ -111,11 +121,12 @@ func TestPickHeadAndTail_ExactlyTotal(t *testing.T) {
|
||||
func TestPickHeadAndTail_HeadAndTailSplit(t *testing.T) {
|
||||
// Pool of 100 distinct (album, artist) pairs; no caps trim.
|
||||
// With headN=20, tailN=5, expect 25 entries total.
|
||||
// Wide score gaps (1.0 per step) keep jitter from perturbing order.
|
||||
in := make([]recommendation.Candidate, 0, 100)
|
||||
for i := 0; i < 100; i++ {
|
||||
in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)/100.0))
|
||||
in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
|
||||
}
|
||||
got := pickHeadAndTail(in, "2026-05-07", time.Now(), 20, 5)
|
||||
got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 20, 5)
|
||||
if len(got) != 25 {
|
||||
t.Errorf("len = %d, want 25 (20 head + 5 tail)", len(got))
|
||||
}
|
||||
@@ -125,11 +136,11 @@ func TestPickHeadAndTail_Determinism(t *testing.T) {
|
||||
// Same inputs, same dateStr → identical output both times.
|
||||
in := make([]recommendation.Candidate, 0, 100)
|
||||
for i := 0; i < 100; i++ {
|
||||
in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)/100.0))
|
||||
in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
|
||||
}
|
||||
now := time.Now()
|
||||
got1 := pickHeadAndTail(in, "2026-05-07", now, 20, 5)
|
||||
got2 := pickHeadAndTail(in, "2026-05-07", now, 20, 5)
|
||||
got1 := pickHeadAndTail(in, testUserID, "2026-05-07", now, 20, 5)
|
||||
got2 := pickHeadAndTail(in, testUserID, "2026-05-07", now, 20, 5)
|
||||
if len(got1) != len(got2) {
|
||||
t.Fatalf("len mismatch: %d vs %d", len(got1), len(got2))
|
||||
}
|
||||
@@ -147,11 +158,11 @@ func TestPickHeadAndTail_HeadStable_TailVariesAcrossDays(t *testing.T) {
|
||||
// because tieBreakHash incorporates the date.
|
||||
in := make([]recommendation.Candidate, 0, 100)
|
||||
for i := 0; i < 100; i++ {
|
||||
in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)/100.0))
|
||||
in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
|
||||
}
|
||||
now := time.Now()
|
||||
day1 := pickHeadAndTail(in, "2026-05-07", now, 20, 5)
|
||||
day2 := pickHeadAndTail(in, "2026-05-08", now, 20, 5)
|
||||
day1 := pickHeadAndTail(in, testUserID, "2026-05-07", now, 20, 5)
|
||||
day2 := pickHeadAndTail(in, testUserID, "2026-05-08", now, 20, 5)
|
||||
if len(day1) != 25 || len(day2) != 25 {
|
||||
t.Fatalf("len mismatch: day1=%d day2=%d", len(day1), len(day2))
|
||||
}
|
||||
@@ -190,10 +201,11 @@ func TestPickHeadAndTail_TailFromBeyond2xHeadN(t *testing.T) {
|
||||
in := make([]recommendation.Candidate, 0, 50)
|
||||
for i := 0; i < 50; i++ {
|
||||
// trackN = i+1, score descends: track 1 scores highest.
|
||||
sim := float64(50-i) / 50.0
|
||||
// Wide score gaps (1.0 per step) keep jitter from perturbing order.
|
||||
sim := float64(50 - i)
|
||||
in = append(in, makeCand(i+1, i+1, i+1, sim))
|
||||
}
|
||||
got := pickHeadAndTail(in, "2026-05-07", time.Now(), 5, 3)
|
||||
got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 5, 3)
|
||||
if len(got) != 8 {
|
||||
t.Fatalf("len = %d, want 8 (5 head + 3 tail)", len(got))
|
||||
}
|
||||
@@ -226,7 +238,7 @@ func TestPickTopN_DiversityCap(t *testing.T) {
|
||||
makeCand(4, 13, 100, 0.7), // 4th by artist → dropped by cap
|
||||
makeCand(5, 14, 100, 0.6), // 5th by artist → dropped by cap
|
||||
}
|
||||
got := pickTopN(in, "2026-05-07", time.Now(), 25)
|
||||
got := pickTopN(in, testUserID, "2026-05-07", time.Now(), 25)
|
||||
if len(got) != 3 {
|
||||
t.Errorf("len = %d, want 3 (artist 100 capped at 3)", len(got))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user