feat: M3 weighted shuffle v1 — real /api/radio with scoring #21

Merged
bvandeusen merged 262 commits from dev into main 2026-04-27 11:00:29 -04:00
bvandeusen commented 2026-04-27 08:16:59 -04:00 (Migrated from git.fabledsword.com)

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.goLoadCandidates(...) 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

  • 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.
    • #342contextual_match_score term added to Score; weighted Jaccard similarity; closes M3.

🤖 Generated with Claude Code

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)
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bvandeusen/minstrel#21