First M3 sub-plan (Fable #340). Replaces the M2 stub /api/radio with 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/recommendation package, three layers:
score.go — pure Score(inputs, weights, now, rng) → float64. Implements base + (liked ? LikeBoost : 0) + recency_decay * RecencyWeight - skip_ratio * SkipPenalty + jitter. recencyDecay ramps [0,1] over 30 days; never-played returns 1.0 (cold-start bias). skipRatio returns 0 for never-played. rng is injectable for deterministic tests.
shuffle.go — pure Shuffle(candidates, weights, now, rng, limit) []Candidate. Scores, sorts descending, truncates. Candidate pairs dbq.Track with ScoringInputs.
candidates.go — LoadCandidates(...) wraps the new sqlc query and projects rows to []Candidate. Single SQL: SELECT sqlc.embed(t) + LEFT JOIN general_likes (for is_liked) + LATERAL aggregate on play_events (for last_played_at, play_count, skip_count). WHERE excludes the seed and any track played within the recently-played-hours window.
internal/api/radio.go — handler rewrite. Validates seed_track + optional ?limit=, defaults to cfg.Recommendation.RadioSize (50), clamped to RadioSizeMax (200). Returns { tracks: [seed, ...top picks] } — seed always at index 0 so the existing playRadio web action's playQueue(resp.tracks, 0) plays the seed.
Mount + server.New + cmd/minstrel/main.go thread the config through.
Web
No web changes. The existing playRadio(seedTrackId) action already calls /api/radio?seed_track=… and feeds resp.tracks into playQueue(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 pass
Full integration suite (MINSTREL_TEST_DATABASE_URL=… go test -p 1 ./...) — passes; recommendation candidate-loader tests verify seed + recently-played exclusion, stat-join correctness, cross-user isolation
internal/recommendation coverage 95.5% (target: ≥ 70% per M3 milestone description)
golangci-lint run ./... — clean
cd web && npm run check — 0 errors / 0 warnings
cd web && npm test — 174 tests across 29 files
cd web && npm run build — adapter-static emits web/build/
docker build -t minstrel:m3-shuffle-smoke . + binary smoke — ok
Manual: play 3 tracks fully + skip 1 + like 2; click radio on a 5th; response has 50 tracks with seed first; liked tracks rank early; just-skipped track ranks low or absent; the 3 just-played tracks excluded; jitter varies ordering between calls but liked tracks stay prominent.
Notes
Per-pick album/artist resolution is N round-trips for RadioSize=50 (≈100 queries per /api/radio call). Matches the existing resolveTrackRefs pattern. Fine at v1 scale; future work can batch via IN (...).
sqlc parameter naming — the third param (recently_played_hours) is positional and unnamed, so sqlc generated Column3 interface{}. The Go caller passes float64(recentlyPlayedHours). If we move to named params (sqlc.arg(...)) in a future cleanup, the Go signature improves.
Sub-plan progression: this PR is foundation only. Next:
#341 — session vectors written to play_events.session_vector_at_play at play_started; contextual_likes capture on like.
#342 — contextual_match_score term added to Score; weighted Jaccard similarity; closes M3.
First M3 sub-plan (Fable #340). Replaces the M2 stub `/api/radio` with 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/recommendation`** package, three layers:
- `score.go` — pure `Score(inputs, weights, now, rng) → float64`. Implements `base + (liked ? LikeBoost : 0) + recency_decay * RecencyWeight - skip_ratio * SkipPenalty + jitter`. `recencyDecay` ramps `[0,1]` over 30 days; never-played returns 1.0 (cold-start bias). `skipRatio` returns 0 for never-played. `rng` is injectable for deterministic tests.
- `shuffle.go` — pure `Shuffle(candidates, weights, now, rng, limit) []Candidate`. Scores, sorts descending, truncates. `Candidate` pairs `dbq.Track` with `ScoringInputs`.
- `candidates.go` — `LoadCandidates(...)` wraps the new sqlc query and projects rows to `[]Candidate`. Single SQL: `SELECT sqlc.embed(t)` + LEFT JOIN `general_likes` (for `is_liked`) + LATERAL aggregate on `play_events` (for `last_played_at`, `play_count`, `skip_count`). `WHERE` excludes the seed and any track played within the recently-played-hours window.
- **`internal/api/radio.go`** — handler rewrite. Validates `seed_track` + optional `?limit=`, defaults to `cfg.Recommendation.RadioSize` (50), clamped to `RadioSizeMax` (200). Returns `{ tracks: [seed, ...top picks] }` — seed always at index 0 so the existing `playRadio` web action's `playQueue(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.
- **Mount + server.New + cmd/minstrel/main.go** thread the config through.
### Web
No web changes. The existing `playRadio(seedTrackId)` action already calls `/api/radio?seed_track=…` and feeds `resp.tracks` into `playQueue(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
- [x] `go test -short -race ./...` — all packages pass
- [x] Full integration suite (`MINSTREL_TEST_DATABASE_URL=… go test -p 1 ./...`) — passes; recommendation candidate-loader tests verify seed + recently-played exclusion, stat-join correctness, cross-user isolation
- [x] **`internal/recommendation` coverage 95.5%** (target: ≥ 70% per M3 milestone description)
- [x] `golangci-lint run ./...` — clean
- [x] `cd web && npm run check` — 0 errors / 0 warnings
- [x] `cd web && npm test` — 174 tests across 29 files
- [x] `cd web && npm run build` — adapter-static emits `web/build/`
- [x] `docker build -t minstrel:m3-shuffle-smoke .` + binary smoke — ok
- [ ] Manual: play 3 tracks fully + skip 1 + like 2; click radio on a 5th; response has 50 tracks with seed first; liked tracks rank early; just-skipped track ranks low or absent; the 3 just-played tracks excluded; jitter varies ordering between calls but liked tracks stay prominent.
## Notes
- **Per-pick album/artist resolution** is N round-trips for `RadioSize=50` (≈100 queries per `/api/radio` call). Matches the existing `resolveTrackRefs` pattern. Fine at v1 scale; future work can batch via `IN (...)`.
- **sqlc parameter naming** — the third param (`recently_played_hours`) is positional and unnamed, so sqlc generated `Column3 interface{}`. The Go caller passes `float64(recentlyPlayedHours)`. If we move to named params (`sqlc.arg(...)`) in a future cleanup, the Go signature improves.
- **Sub-plan progression:** this PR is foundation only. Next:
- #341 — session vectors written to `play_events.session_vector_at_play` at play_started; contextual_likes capture on like.
- #342 — `contextual_match_score` term added to `Score`; weighted Jaccard similarity; closes M3.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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