Rule #25/#27: the two knobs slice 6 added server-side are now touchable —
taste-tag weight and snooze length, with deviation dots, save, and reset,
matching the existing profile/taste cards.
Copy states what each knob does AND what it doesn't: the tag-weight hint
says 0 turns the term off and that an untagged candidate is never
penalised, and the snooze hint says it records no opinion about the artist
and never feeds the taste profile. Those are the two properties most likely
to be assumed backwards by whoever turns these next.
Also fixed a latent fragility the new card exposed rather than caused: all
three reset buttons had the accessible name "Reset to defaults", so the
existing test picked the LAST one and assumed that meant taste. Adding a
card below it would have silently retargeted that assertion at the wrong
scope. Each reset button now names its scope — better for screen readers
too, since three identical buttons on one page is a real a11y defect — and
the test selects by name instead of position.
The page's test fixture needed the new `discover` key in both `snapshot`
and `shipped`: the `as TuningSnapshot` cast means a missing field is not a
compile error, it's every test on the page throwing inside fillForm. Noted
that in the fixture so the next scope doesn't rediscover it.
Includes a test that a weight of 0 is actually SENT rather than dropped as
falsy — the off switch is the one value a truthiness bug would eat.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Milestone #160 Opt 2b (mood half of the era+mood option). A fourth taste
facet alongside artists + genre tags + eras: signed weights over canonical
mood buckets (melancholic / energetic / chill / …) derived from a track's
enriched folksonomy tags (#1490).
- internal/mood: shared vocabulary — Of(tags) maps folksonomy tags to
canonical mood buckets (synonyms collapse). Imported by both the taste
builder and the scorer so a track's mood is derived identically.
- Migration 0047: taste_profile_moods table + taste_tuning.mood_scale
(DEFAULT 0.5).
- Build side (internal/taste): Config.MoodScale ([0,1] damper, mirrors
EraScale); accumulate folds each play/like's mood buckets at
base*MoodScale; persist atomic-replaces the mood rows.
- Scorer (internal/recommendation): TasteProfile gains a mood term
(own tanh scale + additive 0.12 share, so it never weakens the existing
signal when a track has no mood tags). Match now takes the candidate's
mood buckets; loaded per candidate (ListTrackTagsForTracks → mood.Of) in
the primary similarity loader only — the near-whole-library fallback
pool passes nil (mood → 0) to avoid a full-library tag scan.
- Tuning lab: mood_scale threaded through recsettings + admin API + web
card ("Mood weight" row) + Go/web tests.
Coverage is partial (grows with tag enrichment; richer once Last.fm is
keyed), so mood is a supplement — neutral for tracks with no mood tags.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Milestone #160 Opt 3 (temporal half). A new additive scoring term that
boosts a candidate when its artist's play history concentrates in the
CURRENT daypart × weekday-type cell, in the user's local timezone.
- Migration 0046: recommendation_weight_profiles.context_time_weight
(per-profile scoring weight, DEFAULT 1.0).
- Query ListArtistContextPlayCountsForUser: per-artist completed-play
counts split by the current cell (daypart night[22,5)/morning[5,12)/
afternoon[12,17)/evening[17,22) × weekday-vs-weekend) via
started_at AT TIME ZONE users.timezone; 365-day window, skips excluded.
- internal/recommendation/context.go: LoadContextAffinity computes each
artist's shrunk cell-share minus the user's baseline share, clamped to
[-1,1]; sparse artists shrink toward baseline (pseudo-count 5), unknown
artists → 0 (cold-start neutral).
- Score() gains context_affinity_score · ContextTimeWeight; both
candidate loaders set it per candidate.
- Tuning lab: ContextTimeWeight threaded through recsettings + admin API
+ web card ("Time-of-day weight" row) + Go/web tests. Shipped 1.0 both
profiles (uniform start, re-bakeable).
Device-class axis deferred to #1551 (needs a client_id → device-class
mapping that doesn't exist yet).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Milestone #160 Opt 2 (era half). A third taste facet alongside artists
+ genre tags: signed weights over decade buckets ("1990s") derived from
albums.release_date, rebuilt daily and scored into the taste match.
- Migration 0045: taste_profile_eras table (mirrors taste_profile_tags)
+ taste_tuning.era_scale column (DEFAULT 0.5).
- Build side (internal/taste): Config.EraScale ([0,1] damper, mirrors
EnrichedTagScale), accumulate folds each play/like's decade at
base*EraScale, persist atomic-replaces the era rows.
- Scorer (internal/recommendation): TasteProfile gains an era term (own
tanh scale + additive 0.15 share so it never weakens the existing
artist/tag signal when a track is undated); candidate queries return
album release_date; decadeOf mirrors the builder helper.
- Tuning lab: era_scale threaded through recsettings + admin API + web
card (auto-renders the new row) + Go/web tests.
Mood facet deferred to #1534 (partial enrichment coverage + needs
candidate-side enriched-tag loading).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Promote the enriched-tag weight (#1490) from a taste.Config default into
the DB-backed tuning lab so operators can dial how much folksonomy tags
count vs raw ID3 genre (rule #25).
- Migration 0044: taste_tuning.enriched_tag_scale (DEFAULT 0.5, backfills
the existing row).
- recsettings: TasteTuning gains the field; seeded/read/updated through
reconcile + persistTaste; applyTastePatch validates it to [0,1]
(generic non-half-life clamp) and diffTaste audits it; TasteConfig maps
it into the profile build.
- API: tasteTuningResp exposes enriched_tag_scale.
- Web tuning card: a data-driven "Enriched tag weight" knob (0 = genre
only). Tests: recsettings persist+range, web fixture field.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The verify half of the tune→verify loop (#1251), on the same admin
Tuning page as the knobs:
- RecommendationWeeklyTrends: weekly per-source outcomes aggregated
across all users (the knobs are global, so judging a turn needs
global outcomes — rows carry rates only, no track/user identity),
with a taste-hit count per bucket: plays whose track's artist has a
positive weight in the player's current taste profile. That's the
"cheap recompute" reading — retroactive over the whole window, at
the cost of profile drift.
- GET /api/admin/recommendation-trends?weeks=N (default 12, cap 52):
per-family weekly series (skip rate, sample-weighted completion,
taste-hit rate) plus the tuning-audit markers inside the window.
- Web: sparkline table under the tuning cards — skip rate per week on
a shared axis with dashed ticks at knob turns, latest-week columns,
window taste-hit rate, low-volume rows dimmed as anecdote, and a
plain-text list of the window's tuning changes.
Also fixes the revive unused-parameter lint on the tuning GET handler
that failed CI run 1903 on the previous commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
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