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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user