The same rewrite as d191a282, for four packages whose comments carried the
most history: tuning stories, quotes from the operator, single-install
measurements in library_scale.go, and 'used to' accounts of bugs. The
constraint each story protected stays as a present-tense reason (why tier 3
is reduced not removed, why the randomised arms take an order seed, why the
seed artist is eligible in Songs-like). Comments naming system_cron.go and
config.RecommendationConfig, neither of which holds what they claimed, now
name the scheduler and internal/recsettings.
Checked: non-comment tokens identical to HEAD; gofmt clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
195 lines
6.9 KiB
Go
195 lines
6.9 KiB
Go
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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|