Files
bvandeusen 8c3d06c0a1 feat(recommendation): add Shuffle orchestrator
Composes Score over a candidate slice, sorts descending, truncates.
Pure — no IO. Liked-rank-higher and high-skip-ranks-last behaviors
are stable across RNG seeds (jitter band is smaller than LikeBoost
and SkipPenalty).
2026-04-27 07:39:16 -04:00

87 lines
2.6 KiB
Go

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