Replaces the M2 stub /api/radio with real weighted-shuffle scoring: LikeBoost + recency_decay - skip_ratio penalty + jitter, hard suppression of last-hour plays. Wide candidate pool (whole library) for v1; M4 adds similarity-based pool refinement. Pure scoring function in internal/recommendation; live-DB candidate loader; HTTP handler is a thin shim. No web changes — existing playRadio action already calls /api/radio. Sub-plan #1 of 3 in M3 (Fable #340). Session vectors and contextual match score land in the next two sub-plans.
16 KiB
M3 Weighted Shuffle v1 — Design Spec
Status: approved 2026-04-27 Slice: M3 sub-plan #1 of 3 (recommendation engine v1 + contextual likes). Spec §13 step 7. Step 8 (session vectors + contextual_match_score) is the next sub-plan; this slice ships the scoring foundation without the contextual term. Fable task: #340.
Goal
Replace the M2 stub /api/radio (currently returns just the seed track) with a real weighted-shuffle implementation that scores user library tracks against a per-track-stats formula and returns a top-N queue ordered by score. After this slice, clicking a track in the SPA's search/track-row "play radio" surface produces a meaningful 50-track queue instead of a one-track stub.
Non-goals
- Session vectors /
contextual_match_score(sub-plan #2 / Fable #341 covers the write path; sub-plan #3 / Fable #342 folds the score into this function). - Real "radio" candidate pools from ListenBrainz similarity / similar artists / similar tags (M4).
- Album-seed or artist-seed radio (
?seed_album=,?seed_artist=). v1 is track-seeded only; the Fable task covers that scope explicitly. - Periodic radio refresh (the spec's "regenerate at 80% consumed"). Player-side concern; lands later.
- Persistent radio queues. Each
/api/radiocall returns a fresh selection — stateless on the server. - Performance optimization for very large libraries (>50k tracks). At v1 scale the wide-pool scan is fine; M3.5 / M4 can add sampling if telemetry shows it matters.
Subsonicstar-radio compatibility. Subsonic'sgetRandomSongsis a different shape; we don't try to map weighted shuffle into it for v1.
Architecture
A new internal/recommendation package owns:
- A pure scoring function
Score(inputs, weights, now, rng) → float64with no DB dependency. Therngparameter is injectable so tests pin jitter to deterministic values. - A candidate loader
LoadCandidates(ctx, q, userID, seedID, recentlyPlayedHours) → []Candidatethat runs ONE SQL query joiningtracks,general_likes, and aggregatedplay_eventsto produce the per-track stats the scoring function needs. Excludes the seed and any track played within the recently-played window. - A
Shuffle(candidates, weights, now, rng, limit)orchestrator that scores each candidate, sorts descending, truncates tolimit. Pure.
The /api/radio handler becomes a thin shim: validate the seed → call LoadCandidates → call Shuffle → prepend the seed to the result → project to []TrackRef → return JSON.
Why split into three layers:
score.gois the math; trivially unit-testable, no infra.candidates.gois the data access; integration-tested against a live DB.shuffle.gois the composition; pure-function tests against fake candidate sets.- The HTTP handler is the I/O glue; lives in
api/radio.golike the existing pattern.
This separation also sets up sub-plan #3 (contextual scoring) cleanly — we'll add LoadContextualSimilarity next to LoadCandidates, extend ScoringInputs with ContextualMatchScore, and the rest of the chain doesn't change.
Schema
No migration. All inputs are computed on demand from existing tables:
general_likesforis_general_liked.play_eventsforlast_played_at,play_count,skip_count. Aggregations done in the candidate-loader query.tracksprovides the candidate set itself.
The recently-played-in-the-last-hour filter is applied at candidate-load time (a single WHERE NOT EXISTS (... AND started_at > now() - interval '1 hour') clause). Hard suppression — these tracks aren't even scored.
Scoring math
type ScoringInputs struct {
IsGeneralLiked bool
LastPlayedAt *time.Time // nil = never played
PlayCount int // total play_events
SkipCount int // play_events with was_skipped=true
}
type ScoringWeights struct {
BaseWeight float64 // default 1.0
LikeBoost float64 // default 2.0
RecencyWeight float64 // default 1.0
SkipPenalty float64 // default 1.0
JitterMagnitude float64 // default 0.1
}
func Score(in ScoringInputs, w ScoringWeights, now time.Time, rng func() float64) float64 {
s := w.BaseWeight
if in.IsGeneralLiked {
s += w.LikeBoost
}
s += recencyDecay(in.LastPlayedAt, now) * w.RecencyWeight
s -= skipRatio(in.PlayCount, in.SkipCount) * w.SkipPenalty
s += (rng()*2 - 1) * w.JitterMagnitude
return s
}
recencyDecay(lastPlayed *time.Time, now time.Time) float64 — returns [0, 1]:
- Never played (
lastPlayed == nil) →1.0. Cold-start tracks compete favorably with stale ones; otherwise the recommendation engine never surfaces music the user hasn't tried. - Played within last hour: doesn't reach this function (filtered earlier at the candidate-pool stage).
- Otherwise:
min(age_days / 30.0, 1.0). Linear ramp; tracks ≥ 30 days stale hit max recency boost.
skipRatio(plays, skips int) float64 — returns [0, 1]:
plays == 0→0.0. Don't penalize never-played tracks.- Otherwise →
float64(skips) / float64(plays).
Random jitter — (rng()*2 - 1) * JitterMagnitude produces values in [-magnitude, +magnitude]. The rng parameter is func() float64 (matches math/rand.Float64); tests inject a fixed-value version for deterministic ordering.
Score range under defaults:
- Min (unliked, recent, all-skips):
1.0 + 0 + 0 - 1.0 - 0.1 = -0.1 - Max (liked, ≥30d stale, never skipped):
1.0 + 2.0 + 1.0 - 0 + 0.1 = 4.1 - Liked-track structural advantage (
LikeBoost = 2.0) dominates the jitter band (±0.1), so liked tracks always rank above identical unliked tracks.
Configurable via YAML / env. Operators can crank LikeBoost up if their library is dominated by stuff they don't actually like, or JitterMagnitude up if they want more variety.
API contracts
Request:
GET /api/radio?seed_track=<uuid>&limit=<int>
seed_track(required) — UUID of the track that seeds the radio.limit(optional, default 50, max 200) — total number of tracks to return including the seed.
Response (shape unchanged from M2 stub, only contents grew):
type RadioResponse = { tracks: TrackRef[] };
tracks[0]is always the seed track (soplayQueue(resp.tracks, 0)plays the seed).tracks[1..]are the top-scored candidates from the user's library, descending. Length up tolimit - 1.- Cold-start (empty library beyond the seed): returns
{ tracks: [<seed>] }.
Errors (unchanged from M2 stub):
400 bad_request— missingseed_track, malformed UUID, orlimit < 1.404 not_found—seed_trackdoesn't exist.500 server_error— DB issue.
No web client changes. The existing playRadio(seedTrackId) already calls this endpoint and feeds resp.tracks into playQueue(tracks, 0). After this slice the queue is fuller; the call shape is identical.
No Subsonic changes. Track-seeded radio isn't part of Subsonic's surface in our v1 scope.
Components & files
New server files
| Path | Responsibility |
|---|---|
internal/recommendation/score.go |
Pure scoring function + recencyDecay + skipRatio helpers. No DB. |
internal/recommendation/score_test.go |
Boundary cases: every term, cold-start, jitter determinism, score ranges. |
internal/recommendation/candidates.go |
LoadCandidates(...) — single SQL query returning []Candidate (Track + ScoringInputs). |
internal/recommendation/candidates_test.go |
Live-DB tests: seed exclusion, recently-played exclusion, stat-join correctness, cross-user isolation. |
internal/recommendation/shuffle.go |
Shuffle(candidates, weights, now, rng, limit) []Candidate — composes Score + sort + truncate. Pure. |
internal/recommendation/shuffle_test.go |
Pure-function tests: liked-rank-higher, high-skip-rejected, jitter-doesn't-reorder-structural-winners, limit. |
internal/db/queries/recommendation.sql |
sqlc query: LoadRadioCandidates — SELECT tracks WITH LEFT JOIN general_likes, LEFT JOIN aggregated play_events stats. WHERE clause excludes seed_id and last-hour plays. |
internal/db/dbq/recommendation.sql.go |
Generated bindings. |
Modified server files
| Path | Change |
|---|---|
internal/api/radio.go |
Replace stub. New flow: validate seed_track and limit, call recommendation.LoadCandidates, call recommendation.Shuffle, prepend seed, project to []TrackRef, return JSON. |
internal/api/radio_test.go |
Replace stub tests with: cold-start, typical (seed + scored picks), 404 on unknown seed, 400 on bad seed/limit, cross-user isolation. |
internal/config/config.go + config.example.yaml |
Add RecommendationConfig struct: BaseWeight (1.0), LikeBoost (2.0), RecencyWeight (1.0), SkipPenalty (1.0), JitterMagnitude (0.1), RecentlyPlayedHours (1), RadioSize (50), RadioSizeMax (200). YAML key recommendation:. |
internal/api/api.go |
handlers struct gains recCfg config.RecommendationConfig. Mount signature gains the config arg; constructs the handler with it; the radio handler builds ScoringWeights from it per request. |
internal/server/server.go + cmd/minstrel/main.go |
Pass cfg.Recommendation through Mount. |
No web changes
The existing playRadio(seedTrackId) already consumes RadioResponse. The web slice for this PR is empty — server-only.
Data flow
- SPA calls
playRadio(seedTrackId)(existing player-store action). It POSTsGET /api/radio?seed_track=<id>(no explicitlimit, server defaults to 50). handleRadiovalidates auth + seed UUID. Looks up the track to confirm it exists (404 otherwise).- Reads
limitfrom query (defaultcfg.Recommendation.RadioSize, clamped tocfg.Recommendation.RadioSizeMax). - Calls
recommendation.LoadCandidates(ctx, q, userID, seedID, cfg.Recommendation.RecentlyPlayedHours). The SQL query joins:tracks t— base setLEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id→is_likedLEFT JOIN LATERAL (SELECT max(started_at) FROM play_events WHERE user_id = $1 AND track_id = t.id) pe_last → last_played_atLEFT JOIN LATERAL (SELECT count(*), count(*) FILTER (WHERE was_skipped) FROM play_events WHERE user_id = $1 AND track_id = t.id) pe_stats → play_count, skip_countWHERE t.id <> seed_id AND NOT EXISTS (SELECT 1 FROM play_events WHERE user_id = $1 AND track_id = t.id AND started_at > now() - interval '$2 hours')
- Iterates the candidates, calls
Score(...)per track, sorts descending, truncates tolimit - 1. - Prepends the seed track, projects each
Candidate.TracktoTrackRef(existingtrackRefFromhelper), writesRadioResponse{ Tracks: ... }. - SPA receives 50 tracks, plays from index 0 (the seed).
Stateless — every call recomputes from scratch. No persisted radio queue, no cursor tracking, no "you already heard this" memory beyond the recently-played-hours window.
Testing
Server (go test)
internal/recommendation/score_test.go (pure unit tests):
- Base case (never played, not liked, no skip data) with deterministic RNG
() => 0.5→ score =BaseWeight + RecencyWeight + 0exactly. - Liked boost: same inputs but
IsGeneralLiked=true→ score increases byLikeBoost. - Recency ramp:
lastPlayedAt = now - 15d→recencyDecay = 0.5.lastPlayedAt = now - 60d→1.0(capped). - Skip ratio:
PlayCount=4, SkipCount=2→ ratio0.5, score loses0.5 * SkipPenalty. - Cold-start skip:
PlayCount=0, SkipCount=0→ ratio0.0, no penalty. - Jitter bounds: 1000
Score(...)calls withmath/rand.Float64, every result within[mid - JitterMagnitude, mid + JitterMagnitude]wheremidis the deterministic-RNG score. - Determinism: same
(inputs, weights, now, rng)returns the same score (regression guard for hidden global state).
internal/recommendation/shuffle_test.go (pure unit tests):
- Liked-vs-not: two otherwise-identical candidates → liked one ranks higher.
- High-skip rejected: candidate with
skipRatio=1.0ranks last among otherwise-identical candidates. - Limit truncates: 100 candidates,
limit=10→ result has 10. - Jitter doesn't reorder structural winners: 100 random RNG seeds → liked track ALWAYS ranks above an equivalent unliked track.
- Empty input → empty output.
internal/recommendation/candidates_test.go (live DB):
- Seed exclusion: 5 tracks in library, ask for radio with one as seed → returns the other 4.
- Recently-played exclusion: seed a
play_eventsrow withstarted_at = now - 30 minutesfor track A → radio excludes A. - Stat join: track with one play + one skip →
play_count=1, skip_count=1. Liked track →is_liked=true. Never-played →last_played_at IS NULL. - Cross-user: Alice's plays / likes do NOT appear in Bob's stats.
internal/api/radio_test.go (HTTP integration, replacing existing stub tests):
- Cold-start: seed only in library → response is
{ tracks: [seed] }. - Typical: seed + 5 other tracks → response is
[seed, 5 ranked]. Seed always at index 0. - 404 on unknown seed.
- 400 on missing/malformed seed.
- 400 on
limit=0orlimit < 0. limit > RadioSizeMaxclamped (returns at mostRadioSizeMax, no error).- Cross-user isolation: Alice has many plays + likes → Bob's radio uses Bob's clean stats.
Coverage: go test -coverprofile=cover.out ./internal/recommendation/... → ≥ 70% per the M3 milestone description. The pure-function tests should hit ~100% on score.go + shuffle.go; candidates.go reaches similar coverage via the integration tests.
End-to-end manual
Final task in the implementation plan:
- Sign in. Play 3 tracks all the way through (build
play_counthistory). - Skip a 4th track within 10 seconds (creates a high
skip_ratio). - Like 2 tracks via the heart button.
- Click radio on a 5th track.
- Inspect the response in dev tools network tab — 50 tracks, seed first.
- The 2 liked tracks appear early in the list (within first ~20).
- The just-skipped track appears late or absent.
- The 3 just-played tracks are absent (recently-played exclusion).
- Trigger radio again with a different seed; ordering varies (jitter), liked tracks still rank prominently.
Risks & mitigations
- Wide-pool scan performance. For a 50k-track library: 50k rows × scoring overhead × sort ≈ a few hundred ms in Go. Below the user-perceptible threshold for a click-to-play. If telemetry later shows >1s P95 latency, M3.5 / M4 can add sampling (random-N candidate pre-filter) or a partial index. The candidate-pool function is the swap point; the scoring function and HTTP handler don't change.
- Recently-played window edge effects. If the user plays 50 tracks in an hour, all of them get excluded from the next radio call. Library < 100 tracks could leave the candidate pool too thin to fill
RadioSize. Mitigation: handler returns whatever it can fit (length might be <RadioSize); SPA'splayQueueworks on any non-empty array. Document as acceptable degradation; users with tiny libraries get short radios. - Skip-ratio gaming. A user who skips a track once (during initial library discovery) gets a
skipRatio=1.0for that track. WithSkipPenalty=1.0that's a-1.0hit — could permanently demote the track even if they like it. Mitigation: weights are tunable; user can lowerSkipPenalty. Future: smoothing (e.g.(skips + α) / (plays + β)) instead of raw ratio. Out of scope for v1. - Cold-start RNG dominance. A brand-new user with zero plays / likes gets every track scored at
BaseWeight + RecencyWeight + jitter. Top-N is essentially random. That's fine — it matches user expectation ("I haven't told you anything about me, give me random music"). The recency-decay max-for-never-played avoids amplifying new tracks just because they're new. - Score formula evolution. Adding
contextual_match_scorein sub-plan #3 means changingScoringInputsand theScorefunction signature. Mitigation: the function is internal-package only; sub-plan #3 changes both ends of the call atomically. No external consumers.