Operator, 2026-09-10: started radio from a song and "literally all of the
songs in the playlist after that were from a single artist which was not
expected."
There was no per-artist cap anywhere in the radio path. radio.go built the
pool and handed it straight to Shuffle, which scores, sorts and takes the
top N — nothing between those steps bounded any artist's share, so a pool
dominated by one artist produced an output dominated by it. The asymmetry
was the tell: discover.go, you_might_like.go and home.go all cap; radio
never got one.
With the fixture that reproduces it — 20 liked tracks by one artist plus 10
by ten others — the old path returns 10 tracks from 1 artist. It now returns
10 from 8.
TWO PASSES, and that is the whole design. A hard cap was the easy mistake:
radio asks for 50 tracks by default and 200 at most, so capping at three per
artist over a concentrated pool would hand back a six-track "radio". Pass
one takes candidates that fit under the caps; pass two fills any remaining
slots from those it skipped, still in score order. The result always holds
min(limit, len(candidates)) — the caps change WHICH tracks are picked, never
HOW MANY. Rule 131's principle past the system mixes it was written for.
The caps SCALE with the requested length rather than being a constant.
Three-per-artist is a sensible 12% of a 25-track mix and an absurd 1.5% of a
200-track radio, where every selection would sit in the relaxation path and
the cap would be decorative. RadioDiversityCaps holds the system mixes'
proportion at any length: 3/2 at 25, 6/4 at 50, 24/16 at 200, with floors so
a very short radio is not capped down to one track per artist.
A BOUND, NOT AN EXCLUSION — the operator asked for the opposite of removal:
"again it should be able to add songs from the same artist." The dominant
artist still appears, just not exclusively. Guarded, because the tempting
wrong fix is the filter songs-like used to carry.
Shuffle grew the parameter rather than gaining a capped twin: radio is its
only production caller, so a second function would have left the original
dead (rule 22).
Falsified against each named regression: uncapped gives 10/10 to one artist;
a hard cap returns 3 of 10 on a single-artist pool; a cap-as-exclusion drops
the artist entirely; a fixed cap stays 3 where the scaled one reaches 24.
Caught while writing the guards: the artist-key constant was hand-written
hex and wrong — the fixture's artist UUID carries 0001 in its fourth group,
so the lookup missed and the assertion measured nothing. Derived from the
same construction the fixture uses now.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
Milestone #160 Opt 3b. Adds device class as a third context axis on top
of the #1531 time-of-day/weekday affinity: on the radio path, a candidate
is boosted when its artist concentrates in the current (daypart × weekday
× device) cell. Client-sent (client_id is opaque; no UA stored), so it's
captured going forward and applies to radio only (daily mixes are
cron-built with no device → stay device-agnostic).
Server:
- Migration 0048: play_events.device_class text NULL (no CHECK; normalized
in Go — one whitelist entry per new client class, not a migration).
- events.go: eventRequest.device_class + normalizeDeviceClass (whitelist →
mobile/web/…, else "other", empty → NULL); threaded through both
RecordPlayStartedWithSource and RecordOfflinePlay into InsertPlayEvent.
- ListArtistContextPlayCountsForUser gains a current-device param; the cell
FILTER adds AND ($2='' OR device_class=$2) — '' reproduces the #1531
time-only behaviour exactly (used by mixes). SessionVector.DeviceClass
carries it; the radio handler derives the current device from the user's
latest play (GetLatestPlayDeviceClassForUser) — request-free proxy.
- No new tuning knob: device narrows the existing ContextAffinityScore
(reuses context_time_weight).
Clients:
- web: play_started sends device_class 'web'.
- android: play_started + offline replay send 'mobile' (EventsWire +
PlayOfflinePayload + MutationReplayer + PlayEventsReporter).
Test: LoadContextAffinity device-narrowing integration test (mobile vs web
artist separation; device-agnostic parity).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The recommendation scoring knobs move out of YAML (radio profile) and
out of the systemMixWeights hard-code (daily_mix profile) into
DB-backed settings with live effect (#1250) — the defaults-discovery
lab per decision #1247: the operator turns knobs to find good values,
which then get baked back into shipped defaults; end users and other
operators should never need the card.
- Migration 0040: recommendation_weight_profiles (radio / daily_mix,
8 weight columns), taste_tuning singleton (engagement half-life +
completion-curve points), recommendation_tuning_audit (one row per
change with a {field, old, new} diff — the trend view's markers,
#1251).
- internal/recsettings: boot reconcile seeds shipped defaults without
clobbering tuned rows (coverart SettingsService pattern), validates
patches (bounds, curve ordering), writes audit rows, and pushes
daily_mix weights + taste config into package playlists. No-op
patches write no audit row.
- playlists gains SetSystemMixWeights / SetTasteConfig swap points
under a RWMutex — no signature threading through the producers; the
scheduler's taste rebuild reads the pushed config.
- Radio reads its weight profile from the service per request; the 8
weight fields leave config.RecommendationConfig (YAML keeps only
RecentlyPlayedHours / RadioSize / RadioSizeMax).
- Admin API: GET/PATCH/reset under /api/admin/recommendation-tuning,
echoing current + shipped values.
- Web: new admin Tuning tab — two weight profiles side by side, taste
card, per-scope save (changed fields only) + reset, deviation dots
against shipped defaults.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
The profile built in phase 1 now changes what gets surfaced. Adds a TasteMatch
term to the weighted-shuffle score so candidates are re-ranked by their fit to
the user's learned taste (positive draws toward it; negative reflects passive
avoidance; 0 at cold start).
- recommendation/score.go: ScoringInputs.TasteMatchScore ([-1,+1]) +
ScoringWeights.TasteWeight + the term in Score.
- recommendation/taste.go: LoadTasteProfile reads the taste_profile_* tables;
TasteProfile.Match blends the candidate's artist weight (0.7) and avg genre-tag
weight (0.3), each tanh-squashed by a fixed scale so one outlier artist can't
compress the rest. Unknown artist/tags and empty profiles → 0 (neutral).
- candidates.go: both candidate loaders set TasteMatchScore per candidate, so
every Score caller (system playlists incl. You-might-like, radio) becomes
taste-aware automatically.
- weights: systemMixWeights.TasteWeight = 1.5 (daily mixes are the primary
taste surface); config.RecommendationConfig gains taste_weight (default 1.0,
lighter — radio is seed-directed) wired into the radio handler.
- tests: pure (Match curve incl. saturation/clamp/empty-neutral, Score term
add+subtract) + DB round-trip (seed taste rows → Match positive). All green
vs real Postgres; existing playlist/radio tests unaffected (empty profile →
zero taste effect).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire LoadCandidatesFromSimilarity as the primary candidate loader with
an ?exclude= query param for comma-separated UUID filtering; fall back
to LoadCandidates on error. Thread SimilarityWeight into ScoringWeights
and update testHandlers recCfg accordingly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wire contextual scoring end-to-end: add ContextWeight to RecommendationConfig
(struct + Default()), fetch the user's current session vector in handleRadio
via loadCurrentSessionVector (cold-start returns Seed sentinel), and pass it
as the 6th arg to LoadCandidates so ContextualMatchScore flows into Shuffle.
Validates seed_track + optional limit (default cfg.Recommendation.RadioSize,
clamped to RadioSizeMax). Calls recommendation.LoadCandidates +
recommendation.Shuffle. Prepends seed to result. Server seeds a
math/rand source at startup; handlers package threads that as a
func() float64 so tests inject deterministic RNGs.
Mount + server.New gain a RecommendationConfig parameter.
M6 stub. Validates seed_track param (400 on missing/blank/bad UUID),
looks up the track (404 on miss), returns RadioResponse with a single
TrackRef. M4 will replace the body with similarity-driven candidate
pool + scoring; the request/response shape is final.