9e02878b61
Two approved composition changes (#1269), mechanism only — the taste/fresh share stays data-decided (#1252) and pick_kind attribution is unchanged. Multi-seed blending: each day's build now seeds from up to 3 of the user's top-5 tracks (pickDailySeeds, the generalized daily shuffle) instead of one rotating anchor, so the mix spans neighborhoods within a day and stops feeling bipolar as the rotation swings between dissimilar seeds. Per-seed pools merge first-seen-deduped; the head is filled best-first under 50/30/20 per-seed quotas (60/40 for two seeds) so one neighborhood can't monopolize it, with thin-seed quota spilling best-first. Score-weighted fresh tail: the tail sample (rank 2*headN onward) was uniform — the 380th-best candidate as likely as the 101st. It now uses deterministic Efraimidis-Spirakis keys with weight halving every 50 ranks, so freshness keeps its "you'll probably enjoy this" half while still rotating daily. The retired single-seed picker's one other caller, You-might-like, moves to pickDailySeeds(n=1) — a single neighborhood per day is right for a short shelf, and the behavior note is inline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
433 lines
14 KiB
Go
433 lines
14 KiB
Go
package playlists
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
"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
|
|
tID.Bytes[15] = byte(trackN)
|
|
aID.Bytes[15] = byte(albumN)
|
|
arID.Bytes[15] = byte(artistN)
|
|
return recommendation.Candidate{
|
|
Track: dbq.Track{ID: tID, AlbumID: aID, ArtistID: arID},
|
|
Inputs: recommendation.ScoringInputs{
|
|
SimilarityScore: similarity,
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestCapCandidatesByAlbumAndArtist_AlbumCap(t *testing.T) {
|
|
// discoverMaxTracksPerAlbum = 2: third track on album 10 must be dropped.
|
|
in := []recommendation.Candidate{
|
|
makeCand(1, 10, 100, 1.0),
|
|
makeCand(2, 10, 101, 0.9),
|
|
makeCand(3, 10, 102, 0.8), // 3rd from album 10 → drop
|
|
makeCand(4, 11, 103, 0.7),
|
|
}
|
|
got := capCandidatesByAlbumAndArtist(in)
|
|
if len(got) != 3 {
|
|
t.Errorf("len = %d, want 3 (album 10 capped at 2)", len(got))
|
|
}
|
|
}
|
|
|
|
func TestCapCandidatesByAlbumAndArtist_ArtistCap(t *testing.T) {
|
|
// discoverMaxTracksPerArtist = 3: fourth track by artist 100 must be dropped.
|
|
in := []recommendation.Candidate{
|
|
makeCand(1, 10, 100, 1.0),
|
|
makeCand(2, 11, 100, 0.9),
|
|
makeCand(3, 12, 100, 0.8),
|
|
makeCand(4, 13, 100, 0.7), // 4th by artist 100 → drop
|
|
makeCand(5, 14, 101, 0.6),
|
|
}
|
|
got := capCandidatesByAlbumAndArtist(in)
|
|
if len(got) != 4 {
|
|
t.Errorf("len = %d, want 4 (artist 100 capped at 3)", len(got))
|
|
}
|
|
}
|
|
|
|
func TestCapCandidatesByAlbumAndArtist_PreservesOrder(t *testing.T) {
|
|
// All distinct albums and artists: all kept, original order preserved.
|
|
in := []recommendation.Candidate{
|
|
makeCand(3, 10, 100, 0.5),
|
|
makeCand(1, 11, 101, 0.9),
|
|
makeCand(2, 12, 102, 0.7),
|
|
}
|
|
got := capCandidatesByAlbumAndArtist(in)
|
|
if len(got) != 3 {
|
|
t.Fatalf("len = %d, want 3", len(got))
|
|
}
|
|
for i, want := range []byte{3, 1, 2} {
|
|
if got[i].Track.ID.Bytes[15] != want {
|
|
t.Errorf("got[%d].ID = %d, want %d", i, got[i].Track.ID.Bytes[15], want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCapCandidatesByAlbumAndArtist_Empty(t *testing.T) {
|
|
got := capCandidatesByAlbumAndArtist(nil)
|
|
if len(got) != 0 {
|
|
t.Errorf("len = %d, want 0", len(got))
|
|
}
|
|
}
|
|
|
|
func TestPickHeadAndTail_SmallPool(t *testing.T) {
|
|
// Pool < headN+tailN → return up to total entries, no tail split.
|
|
in := []recommendation.Candidate{
|
|
makeCand(1, 10, 100, 1.0),
|
|
makeCand(2, 11, 101, 0.9),
|
|
makeCand(3, 12, 102, 0.8),
|
|
}
|
|
got := pickHeadAndTail(in, nil, 1, 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))
|
|
}
|
|
}
|
|
|
|
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)))
|
|
}
|
|
got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 5, 2)
|
|
if len(got) != 7 {
|
|
t.Errorf("len = %d, want 7 (pool == total)", len(got))
|
|
}
|
|
}
|
|
|
|
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)))
|
|
}
|
|
got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 20, 5)
|
|
if len(got) != 25 {
|
|
t.Errorf("len = %d, want 25 (20 head + 5 tail)", len(got))
|
|
}
|
|
}
|
|
|
|
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)))
|
|
}
|
|
now := time.Now()
|
|
got1 := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", now, 20, 5)
|
|
got2 := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", now, 20, 5)
|
|
if len(got1) != len(got2) {
|
|
t.Fatalf("len mismatch: %d vs %d", len(got1), len(got2))
|
|
}
|
|
for i := range got1 {
|
|
if got1[i].TrackID.Bytes[15] != got2[i].TrackID.Bytes[15] {
|
|
t.Errorf("position %d differs across calls (not deterministic within day)", i)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPickHeadAndTail_HeadStable_TailVariesAcrossDays(t *testing.T) {
|
|
// Head (positions 0..headN-1) must match across different date strings.
|
|
// Tail (positions headN..headN+tailN-1) should differ for different dates
|
|
// 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)))
|
|
}
|
|
now := time.Now()
|
|
day1 := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", now, 20, 5)
|
|
day2 := pickHeadAndTail(in, nil, 1, 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))
|
|
}
|
|
|
|
// Head (score-sorted, deterministic): positions 0..19 must match.
|
|
for i := 0; i < 20; i++ {
|
|
if day1[i].TrackID.Bytes[15] != day2[i].TrackID.Bytes[15] {
|
|
t.Errorf("head position %d differs across date strings (should be score-stable)", i)
|
|
}
|
|
}
|
|
|
|
// Tail (tieBreakHash order): at least one position should differ.
|
|
tailDiffers := false
|
|
for i := 20; i < 25; i++ {
|
|
if day1[i].TrackID.Bytes[15] != day2[i].TrackID.Bytes[15] {
|
|
tailDiffers = true
|
|
break
|
|
}
|
|
}
|
|
if !tailDiffers {
|
|
t.Errorf("tail identical across different date strings (tieBreakHash not using dateStr)")
|
|
}
|
|
}
|
|
|
|
func TestPickHeadAndTail_TailFromBeyond2xHeadN(t *testing.T) {
|
|
// With headN=5 and a 50-candidate pool, tailStart = 2*5 = 10.
|
|
// Tail samples come from positions >=10, not from positions 5..9.
|
|
// Verify: the first 5 positions contain tracks from the top-5 by score,
|
|
// and positions 5..9 (the "buffer zone") do not appear in the tail.
|
|
//
|
|
// Tracks 1..50 have similarity 0.99..0.01 (descending), so
|
|
// after scoring the sorted order is tracks 1,2,3,...,50.
|
|
// Top 5 head = tracks 1..5.
|
|
// Buffer zone (positions 5..9) = tracks 6..10.
|
|
// Tail pool = tracks 11..50; tail sample = 3 from that pool.
|
|
in := make([]recommendation.Candidate, 0, 50)
|
|
for i := 0; i < 50; i++ {
|
|
// trackN = i+1, score descends: track 1 scores highest.
|
|
// 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, nil, 1, testUserID, "2026-05-07", time.Now(), 5, 3)
|
|
if len(got) != 8 {
|
|
t.Fatalf("len = %d, want 8 (5 head + 3 tail)", len(got))
|
|
}
|
|
|
|
// Head tracks must be tracks 1..5 (highest scorers).
|
|
for i := 0; i < 5; i++ {
|
|
if got[i].TrackID.Bytes[15] != byte(i+1) {
|
|
t.Errorf("head[%d] = track %d, want track %d",
|
|
i, got[i].TrackID.Bytes[15], i+1)
|
|
}
|
|
}
|
|
|
|
// Buffer-zone tracks 6..10 must NOT appear in the tail positions 5..7.
|
|
bufferSet := map[byte]bool{6: true, 7: true, 8: true, 9: true, 10: true}
|
|
for i := 5; i < 8; i++ {
|
|
if bufferSet[got[i].TrackID.Bytes[15]] {
|
|
t.Errorf("tail[%d] = track %d (buffer zone); should come from positions >=10",
|
|
i-5, got[i].TrackID.Bytes[15])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPickTopN_DiversityCap(t *testing.T) {
|
|
// Verify pickTopN now enforces the diversity cap.
|
|
// 5 tracks from the same artist: only 3 should survive the cap.
|
|
in := []recommendation.Candidate{
|
|
makeCand(1, 10, 100, 1.0),
|
|
makeCand(2, 11, 100, 0.9),
|
|
makeCand(3, 12, 100, 0.8),
|
|
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, testUserID, "2026-05-07", time.Now(), 25)
|
|
if len(got) != 3 {
|
|
t.Errorf("len = %d, want 3 (artist 100 capped at 3)", len(got))
|
|
}
|
|
}
|
|
|
|
func TestPickHeadAndTail_MarksPickKinds(t *testing.T) {
|
|
// 100-deep pool with headN=20, tailN=5: the 20 head entries are the
|
|
// taste picks, the 5 tail entries the freshness injection (#1249).
|
|
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)))
|
|
}
|
|
got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 20, 5)
|
|
if len(got) != 25 {
|
|
t.Fatalf("len = %d, want 25", len(got))
|
|
}
|
|
for i := 0; i < 20; i++ {
|
|
if got[i].PickKind != pickKindTaste {
|
|
t.Errorf("head[%d].PickKind = %q, want %q", i, got[i].PickKind, pickKindTaste)
|
|
}
|
|
}
|
|
for i := 20; i < 25; i++ {
|
|
if got[i].PickKind != pickKindFresh {
|
|
t.Errorf("tail[%d].PickKind = %q, want %q", i-20, got[i].PickKind, pickKindFresh)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPickHeadAndTail_SmallPoolAllTaste(t *testing.T) {
|
|
// The small-pool fallback is pure top-N-by-score — that IS the taste
|
|
// mechanism, so nothing on this path is an exploration pick.
|
|
in := []recommendation.Candidate{
|
|
makeCand(1, 10, 100, 1.0),
|
|
makeCand(2, 11, 101, 0.9),
|
|
makeCand(3, 12, 102, 0.8),
|
|
}
|
|
got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 5, 2)
|
|
for i, rc := range got {
|
|
if rc.PickKind != pickKindTaste {
|
|
t.Errorf("got[%d].PickKind = %q, want %q (fallback is all taste)",
|
|
i, rc.PickKind, pickKindTaste)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPickTopN_NoPickKind(t *testing.T) {
|
|
// Songs-like-X and the discovery mixes don't split; their rows must
|
|
// persist pick_kind NULL (empty string here).
|
|
in := []recommendation.Candidate{makeCand(1, 10, 100, 1.0)}
|
|
got := pickTopN(in, testUserID, "2026-05-07", time.Now(), 25)
|
|
if len(got) != 1 || got[0].PickKind != "" {
|
|
t.Errorf("pickTopN PickKind = %q, want empty (persists as NULL)", got[0].PickKind)
|
|
}
|
|
}
|
|
|
|
func TestHeadQuotas(t *testing.T) {
|
|
cases := []struct {
|
|
seeds, headN int
|
|
want []int
|
|
}{
|
|
{1, 50, []int{50}},
|
|
{2, 50, []int{30, 20}},
|
|
{3, 50, []int{25, 15, 10}},
|
|
{3, 7, []int{4, 2, 1}}, // rounding remainder → primary seed
|
|
}
|
|
for _, c := range cases {
|
|
got := headQuotas(c.seeds, c.headN)
|
|
if len(got) != len(c.want) {
|
|
t.Fatalf("headQuotas(%d, %d) len = %d, want %d", c.seeds, c.headN, len(got), len(c.want))
|
|
}
|
|
sum := 0
|
|
for i := range got {
|
|
sum += got[i]
|
|
if got[i] != c.want[i] {
|
|
t.Errorf("headQuotas(%d, %d)[%d] = %d, want %d", c.seeds, c.headN, i, got[i], c.want[i])
|
|
}
|
|
}
|
|
if sum != c.headN {
|
|
t.Errorf("headQuotas(%d, %d) sums to %d, want %d", c.seeds, c.headN, sum, c.headN)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPickQuotaHead_BlendsSeeds(t *testing.T) {
|
|
// Seed 0's neighborhood out-scores everything (#1269): without
|
|
// quotas the head would be all seed-0. With headN=10 and quotas
|
|
// [5,3,2], the walk takes the best 5 from seed 0, then seeds 1 and
|
|
// 2 get their guaranteed slots from deeper ranks.
|
|
in := make([]recommendation.Candidate, 0, 30)
|
|
seedOf := map[pgtype.UUID]int{}
|
|
for i := 0; i < 30; i++ {
|
|
c := makeCand(i+1, i+1, i+1, float64(30-i))
|
|
si := 0
|
|
if i >= 20 && i < 25 {
|
|
si = 1
|
|
} else if i >= 25 {
|
|
si = 2
|
|
}
|
|
seedOf[c.Track.ID] = si
|
|
in = append(in, c)
|
|
}
|
|
// Input is already score-descending and cap-free.
|
|
head := pickQuotaHead(in, seedOf, 3, 10)
|
|
if len(head) != 10 {
|
|
t.Fatalf("head len = %d, want 10", len(head))
|
|
}
|
|
want := []byte{1, 2, 3, 4, 5, 21, 22, 23, 26, 27}
|
|
for i, w := range want {
|
|
if head[i].Track.ID.Bytes[15] != w {
|
|
t.Errorf("head[%d] = track %d, want %d", i, head[i].Track.ID.Bytes[15], w)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPickQuotaHead_ThinSeedSpillsBestFirst(t *testing.T) {
|
|
// Seed 1 has a single candidate; its unfilled quota goes to the
|
|
// best remaining candidates regardless of seed.
|
|
in := make([]recommendation.Candidate, 0, 12)
|
|
seedOf := map[pgtype.UUID]int{}
|
|
for i := 0; i < 12; i++ {
|
|
c := makeCand(i+1, i+1, i+1, float64(12-i))
|
|
si := 0
|
|
if i == 11 {
|
|
si = 1 // the worst-ranked candidate is seed 1's only one
|
|
}
|
|
seedOf[c.Track.ID] = si
|
|
in = append(in, c)
|
|
}
|
|
head := pickQuotaHead(in, seedOf, 2, 10)
|
|
if len(head) != 10 {
|
|
t.Fatalf("head len = %d, want 10", len(head))
|
|
}
|
|
// Quotas [6,4]: seed 0 fills 6 (tracks 1-6), seed 1 fills 1
|
|
// (track 12), spill tops up with tracks 7-9.
|
|
got := map[byte]bool{}
|
|
for _, c := range head {
|
|
got[c.Track.ID.Bytes[15]] = true
|
|
}
|
|
for _, w := range []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 12} {
|
|
if !got[w] {
|
|
t.Errorf("head missing track %d (spill should fill best-first)", w)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPickWeightedTail_PrefersEarlyRanks(t *testing.T) {
|
|
// The fresh tail's sample is rank-weighted (#1269): across many
|
|
// days, the mean selected rank must sit well below the uniform
|
|
// draw's ~99.5 for a 200-deep pool.
|
|
pool := make([]recommendation.Candidate, 0, 200)
|
|
for i := 0; i < 200; i++ {
|
|
c := makeCand(1, i+1, i+1, 1.0)
|
|
// Distinct track IDs beyond one byte: bytes wrap at 256.
|
|
c.Track.ID.Bytes[14] = byte(i / 200)
|
|
c.Track.ID.Bytes[15] = byte(i % 200)
|
|
pool = append(pool, c)
|
|
}
|
|
rankOf := map[pgtype.UUID]int{}
|
|
for i, c := range pool {
|
|
rankOf[c.Track.ID] = i
|
|
}
|
|
totalRank, picks := 0, 0
|
|
for day := 1; day <= 28; day++ {
|
|
got := pickWeightedTail(pool, "2026-06-"+twoDigits(day), 10)
|
|
if len(got) != 10 {
|
|
t.Fatalf("day %d: len = %d, want 10", day, len(got))
|
|
}
|
|
for _, c := range got {
|
|
totalRank += rankOf[c.Track.ID]
|
|
picks++
|
|
}
|
|
}
|
|
mean := float64(totalRank) / float64(picks)
|
|
if mean > 80 {
|
|
t.Errorf("mean selected rank = %.1f; want well below uniform ~99.5", mean)
|
|
}
|
|
}
|
|
|
|
func TestPickWeightedTail_DeterministicWithinDay(t *testing.T) {
|
|
pool := make([]recommendation.Candidate, 0, 60)
|
|
for i := 0; i < 60; i++ {
|
|
pool = append(pool, makeCand(i+1, i+1, i+1, 1.0))
|
|
}
|
|
a := pickWeightedTail(pool, "2026-06-05", 8)
|
|
b := pickWeightedTail(pool, "2026-06-05", 8)
|
|
for i := range a {
|
|
if a[i].Track.ID != b[i].Track.ID {
|
|
t.Fatal("weighted tail must be deterministic within a day")
|
|
}
|
|
}
|
|
}
|