Fixes the integration failure from 72115484: a SIGSEGV inside handleRadio
took down TestHandleRadio_ColdStart_OnlySeedReturned.
recommendation.(*LibrarySize).Get(0x0, ...)
library_scale.go:146
api.(*handlers).handleRadio(...)
radio.go:95
internal/api builds its handlers struct directly in a dozen tests, none of
which know about every field, so librarySize arrives nil there. Get took
l.mu.Lock() straight off the nil receiver.
The shape of the bug is what matters more than the nil check. This value's
entire contract is that it degrades — an errored count keeps the last known
number, a never-counted cache returns 0, and 0 scales to the base limits,
i.e. today's behaviour. A pool-sizing HINT then turned a request into a
crash, which is the precise opposite of that.
A nil receiver is now VALID and means "no cache": the count still runs, it
is just not memoised. Correct-but-uncached rather than zero, so a wiring
miss in production would cost a query per request, not silently unscale
every pool — a performance bug is findable, a quietly-wrong pool is not.
Patching the test constructors was the alternative and is worse: a dozen
call sites, and the next test to build a handlers literal reintroduces it.
Guarded with the nil path exercised directly, including that it counts
again rather than memoising, and still returns 0 on a failed count. The old
shape fails it by panicking.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
Operator, 2026-09-10: "is the pool that we draw from somehow scaled to the
amount of music in the library... my earlier understanding of the tuning and
work may have been skewed by what was in my library."
It was not. DefaultCandidateSourceLimits returns what its own comment calls
"the v1 hardcoded constants per spec" — ~170 candidates for a 500-track
library and a 100,000-track one alike. The pool therefore samples a
shrinking FRACTION of a growing collection: 17% of 1,000 tracks, 1.7% of
10,000, 0.17% of 100,000. RandomFill, whose whole job is exploration,
becomes a thinner and noisier slice at exactly the moment a library gets
more diverse — which is the "starting to feel weird" being reported.
1,000 tracks -> pool 170 (unchanged)
5,000 -> pool 170 (unchanged)
20,000 -> pool 280
80,000 -> pool 500 (ceiling)
THE SCALING IS PER-ARM, and that is the substance rather than a refinement.
A limit only matters if there are rows for it to cut off, so what an arm is
BOUNDED BY decides whether library size can help it. LBSimilar,
SimilarArtist, TagOverlap and RandomFill grow: they are bounded by
similarity/tag data and by the library itself. LikesOverlap, UserCoplay and
TasteOverlap do not: they are bounded by the user's likes, the instance's
co-play graph and the taste profile, none of which grow when the library
does. Raising those would sample more of a set that did not change — churn,
not reach. It also keeps this from inflating the sim_score-0 share, since
TasteOverlap is one of the two zero-similarity arms.
sqrt, not linear: linear would put a 100,000-track library at a
3,400-candidate pool, long past where more candidates improve the answer.
A 4x ceiling bounds it at ~500.
Never shrinks an arm. The base limits are a floor, and #3889 makes that
load-bearing rather than tidy — shrinking an arm ordered by unseeded
random() changes pool membership between same-day rebuilds.
Library size comes from a TTL-cached count reusing CountTracksMatching with
an empty pattern (rule 28 — a new query would need sqlc regeneration, which
is blocked). The ILIKE defeats every index, so it is a full scan and must
not run per request. It degrades rather than fails: an error keeps the last
known value, a never-counted cache returns 0, and 0 scales to the base
limits — today's behaviour exactly. Nothing about sizing a pool justifies
failing the request it is sizing. Bounded by a 3s deadline (rule 156), and
a failed refresh does not stamp the clock, so a blip cannot pin a stale
value for the whole TTL.
THE REFERENCE IS ASSUMED, NOT MEASURED. libraryScaleReference = 5000 is
where growth starts, and the size the v1 constants were really tuned against
is unrecorded. #3879 should replace it; until then that constant is the one
thing to change. Deliberately conservative: below it nothing scales at all,
so no existing install changes behaviour.
Falsification caught a weak guard: the sqrt-vs-linear assertion was written
at SIXTEEN times the reference, where linear has already been clamped by the
ceiling and both curves land on 4x. It proved nothing. Moved to four times
the reference, below the ceiling for both, where sqrt gives 2x and linear
would give 4x.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
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
Fixes the integration failure from f367eeaa:
"same-day rebuild produced different track lists".
Cutting RandomFill 30→10 for Songs-like broke
TestBuildSystemPlaylists_DailyNonceDeterminism, and the reason is worth
stating because the number is not the bug.
`likes_overlap` and `random_fill` end in a bare `ORDER BY random()` with no
daily seed (recommendation.sql:118, :161). Such an arm returns a STABLE set
only while its LIMIT exceeds the rows eligible for it — then it returns all
of them, and the random order stops mattering because scoreAndSortCandidates
sorts by track id before drawing jitter. Below that threshold the arm
returns a random SUBSET, and two builds on the same day draw different ones.
So the test was green by accident. It seeds ~20 tracks against a default
RandomFill of 30; the limit exceeded the library, so the arm returned
everything. Determinism held for a reason unrelated to the code being right.
Which means it does NOT hold in production. Any real library is larger than
30, so same-day rebuilds have been drawing different mixes since that arm
was written — invisible, because a mix changing after a refresh looks like a
feature. Filed as #3889; the fix is a seeded ordering per (user, day), which
needs a .sql change and sqlc regeneration and so cannot land from here.
The correction: grow an arm freely, never shrink one whose ordering is
unseeded random. LikesOverlap and RandomFill go back to the defaults;
TasteOverlap stays halved because it sorts by `tpa.weight DESC, t.id` and is
genuinely deterministic. Guarded by a test that names the reasoning, so the
next person to trim these has to read why first — and it should be DELETED
once #3889 lands rather than worked around.
The cost is honest: the seed-independent share of the Songs-like pool falls
from 29% to 20% instead of the intended cut. That matters less than it
sounds. The pool only biases the draw; the songs_like WEIGHTS are what
actually demote sim_score-0 candidates, and they are untouched here — a
perfect match still scores 5.00 against an unrelated favourite's 2.00.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
Operator, 2026-09-10: "when I play it I'm expecting to get a consistent
sound and style from the experience... I was getting a seeming wide variety
of music from each one when I was hoping to stay in a certain neighborhood."
Songs-like shared the `daily_mix` weight profile with For-You, and that
sharing WAS the bug. The two surfaces want opposite things: For-You answers
"what will they enjoy today" and is supposed to roam; Songs-like answers
"what sounds like THIS". Under one profile the broad answer wins.
The arithmetic, from the shared weights:
unrelated track, liked, not played recently → 1.0 + 2.0 + 1.0 = 4.0
PERFECT similarity match, not liked → 1.0 + 1.5 = 2.5
Liking something outranked sounding like the seed, because LikeBoost (2.0)
exceeded SimilarityWeight's whole range (1.5) and TasteWeight (1.5, and
seed-INDEPENDENT) matched it outright. Under the new profile the same pair
scores 5.00 vs 2.00.
Two levers, because either alone leaves the other's failure intact:
POOL. Songs-like now takes its own CandidateSourceLimits. The default gave
~29% of candidates a sim_score of literally zero — `taste_overlap` and
`random_fill` are both `0.0::float8` in recommendation.sql, seed-independent
by construction. Same total pool size; composition shifts to arms that
measure distance from the seed, LBSimilar doubled.
WEIGHTS. A third profile beside radio and daily_mix, DB-backed and live per
rule 25, with the property that similarity's range exceeds the combined
range of every seed-independent differentiator — so a closer match cannot
be beaten on likes, freshness and taste alone, while tracks within ~0.39
similarity of each other still get ordered by what the user likes.
Rule 131 changed the pool design mid-way and for the better. Zeroing the
two seed-independent arms was the first instinct and is exactly the
vanish-or-nothing shape that rule forbids: a seed with thin ListenBrainz
coverage would yield a short mix or none. They are the tier-3 FLOOR — cut
hard, never removed — and the weights keep them at the bottom of the
ranking rather than out of the pool. "A few tracks further from the seed
than we'd like" beats "no playlist".
Caught while wiring it: switching only pickTopN's final Score would have
been nearly INERT. scoreAndSortCandidates does the selection sort, and the
caller caps and truncates in that order — so the playlist would still have
been chosen by daily_mix and merely relabelled with songs_like numbers. It
now takes the profile as a parameter, and each surface passes its own.
Also corrects the daily_mix card's blurb, which claimed Songs-like as one
of its surfaces and no longer is.
Guards pin behaviour rather than the numbers, since numbers get retuned:
that similarity beats an unrelated liked track, that daily_mix still
DOESN'T (or the split buys nothing), that the tier-3 floor is non-zero,
and that the UI card shows its own values rather than falling back. Each
falsified against its named regression first.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
dhowden/tag's readTFrame splits ID3v2 null-separated multi-value text
frames and rejoins them with the EMPTY string, so a file tagged
"Alternative Rock" + "Rock" was stored as "Alternative RockRock". It also
leaves bare numeric ID3v1 references unresolved, which is why the
library showed genres like "4017" and "526617".
This corrupted more than the browse axis added in #367: taste_profile.sql
reads tracks.genre directly, so the welded tokens were entering the taste
profile's tag vocabulary, and recommendation.sql/discover.sql were
comparing them as single opaque tags. Genre counts were wrong everywhere.
ffprobe is not a fix — ffmpeg's read_ttag calls decode_str once with no
loop, keeping only the first value. Truncating multi-genre tags would
blunt the similarity signal genre mainly feeds. So the TCON frame is now
parsed directly (ID3v2.2/2.3/2.4, all four text encodings, per-frame and
tag-level unsynchronisation, numeric and parenthesised ID3v1 references);
everything else still comes from dhowden/tag. Values are stored
";"-delimited, which the read side already splits on, so no query changes.
Existing rows are repaired without an operator-run rebuild: migration
0054 adds tracks.tag_read_version DEFAULT 0, below the scanner's current
tagReadVersion, so the next scan re-reads tags it would otherwise skip on
mtime. Such a re-read reuses the stored duration instead of re-running
ffprobe, keeping a repair pass tag-read-bound rather than one fork+exec
per file. Bumping the constant is how a future extraction fix reaches an
existing library.
Only ID3v2 is in scope — dhowden welds nowhere else. The Vorbis/MP4
repeated-field question is #2500, unproven and deliberately not built.
TestSuggestArtists_UntaggedCandidateSurvivesAlongsideTagged asserted the
untagged candidate's score was 0.9 — the raw similarity value I'd seeded.
It's actually 1.61, because the pool score is signal-weighted by the seed
query: ln(1+signal) x similarity, and a liked seed carries signal 5, so
ln(6) x 0.9.
The assertion was testing the seeding arithmetic, which is a different
layer and not what the test is about. Rewritten to run the same request
twice — once with the tag term disabled, once enabled — and assert the
untagged candidate's score is IDENTICAL across both. That states the real
property (the blend leaves untagged candidates alone) without depending on
how the pool score is derived, so it survives future changes to seeding.
Added a sanity assertion that the TAGGED candidate's score did move, so
the comparison can't pass by both runs being trivially identical — the
same "a test that cannot fail" trap recorded for this milestone.
Exact-preservation at the arithmetic level is already covered where it
belongs, by TestApplyTagOverlap_UntaggedCandidateScoreIsUnchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The payoff slice. Until now a candidate's only claim on a slot was "some
artist you play is adjacent to it in a similarity graph" — a fact that says
nothing about whether the music sounds like anything you like. Now the
candidate's own folksonomy tags (cached by slice 5) are compared against
the user's taste-profile tags, so the deck ranks on taste and can say WHY.
The blend is MULTIPLICATIVE — score × (1 + weight × overlap) — and that
choice carries the whole safety argument:
- An untagged candidate has overlap 0, so its score is EXACTLY unchanged.
Tag coverage is permanently partial (#2376); it must cost a candidate
nothing, not sink it (rule #131).
- Nothing can leapfrog on tags alone. An additive term with a large
weight would let a near-zero-similarity artist outrank a strong match
for sharing one popular tag, which reads as noise.
- Weight 0 restores pure similarity order bit-for-bit, so the operator's
knob has a real off position.
overlap = Σ(shared) candWeight × normalizedTasteWeight ÷ Σ(all) candWeight.
Normalizing the taste side by the user's strongest tag makes the score
comparable across users (taste weights accumulate with listening, so a
heavy listener's raw numbers dwarf a new user's while meaning the same
thing). Dividing by the candidate's own mass makes it comparable across
candidates, so a densely-tagged artist can't win on tag count alone.
Applied to the whole over-fetched pool BEFORE selectSuggestions, so the
rotation and diversity rules operate on blended scores — boosting only the
twelve already chosen by similarity would leave the re-ranking undone.
A query failure is returned, NOT degraded past. Graceful degradation is
for expected absence (no taste profile, no cached tags) and both are
handled explicitly as empty inputs; swallowing a real error would hide a
broken DB behind a subtly worse ranking that nothing reports.
Migration 0051 adds a FOURTH tuning scope rather than columns on
taste_tuning, because snooze_days lives here too and a snooze must never
be read as taste signal (#2374) — filing it under 'taste' would put it one
careless join from the leak that design forbids. Expanding
recommendation_tuning_audit's CHECK is in the same migration per rule #36,
and a test asserts the audit row lands, which is what would catch its
absence.
snooze_days moves out of a Go constant onto the tuning card (rule #25),
closing the deferral from #2374.
Tag-overlap tests use deliberately SKEWED fixtures: an evenly-matching pool
cannot exercise a re-ranking, since every candidate gets the same
multiplier and the order is unchanged whether the blend works or not.
Admin UI + client attribution follow in this batch — rule #27.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Migration 0050 adds candidate_artist_tags + candidate_artist_tag_state:
folksonomy tags for artists NOT in the library, which track_tags cannot
hold because it's FK'd to tracks(id) and a Discover candidate has no local
row. Slice 6 ranks against these; this slice only fills the cache.
The reuse the task claimed is real and verified: MusicBrainz's
fetchEntityTags(ctx, "artist", mbid, scale) already existed for the #1519
recording→artist fallback, so FetchArtistTags is a thin wrapper. Two
subtleties it does NOT inherit:
- Weight scale is 1.0, not artistTagWeightFactor (0.6). That discount
exists because FetchTrackTags uses artist tags as a *proxy* for a
track's; here the artist IS the subject. Applying it would make these
weights incomparable with track_tags — exactly the comparison slice 6
depends on. Pinned by a test.
- fetchEntityTags reports existing-but-untagged as (empty, nil) so the
track path can fall through. There's no next level here, so empty
becomes the terminal ErrNotFound; otherwise the enricher would settle
a candidate as "enriched" with zero tags.
ArtistTagProvider is the split TrackTagProvider's own doc comment
anticipated ("e.g. artist-level tags"). Last.fm gains artist.getTopTags,
which returns the same toptags envelope, so the response type and
normalizer are reused unchanged.
Rather than write the merge-and-classify loop twice, extracted it from
EnrichTrack into runChain(). The ErrNotFound-vs-transient split is the
load-bearing part — those lead to opposite persistence decisions — so it
now has direct unit tests it never had while inlined.
Bookkeeping is a separate table, not columns, because the "providers had
nothing" outcome must be recordable for a candidate with zero tag rows,
and there is no per-candidate row to hang columns off (
artist_similarity_unmatched holds many rows per candidate). Absence of a
state row means "never processed", so a transient failure writes nothing
and stays eligible.
Two capacity realities are designed for, not papered over:
- The pool is O(library artists x neighbours) and MusicBrainz allows
~1 req/s, so it can never drain in one pass. The eligibility query
returns candidates in descending summed-similarity order, so the ones
that can actually reach a deck are enriched first.
- candidateBatch (50) is smaller than the track batch (200): tracks are
finite and drain to completion, candidates are effectively unbounded
and would otherwise starve the track arm forever.
GC sweeps both tables — the similarity feed churns, and a candidate that
joins the library has its tags in track_tags now. Tags swept before state
so a mid-sweep crash leaves a valid state, not a re-fetch loop.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Migration 0049 adds suggestion_snoozes(user_id, candidate_mbid,
candidate_name, snoozed_until), and SuggestArtistsForUser excludes rows
whose snooze hasn't expired.
This is NOT a dislike. Rule #101 forbids a "Not for me" / thumbs-down
UI; a snooze is the approved shape instead because it records no verdict
on the music, expires on its own (~90d), and never reaches the taste
profile. It's acquisition triage — "not right now" — so the filter sits
at the candidate stage rather than in the score, where it would become a
ranking signal by the back door.
Per-user throughout (rule #47): one household member parking a candidate
leaves everyone else's deck untouched.
candidate_name is denormalized because suggestions are out-of-library by
definition — there is no artists row to resolve a display name from, and
the un-snooze list has to show something. That list is why GET
/discover/snoozes exists at all: a parked candidate is by definition
absent from the deck, so without it the DELETE would be unreachable.
Also fixes a hole in the codegen check from #2380: `git diff` ignores
untracked paths, so a brand-new generated file would have passed it
silently. `git add -N` first. This commit is the first to add one.
Endpoints:
POST /api/discover/suggestions/{mbid}/snooze (body: name, days)
DELETE /api/discover/suggestions/{mbid}/snooze
GET /api/discover/snoozes
UI lands in slice 4 (#2375) before any of this merges — rule #27.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second half of the reported symptom: suggestions "show the same artists until
you request one". The ranking was `ORDER BY total_score DESC` with no
randomization and no seen-state, so the only things that could ever change the
deck were a candidate entering the library or the user filing a request. The
tail of the ranking was unreachable — requesting was literally the only lever.
No SQL change was needed. The query already takes a limit, so it over-fetches a
pool (4x the slots, capped at 60) and the selection moves to Go, where it is a
pure function of (pool, limit, day) — no DB, no clock — and therefore unit
testable in the fast lane instead of behind the integration gate.
Three rules. The best few by score always lead, so the strongest matches never
rotate out of sight (For You's head/tail shape). The remaining slots are drawn
by md5(mbid + day), the same daily-stable idiom the Home rows already use:
stable within a day so pull-to-refresh doesn't reshuffle, different tomorrow,
and no stored state. And a per-seed cap keeps roughly a quarter of the deck
attributable to any one seed artist, so twelve neighbours of a single artist
can't be the whole surface.
The cap is a preference, not a quota. A user whose pool hangs off one or two
seeds would otherwise get a three-card surface — worse than the monoculture
being avoided, and exactly the vanish-or-nothing shape rule #131 exists to
prevent — so a short deck tops up in score order from what the cap set aside.
This is also what keeps the existing Top12Cap integration test honest: its
30 candidates share one seed, and without the top-up it would return 3.
Eight unit tests, including one that had to be rewritten mid-change: the first
version asserted the cap against an evenly-spread pool, where the top-N is
already diverse and the assertion could not fail. It now uses a skewed pool
where one seed owns the entire top of the ranking, which is the only shape that
actually exercises a cap.
Dropped two //nolint:gosec directives added in passing — gosec isn't in
.golangci.yml, so they suppressed nothing and only implied a check that runs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Discover request surface was the one recommendation surface still on its
M5c implementation from early May. #796's taste profile, #1488's taste_unheard
bucket and #1490's folksonomy enrichment all modernized in-library surfaces;
this one was never in scope for any of them, so it still projected raw likes +
plays through artist_similarity_unmatched.
Two defects fall out of that signal, `5*liked + Σexp(-age/halflife)` summed
over every play of the artist.
It is unbounded, and contribution is signal × similarity — so a handful of
heavily-played artists monopolize all twelve slots, and their share GROWS the
more the user listens. The surface entrenched harder the better it knew you,
which is exactly backwards and matches the reported "goes stale once it has a
strong signal of your taste".
It also counted every play_event with no was_skipped filter, so skipping an
artist repeatedly INCREASED its signal and pushed more of its neighbours at the
user. ListMostPlayedTracksForUser and the taste engine both filter skips; this
query was the odd one out.
Seeds now come from taste_profile_artists.weight, which the taste engine has
already engagement-graded, time-decayed and signed — an artist the user drifted
away from stops contributing instead of accumulating forever, and can even
contribute negatively. Tiered per rule #131 rather than hard-switched: tier 1 is
the profile, tier 2 is likes + completed plays for a user who has no profile
rows yet (new account, or before the first daily recompute), so the surface
never empties. The old unfiltered-play signal is gone, not kept behind a toggle.
The signal is also log-damped, so one artist cannot take every slot even when
its weight dwarfs the rest.
$2 stays wired to the tier-2 decay: it is genuinely still used there, and
dropping the parameter would have changed the generated signature.
sqlc's image is not on this workstation and the change preserves the query
signature exactly — same three params, same seven columns — so only the
embedded SQL const moves. Both copies are edited and verified byte-identical
rather than pulling a container onto the operator's machine; a malformed query
fails the integration lane loudly, which is the real check either way.
Four integration tests cover what changed: a taste weight alone seeds with no
like or play; tier 2 does not run alongside tier 1; a non-positive weight never
seeds (guarded by a second positive row, so an empty tier 1 can't make it pass
for the wrong reason); and skip-only history seeds nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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 5. A collaborative candidate arm: tracks by artists
co-played across the instance with the seed's artist.
Minstrel is a single shared-library, multi-user server (no per-user
library ACL — verified: no owner/share/group model), so the "household"
is the whole instance's user set; the rule #47 scoping is satisfied by
the shared-library boundary. Single-user servers produce no edges.
- No migration: source='user_cooccurrence' was pre-whitelisted in the
0009 similarity CHECK from day one.
- internal/db/queries/coplay.sql: Delete + Insert artist co-play edges.
Score = Jaccard of the two artists' distinct-player sets (controls for
globally-popular artists); >= 2 co-players AND Jaccard >= floor kept
(the floor also self-limits hub artists). Completed plays, 365d window.
- internal/coplay: periodic worker (6h) that atomic-replaces the
user_cooccurrence edge set from play_events — pure local SQL, no
external calls. Wired in main.go alongside the similarity worker.
- LoadRadioCandidatesV2: new coplay_artists arm (source='user_cooccurrence',
seed-artist based, 0.5 damp like similar_artists) + $11 limit;
CandidateSourceLimits.UserCoplay (default 20, For-You 40).
- Integration tests: perfect-overlap Jaccard=1.0 edge + single-user
empty-set gate.
Device axis and AcousticBrainz (Opt 4) are separately tracked; this
closes the milestone-#160 sequential options.
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>
2a re-ranks the existing pool by TasteMatch; this ensures taste-relevant tracks
ARE in the pool. Adds a 6th arm to LoadRadioCandidatesV2: in-library tracks by
the user's top positively-weighted taste-profile artists ($10 K, weight > 0,
deterministic weight-DESC,id order so it doesn't reintroduce same-day
nondeterminism). Pool-inclusion only (sim_score 0) — TasteMatch already scores
the fit. Empty for cold-start users (no profile).
- CandidateSourceLimits.TasteOverlap; default 20 (radio), 80 for For-You via
systemForYouSourceLimits.
- You-might-like deliberately sets TasteOverlap=0: it surfaces NOT-actively-
engaged artists, so flooding its pool with top-taste (mostly already-played)
artists would just feed the read-time dedup.
- Test: positive-weight artist's track enters via the arm; negative-weight one
is excluded (weight > 0). Existing pool tests unaffected (no profile seeded).
Deferred within 2b: profile-seeded For-You — marginal given the arm + TasteMatch
already inject taste broadly (top-played seed ≈ top-taste artist).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The fallback pulled artists only from explicit artist-likes (general_likes_artists),
but most users like albums and tracks far more than artists — so the artists row
still came up thin (a couple of tiles) even with a rich library, while the albums
row filled fine.
Broaden both fallbacks to "entities you've shown affinity for":
- artist fallback = explicit artist-likes ∪ artists of liked albums ∪ artists of
liked tracks.
- album fallback = explicit album-likes ∪ albums of liked tracks.
New dedicated queries (ListYouMightLike{Artist,Album}FallbackForUser) replace the
narrow Rediscover-fallback reuse; same projection so the Go layer still converts
directly. (Aliased + fully-qualified the UNION arms — sqlc merges UNION scopes,
so unqualified user_id was ambiguous across the three like tables.)
Test: 12 liked TRACKS by distinct artists, no artist-likes → the artist row now
fills from their artists (was empty before).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
golangci-lint v2 (CI-only; local is v1) flagged the field-by-field struct
literals — the fallback and you-might-like row types are identical, so convert
directly instead.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The taste roll-up surfaces top-similar albums/artists, which for a heavy
listener are mostly ones they already play — so the read-time dedup (vs Most
Played + Rediscover + Last Played) can strip the section down to a single tile
(reported on the artists row). The code was sound; the section was just starved.
Adds a read-time fallback: when a You-might-like row comes up short after dedup,
top it up from the user's LIKED artists/albums — a far larger pool than the
12-entity similarity roll-up, so the same exclusions still leave plenty. Reuses
the existing Rediscover-fallback queries (no new SQL), applies the same
exclusions (already-shown + Rediscover + Most/Last Played) so it never
duplicates a tile or suggests an actively-played entity, and is best-effort
(a query error leaves the section as-is). Takes effect immediately — no rebuild.
A cold-start user with no likes gets nothing from the fallback, so the
new-user-empty behaviour is preserved (test still passes).
Test: 20 liked artists, none played → Rediscover fills 10, You-might-like
fallback fills the other 10, disjoint.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Surface in-library albums/artists the listener doesn't actively spin but
is predicted to enjoy, derived from the same similarity + like-weighted
candidate engine that powers For-You — rolled up from track scores to
album/artist granularity. Built in the daily 3am BuildSystemPlaylists
pass, atomic-replaced alongside the system playlists, and read back by
/api/home (+ /api/home/index).
Cold-start gate: skips generation entirely below 20 distinct unskipped
tracks AND 5 distinct artists, so a thin profile ships empty rows rather
than near-random tiles.
- migration 0034: you_might_like_albums / you_might_like_artists (id+rank,
CASCADE, per-user rank index).
- playlists/you_might_like.go: cold-start gate + similarity roll-up
(sum-of-top-3 aggregation, per-artist album cap, daily-rotating via the
same userIDHash jitter as For-You) + atomic-replace persist in the tx.
- recommendation/home.go: two new HomePayload sections with read-time
cross-section dedup vs Most Played / Rediscover / Last Played, trimmed
to 10 each.
- api: you_might_like_albums / you_might_like_artists on /api/home and
/api/home/index, reusing albumRefFrom / artistRefFromCovered.
- tests: pure roll-up/aggregation/cap unit tests + DB-backed gate,
sufficiency, and atomic-replace tests (all green vs real Postgres).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
User request 2026-06-01: the previous Rediscover only fired on
explicit album/artist likes, so users who only liked at the track
level got an empty Rediscover row. Also no daily rotation - the
section never changed between data updates - and 25 items felt long
for the Home carousel.
SQL (internal/db/queries/recommendation.sql):
- ListRediscoverAlbumsForUser UNIONs the existing explicit album-like
signal with a new track-derived path: an album qualifies if it has
>=2 liked tracks where the earliest like is >30 days old.
- ListRediscoverArtistsForUser does the same with threshold 3 (artists
span more releases, so the bar is higher to avoid one-hit-wonder
affinities).
- Both ordering switched from longest-since-last-play to a daily-
stable random hash md5(entity_id || user_id || current_date) so
the row randomizes but stays consistent within a day.
- Fallback queries also switched to the daily-stable hash so the
fallback rows don't reshuffle on every refresh.
Go (internal/recommendation/home.go):
- HomeRediscoverLimit dropped from 25 to 10 (carousel-sized).
- rediscoverInnerLimit (30) is the per-query cap; gives headroom so
the Go-layer filters don't undershoot.
- applyRediscoverAlbumFilters does cross-section dedup vs Most Played
(no point surfacing albums the user is actively spinning) plus a
diversity cap of max 2 albums per artist (prevents one liked-but-
forgotten artist dominating the row).
- applyRediscoverArtistFilters does cross-section dedup vs Most
Played's artist set; no diversity cap needed (one row per artist).
Tests (internal/recommendation/home_integration_test.go):
- TrackDerived path: 2 liked tracks >30d old + no recent plays = album
appears in Rediscover.
- Threshold guard: 1 liked track = album does NOT appear.
- Diversity cap: 4 explicit album-likes from same artist = 2 in output.
- Dedup vs MostPlayed: eligible album whose track is in MostPlayed
gets filtered out.
- Existing FallbackWhenSparse test preserved (semantics unchanged for
recent likes that miss the >30d primary filter).
Clients unchanged - they render whatever /api/home/index returns. No
parity-map row needed (this is a server-internal recommendation
change, not a layout divergence).
staticcheck S1016 in the new golangci-lint v2 flagged four sites
copying field-by-field between two types with identical struct
shapes. Direct type conversion is the canonical form:
- internal/library/scanrun.go:
- LibraryStageTallies copy from Stats → LibraryStageTallies(lastStats)
- MBIDBackfillStageTallies copy from BackfillMBIDsResult →
MBIDBackfillStageTallies(lastRes)
- internal/recommendation/home.go:
- dbq.ListRediscoverAlbumsForUserRow copy from the Fallback row
type → dbq.ListRediscoverAlbumsForUserRow(r)
- Same pattern for the Artists rediscover pair.
No behavior change; the underlying struct shapes are identical
(staticcheck verified the conversion is valid). Net -18 +4 lines.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces var _ = func() pgtype.UUID { ... } with var _ map[pgtype.UUID]struct{}
which actually verifies map-key comparability, matching the comment.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Runs five SQL queries in parallel (recently added albums, most played
tracks, last played artists, rediscover albums, rediscover artists) via
goroutines with mutex-guarded first-error capture. Rediscover sections
merge primary + fallback results, de-duping by pgtype.UUID map key.
All HomePayload slices are non-nil so the API handler encodes [] not null.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add per-user artist-suggestion service ranking out-of-library MBIDs by
signal x similarity. Single-CTE SQL collects user likes (5x weight) and
recency-decayed plays, joins against artist_similarity_unmatched, and
filters in-library candidates plus non-terminal lidarr_requests. The
service resolves top-3 attribution seeds to artist names in a batched
GetArtistsByIDs call so the UI can render "because you liked X" reasons.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Integration tests previously TRUNCATEd the users table at setup,
which wiped the operator's admin login every time the suite ran
against a Postgres instance shared with their dev environment.
This commit:
- Adds internal/dbtest/reset.go exposing ResetDB(t, pool) and
TestUserPrefix. ResetDB truncates every data table EXCEPT users,
then deletes only users whose username starts with TestUserPrefix.
- Migrates 9 test files (subsonic/scrobble, subsonic/star,
recommendation/candidates, scrobble/queue, similarity/worker,
playevents/writer, playsessions/service, api/auth, api/me) to
call ResetDB instead of issuing TRUNCATE-with-users.
- Renames hardcoded test usernames to use TestUserPrefix so ResetDB
cleans them between runs. seedUser in api/auth_test now prefixes
internally; existing call sites pass bare names.
- Leaves auth/bootstrap_test.go alone — it legitimately tests
first-time admin bootstrap and must wipe users.
Verified locally: full integration suite with -p 1 runs cleanly
under the existing MINSTREL_TEST_DATABASE_URL setup, and the admin
row in the shared dev DB survives the run.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The combined seed_info CTE filtered the seed out when its genre was
NULL/empty. Restored the spec's two-CTE design (seed_artist + seed_tags)
so similar-artist matching works regardless of the seed's genre coverage.
Drops the genre workaround from the SimilarArtist test.
Two carry-overs from M3 verification, bundled with the search-input
fix already on dev (b7a59a9).
1. Genre splitting in BuildSessionVector
The library has many tracks whose genre tag is a denormalized
multi-genre string ("Indie Pop; Pop; Alternative Pop"). Reading them
as one opaque tag means a single-genre "Pop" track and a multi-genre
track listing Pop both fail to share the Pop key, so the tags axis
in similarity scoring (weight 0.7!) returned 0 in nearly all cases
on real libraries. Result: contextual scoring couldn't differentiate.
Add splitGenres() that splits on ; and , and trims whitespace;
strings without a delimiter come back as a single-element slice
(so the existing single-genre cases keep working unchanged).
Concatenated-without-separator output ("ElectronicComplextroGlitch
Hop") stays opaque — that needs a genre dictionary, out of scope.
2. Play-radio button on TrackRow
playRadio() was only wired to the click handler on /search and
/search/tracks, leaving /library/liked, album pages, and search
results' inner clicks with no way to start a radio. Adds a small
radio button to TrackRow (between LikeButton and the +queue button).
Click → playRadio(track.id), with stopPropagation so the row's own
activate handler doesn't also fire.
Tests:
- 3 new sessionvector tests: TestSplitGenres, multi-genre semicolon
split, no-separator stays opaque. Existing single-genre tests pass
unchanged.
- 1 new TrackRow test: radio button calls playRadio with track id and
does NOT trigger row play.
Verified locally: go -short -race ./... clean, golangci-lint clean,
svelte-check 0/0, 175 vitest tests (was 174), web build succeeds.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
CI's golangci-lint (`gofmt -s`) on Go 1.23 flagged inline comments after
the SessionVector literals in TestContextualMatchScore_TakesMax because
the variable-length values produced misaligned trailing columns. Local
gofmt on Go 1.26 was lenient. Replace with a leading comment that covers
all three rows; same intent, no alignment dance.
Code review caught four tests using `got, _` to discard load errors. A DB
failure would have surfaced as a misleading "wrong score" assertion rather
than a clear "load failed" message. Match the t.Fatalf pattern used by the
other LoadCandidates tests in this file.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a 6th argument (currentVector SessionVector) to LoadCandidates,
bulk-fetches the user's active contextual_likes in one query, and sets
ContextualMatchScore on each candidate's ScoringInputs via max-similarity
over that candidate's like vectors. Adds helper + 6 integration tests
covering no-likes, single match, multi-like max, soft-delete filtering,
seed-like filtering, and seed-current short-circuit. Adds contextual_likes
to the TRUNCATE list in testPool to prevent cross-test leakage.
internal/api/radio.go is intentionally left broken (Task 6 fixes it).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pure aggregation per spec §6: Seed flag (true when prior < 3),
deduplicated Artists ordered by first appearance, Tags bag-of-counts
from tracks.genre (empty genres skipped), RecentTrackIDs preserving
input order. JSON round-trip verified.
ListRecentSessionTracks + UpdatePlayEventVector for the vector capture
path inside RecordPlayStarted. LikeTrack switches to :execrows so the
contextual-likes capture can detect insert vs already-exists. Existing
callers updated to ignore the count for now; later tasks consume it.
Composes Score over a candidate slice, sorts descending, truncates.
Pure — no IO. Liked-rank-higher and high-skip-ranks-last behaviors
are stable across RNG seeds (jitter band is smaller than LikeBoost
and SkipPenalty).
Implements spec §6 weighted-shuffle scoring without the
contextual_match_score term (sub-plan #3 adds it). Pure Go, no DB
dependency; injectable RNG for deterministic tests. Coverage 100%
on score.go via the boundary tests.