diff --git a/internal/recommendation/shuffle.go b/internal/recommendation/shuffle.go new file mode 100644 index 00000000..d81eedfe --- /dev/null +++ b/internal/recommendation/shuffle.go @@ -0,0 +1,48 @@ +package recommendation + +import ( + "sort" + "time" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// Candidate pairs a track with the inputs needed to score it. +type Candidate struct { + Track dbq.Track + Inputs ScoringInputs +} + +// Shuffle scores each candidate, sorts descending by score, and returns +// the top `limit` candidates. limit <= 0 returns nil; nil input returns +// nil. Pure — no IO, no global state beyond the rng callback. +func Shuffle( + candidates []Candidate, + weights ScoringWeights, + now time.Time, + rng func() float64, + limit int, +) []Candidate { + if len(candidates) == 0 || limit <= 0 { + return nil + } + scored := make([]struct { + c Candidate + score float64 + }, len(candidates)) + for i, c := range candidates { + scored[i].c = c + scored[i].score = Score(c.Inputs, weights, now, rng) + } + sort.Slice(scored, func(i, j int) bool { + return scored[i].score > scored[j].score + }) + if limit > len(scored) { + limit = len(scored) + } + out := make([]Candidate, limit) + for i := 0; i < limit; i++ { + out[i] = scored[i].c + } + return out +} diff --git a/internal/recommendation/shuffle_test.go b/internal/recommendation/shuffle_test.go new file mode 100644 index 00000000..ec3c2341 --- /dev/null +++ b/internal/recommendation/shuffle_test.go @@ -0,0 +1,86 @@ +package recommendation + +import ( + "math/rand" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +func cand(id string, in ScoringInputs) Candidate { + t := dbq.Track{Title: id} + _ = t.ID.Scan("00000000-0000-0000-0000-" + id) // 12-char id padded + return Candidate{Track: t, Inputs: in} +} + +func TestShuffle_LikedRanksAboveUnliked(t *testing.T) { + cs := []Candidate{ + cand("000000000001", ScoringInputs{IsGeneralLiked: false}), + cand("000000000002", ScoringInputs{IsGeneralLiked: true}), + } + out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10) + if out[0].Track.Title != "000000000002" { + t.Errorf("liked track did not rank first: %+v", out) + } +} + +func TestShuffle_HighSkipRanksLast(t *testing.T) { + cs := []Candidate{ + cand("000000000001", ScoringInputs{PlayCount: 10, SkipCount: 10}), // ratio 1.0 + cand("000000000002", ScoringInputs{PlayCount: 10, SkipCount: 0}), // ratio 0 + cand("000000000003", ScoringInputs{PlayCount: 10, SkipCount: 5}), // ratio 0.5 + } + out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10) + if out[0].Track.Title != "000000000002" || out[2].Track.Title != "000000000001" { + t.Errorf("skip-ratio ordering broken: %v", titles(out)) + } +} + +func TestShuffle_LimitTruncates(t *testing.T) { + cs := make([]Candidate, 100) + for i := range cs { + cs[i] = cand("00000000000"+string(rune('a'+i%26)), ScoringInputs{}) + } + out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10) + if len(out) != 10 { + t.Errorf("len = %d, want 10", len(out)) + } +} + +func TestShuffle_JitterDoesNotFlipStructuralWinner(t *testing.T) { + // Liked vs unliked: even with many random RNG seeds, liked NEVER ranks below unliked. + r := rand.New(rand.NewSource(42)) + for i := 0; i < 500; i++ { + cs := []Candidate{ + cand("000000000001", ScoringInputs{IsGeneralLiked: false}), + cand("000000000002", ScoringInputs{IsGeneralLiked: true}), + } + out := Shuffle(cs, defaultWeights(), time.Now(), r.Float64, 10) + if out[0].Track.Title != "000000000002" { + t.Fatalf("iter %d: liked did not rank first; out=%v", i, titles(out)) + } + } +} + +func TestShuffle_Empty_ReturnsEmpty(t *testing.T) { + out := Shuffle(nil, defaultWeights(), time.Now(), fixedRNG(0.5), 10) + if len(out) != 0 { + t.Errorf("len = %d, want 0", len(out)) + } +} + +func titles(cs []Candidate) []string { + out := make([]string, 0, len(cs)) + for _, c := range cs { + out = append(out, c.Track.Title) + } + return out +} + +// pgtype-uuid placeholder: Candidate doesn't need a real UUID for these +// pure tests; the Track.Title field is the human-readable handle. Track ID +// is left as zero pgtype.UUID and never compared. +var _ pgtype.UUID