package recommendation import ( "fmt" "testing" "time" "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) // candBy builds a candidate with a real artist and album identity, which // `cand` deliberately leaves zero. func candBy(t *testing.T, id, artist, album string, in ScoringInputs) Candidate { t.Helper() tr := dbq.Track{Title: id} if err := tr.ID.Scan("00000000-0000-0000-0000-" + id); err != nil { t.Fatalf("track id %q: %v", id, err) } if err := tr.ArtistID.Scan("00000000-0000-0000-0001-" + artist); err != nil { t.Fatalf("artist id %q: %v", artist, err) } if err := tr.AlbumID.Scan("00000000-0000-0000-0002-" + album); err != nil { t.Fatalf("album id %q: %v", album, err) } return Candidate{Track: tr, Inputs: in} } // artistKey is the map key artistsOf produces for a given fixture artist, // derived the same way the fixture builds the UUID. Hand-writing the hex is // how the first version of this test went wrong: the artist id is not all // zeros — it carries 0001 in its fourth group — so the literal did not match // and the assertion measured nothing. func artistKey(t *testing.T, artist string) string { t.Helper() var id pgtype.UUID if err := id.Scan("00000000-0000-0000-0001-" + artist); err != nil { t.Fatalf("artist id %q: %v", artist, err) } return fmt.Sprintf("%x", id.Bytes) } // artistsOf counts how many picks each artist contributed. func artistsOf(picks []Candidate) map[string]int { out := map[string]int{} for _, p := range picks { out[fmt.Sprintf("%x", p.Track.ArtistID.Bytes)]++ } return out } // THE BUG. Operator, 2026-09-10: started radio from a song and "literally // all of the songs in the playlist after that were from a single artist". // // One artist's tracks all outscore everything else, and there are more of // them than the radio has room for. Without a cap the output is entirely // that artist — nothing between the pool and the result bounded its share. func TestShuffle_CapStopsOneArtistTakingTheWholeRadio(t *testing.T) { var cs []Candidate // 20 tracks by artist A, all liked so they sort to the top. for i := 0; i < 20; i++ { cs = append(cs, candBy(t, fmt.Sprintf("%012d", i), "00000000000a", fmt.Sprintf("%012d", i), ScoringInputs{IsGeneralLiked: true})) } // 10 tracks by 10 other artists, none liked, so they all rank below. for i := 0; i < 10; i++ { cs = append(cs, candBy(t, fmt.Sprintf("%012d", 100+i), fmt.Sprintf("%012d", 200+i), fmt.Sprintf("%012d", 100+i), ScoringInputs{IsGeneralLiked: false})) } const limit = 10 caps := DiversityCaps{MaxPerArtist: 3} picks := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), limit, caps) if len(picks) != limit { t.Fatalf("len = %d, want %d", len(picks), limit) } byArtist := artistsOf(picks) artistA := artistKey(t, "00000000000a") if got := byArtist[artistA]; got != 3 { t.Errorf("artist A contributed %d of %d picks, cap is 3", got, limit) } if len(byArtist) < 8 { t.Errorf("only %d distinct artists in a %d-track radio; the cap is not "+ "spreading the selection", len(byArtist), limit) } // The same pool with NO cap is the regression this exists to catch. If // this stops holding, the test above is no longer proving anything. uncapped := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), limit, DiversityCaps{}) if artistsOf(uncapped)[artistA] != limit { t.Errorf("uncapped selection was not single-artist, so this fixture no " + "longer reproduces the bug being fixed") } } // A CAP IS A BOUND, NOT AN EXCLUSION. The operator asked for the opposite of // removal: "again it should be able to add songs from the same artist." func TestShuffle_CappedArtistIsStillRepresented(t *testing.T) { var cs []Candidate for i := 0; i < 20; i++ { cs = append(cs, candBy(t, fmt.Sprintf("%012d", i), "00000000000a", fmt.Sprintf("%012d", i), ScoringInputs{IsGeneralLiked: true})) } for i := 0; i < 10; i++ { cs = append(cs, candBy(t, fmt.Sprintf("%012d", 100+i), fmt.Sprintf("%012d", 200+i), fmt.Sprintf("%012d", 100+i), ScoringInputs{IsGeneralLiked: false})) } picks := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10, DiversityCaps{MaxPerArtist: 3}) if artistsOf(picks)[artistKey(t, "00000000000a")] == 0 { t.Error("the dominant artist was excluded entirely; the cap should bound " + "its share, not remove it") } } // RULE 131, applied past the system mixes it was written for: degrade, never // vanish. A hard cap over a pool with few artists would hand back a six-track // "radio" for a fifty-track request. The caps must change WHICH tracks are // picked, never HOW MANY. func TestShuffle_CapsNeverShortenTheResult(t *testing.T) { for _, tc := range []struct { name string artists int perArt int limit int }{ {"one artist owns the entire pool", 1, 30, 10}, {"two artists, tight cap", 2, 15, 20}, {"pool smaller than the request", 3, 2, 50}, } { t.Run(tc.name, func(t *testing.T) { var cs []Candidate n := 0 for a := 0; a < tc.artists; a++ { for k := 0; k < tc.perArt; k++ { cs = append(cs, candBy(t, fmt.Sprintf("%012d", n), fmt.Sprintf("%012d", 300+a), fmt.Sprintf("%012d", n), ScoringInputs{})) n++ } } want := tc.limit if len(cs) < want { want = len(cs) } picks := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), tc.limit, DiversityCaps{MaxPerArtist: 3, MaxPerAlbum: 2}) if len(picks) != want { t.Errorf("len = %d, want %d — the caps shortened the result instead "+ "of relaxing to fill it", len(picks), want) } // No duplicates: a candidate deferred in pass one must not also be // taken in pass two. seen := map[[16]byte]bool{} for _, p := range picks { if seen[p.Track.ID.Bytes] { t.Fatalf("track %x appears twice; pass two re-added a pick", p.Track.ID.Bytes) } seen[p.Track.ID.Bytes] = true } }) } } // A fixed count cannot serve both a 20-track radio and a 200-track one: three // per artist is 12% of the first and 1.5% of the second, which would leave the // long radio permanently in the relaxation path and effectively uncapped. func TestRadioDiversityCaps_ScaleWithTheRequestedLength(t *testing.T) { short := RadioDiversityCaps(25) long := RadioDiversityCaps(200) if short.MaxPerArtist != 3 { t.Errorf("a 25-track radio caps artists at %d, want 3 — the same "+ "proportion the system mixes use", short.MaxPerArtist) } if long.MaxPerArtist <= short.MaxPerArtist { t.Errorf("a 200-track radio caps artists at %d, no higher than a 25-track "+ "one at %d; the cap is not scaling", long.MaxPerArtist, short.MaxPerArtist) } // Proportion held, not just "bigger". if long.MaxPerArtist != 24 { t.Errorf("200-track artist cap = %d, want 24 (3 per 25)", long.MaxPerArtist) } // Floors: a tiny radio must not be capped down to one track per artist. tiny := RadioDiversityCaps(1) if tiny.MaxPerArtist < 2 { t.Errorf("tiny radio artist cap = %d, want at least 2", tiny.MaxPerArtist) } if tiny.MaxPerAlbum < 1 { t.Errorf("tiny radio album cap = %d, want at least 1", tiny.MaxPerAlbum) } }