feat: M3 weighted shuffle v1 — real /api/radio with scoring #21
Reference in New Issue
Block a user
Delete Branch "dev"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
First M3 sub-plan (Fable #340). Replaces the M2 stub
/api/radiowith a real weighted-shuffle implementation. Single-slice PR, single-purpose.What this ships
Per spec §6 weighted-shuffle algorithm and §13 step 7. The next two M3 sub-plans (#341 session vectors + contextual_likes capture, #342 contextual_match_score in scoring) compose on top of this without disturbing the call shape.
Server
internal/recommendationpackage, three layers:score.go— pureScore(inputs, weights, now, rng) → float64. Implementsbase + (liked ? LikeBoost : 0) + recency_decay * RecencyWeight - skip_ratio * SkipPenalty + jitter.recencyDecayramps[0,1]over 30 days; never-played returns 1.0 (cold-start bias).skipRatioreturns 0 for never-played.rngis injectable for deterministic tests.shuffle.go— pureShuffle(candidates, weights, now, rng, limit) []Candidate. Scores, sorts descending, truncates.Candidatepairsdbq.TrackwithScoringInputs.candidates.go—LoadCandidates(...)wraps the new sqlc query and projects rows to[]Candidate. Single SQL:SELECT sqlc.embed(t)+ LEFT JOINgeneral_likes(foris_liked) + LATERAL aggregate onplay_events(forlast_played_at,play_count,skip_count).WHEREexcludes the seed and any track played within the recently-played-hours window.internal/api/radio.go— handler rewrite. Validatesseed_track+ optional?limit=, defaults tocfg.Recommendation.RadioSize(50), clamped toRadioSizeMax(200). Returns{ tracks: [seed, ...top picks] }— seed always at index 0 so the existingplayRadioweb action'splayQueue(resp.tracks, 0)plays the seed.config.RecommendationConfig— operator-tunable weights via YAML / env:BaseWeight(1.0),LikeBoost(2.0),RecencyWeight(1.0),SkipPenalty(1.0),JitterMagnitude(0.1),RecentlyPlayedHours(1),RadioSize(50),RadioSizeMax(200). Defaults match spec §6 recommendations.Web
No web changes. The existing
playRadio(seedTrackId)action already calls/api/radio?seed_track=…and feedsresp.tracksintoplayQueue(tracks, 0). After this PR the response is a real 50-track queue instead of the M2 one-track stub; the web side just sees a fuller queue.Test plan
go test -short -race ./...— all packages passMINSTREL_TEST_DATABASE_URL=… go test -p 1 ./...) — passes; recommendation candidate-loader tests verify seed + recently-played exclusion, stat-join correctness, cross-user isolationinternal/recommendationcoverage 95.5% (target: ≥ 70% per M3 milestone description)golangci-lint run ./...— cleancd web && npm run check— 0 errors / 0 warningscd web && npm test— 174 tests across 29 filescd web && npm run build— adapter-static emitsweb/build/docker build -t minstrel:m3-shuffle-smoke .+ binary smoke — okNotes
RadioSize=50(≈100 queries per/api/radiocall). Matches the existingresolveTrackRefspattern. Fine at v1 scale; future work can batch viaIN (...).recently_played_hours) is positional and unnamed, so sqlc generatedColumn3 interface{}. The Go caller passesfloat64(recentlyPlayedHours). If we move to named params (sqlc.arg(...)) in a future cleanup, the Go signature improves.play_events.session_vector_at_playat play_started; contextual_likes capture on like.contextual_match_scoreterm added toScore; weighted Jaccard similarity; closes M3.🤖 Generated with Claude Code