Files
minstrel/internal/playlists/foryou_test.go
T
bvandeusen fb4431207d
test-go / test (push) Successful in 31s
test-web / test (push) Successful in 37s
test-go / integration (push) Successful in 4m37s
feat(recommendation): For You exploration attribution — taste vs fresh picks
Milestone #127 step 2 (#1249). For You deliberately blends two
populations — a head of top-scored taste picks and a tail sampled from
deeper ranking (the freshness injection) — but the metrics judged it as
one blob, so its skip rate couldn't distinguish "the taste engine is
missing" from "the freshness tax is too high". That number decides the
exploration share before we tune it.

- Migration 0038: nullable pick_kind ('taste'|'fresh') on both
  playlist_tracks (stamped at snapshot build) and play_events (frozen at
  play-ingestion — the snapshot rebuilds daily, so attribution cannot be
  reconstructed at read time).
- Builder: pickHeadAndTail marks head=taste / tail=fresh; the small-pool
  fallback is all taste (top-N-by-score IS the taste mechanism). Other
  variants persist NULL.
- Ingestion: for_you plays (live + offline replay) look the track up in
  the user's current snapshot; not found → unattributed, never guessed.
- Metrics: For You's row gains a breakdown (taste / fresh / earlier
  unattributed plays), parent row stays the sum; web card renders the
  sub-rows indented with the same baseline deltas + low-data dimming.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-02 20:39:41 -04:00

296 lines
10 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, 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, 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, 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, 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))
}
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, 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))
}
// 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, 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, 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, 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)
}
}