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).
This commit is contained in:
2026-04-27 07:39:16 -04:00
parent 546234187f
commit 8c3d06c0a1
2 changed files with 134 additions and 0 deletions
+48
View File
@@ -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
}
+86
View File
@@ -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