Surfaces in-library albums/artists the listener doesn't actively spin but is
predicted to enjoy, derived from the same similarity + like-weighted engine that
powers For-You, rolled up from track scores to album/artist. Built in the daily
3am pass, atomic-replaced, read by /api/home (+ /api/home/index).
Cold-start gate: no rows below 20 distinct unskipped tracks AND 5 distinct
artists, so a thin profile ships empty rather than near-random tiles.
Roll-up: sum-of-top-3 aggregation, per-artist album cap, daily rotation;
read-time dedup vs Most Played / Rediscover / Last Played.
migration 0034; two thin tables.
Also fixes a pre-existing latent flake it surfaced: scoreAndSortCandidates
drew jitter by slice position over an unstably-ordered candidate query, so
same-day rebuilds could reorder near-ties. Pinned candidate order by track id —
daily builds are now genuinely deterministic-within-a-day as documented.
A persistent, decaying per-user taste model, recomputed daily.
Graded engagement replaces binary was_skipped for learning: completion ratio
→ [-1,+1] (instant-skip → -1, ~0.30 neutral, ≥0.90 → +1), time-decayed
(~75d half-life) so recent listening dominates and drift is tracked.
No dislike button — negatives come only from passive behaviour and are
track-scoped; artist/tag weight is the decayed SUM of its tracks' engagement, so
one skip nets out against many good plays (a loved artist can't be tanked by a
single skip). Floor clamp bounds magnitude.
migration 0035; new internal/taste package; wired into the daily scheduler
(best-effort, never blocks the playlist build).
The profile now changes what surfaces. A TasteMatch scoring term re-ranks every
candidate by fit to learned taste (positive draws toward it, negative reflects
passive avoidance, 0 at cold start) — across system playlists, You-might-like, and
radio. taste_weight is operator-tunable (1.5 daily mixes, 1.0 radio).
Deferred: the You-might-like Android client row; taste phase 2b (taste_overlap
candidate arm + profile-seeded For-You) — best validated after the profile
accumulates real data; taste phases 3 (context) / 4 (observability).
Server-side recommendation work. Three arcs, all CI-green on dev, no client changes.
## "You might like" — in-library album/artist recommendation rows (#790)
Surfaces in-library albums/artists the listener doesn't actively spin but is
predicted to enjoy, derived from the same similarity + like-weighted engine that
powers For-You, rolled up from track scores to album/artist. Built in the daily
3am pass, atomic-replaced, read by `/api/home` (+ `/api/home/index`).
- **Cold-start gate:** no rows below 20 distinct unskipped tracks AND 5 distinct
artists, so a thin profile ships empty rather than near-random tiles.
- Roll-up: sum-of-top-3 aggregation, per-artist album cap, daily rotation;
read-time dedup vs Most Played / Rediscover / Last Played.
- migration 0034; two thin tables.
- **Also fixes a pre-existing latent flake** it surfaced: `scoreAndSortCandidates`
drew jitter by slice position over an unstably-ordered candidate query, so
same-day rebuilds could reorder near-ties. Pinned candidate order by track id —
daily builds are now genuinely deterministic-within-a-day as documented.
## Taste profile — phase 1 (learn) (#796)
A persistent, decaying per-user taste model, recomputed daily.
- **Graded engagement** replaces binary was_skipped for learning: completion ratio
→ [-1,+1] (instant-skip → -1, ~0.30 neutral, ≥0.90 → +1), time-decayed
(~75d half-life) so recent listening dominates and drift is tracked.
- **No dislike button** — negatives come only from passive behaviour and are
track-scoped; artist/tag weight is the decayed SUM of its tracks' engagement, so
one skip nets out against many good plays (a loved artist can't be tanked by a
single skip). Floor clamp bounds magnitude.
- migration 0035; new `internal/taste` package; wired into the daily scheduler
(best-effort, never blocks the playlist build).
## Taste profile — phase 2a (apply) (#796)
The profile now changes what surfaces. A `TasteMatch` scoring term re-ranks every
candidate by fit to learned taste (positive draws toward it, negative reflects
passive avoidance, 0 at cold start) — across system playlists, You-might-like, and
radio. `taste_weight` is operator-tunable (1.5 daily mixes, 1.0 radio).
Deferred: the You-might-like Android client row; taste phase 2b (taste_overlap
candidate arm + profile-seeded For-You) — best validated after the profile
accumulates real data; taste phases 3 (context) / 4 (observability).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
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>
scoreAndSortCandidates drew per-candidate jitter by slice position, but
the candidate query (LoadRadioCandidatesV2) has ORDER BY random() arms and
no stable outer ordering, so DB row order varies call-to-call. When the
recency spread between candidates is smaller than the ±jitter (small or
recency-clustered libraries), two same-day rebuilds assigned jitter to
different tracks and reordered near-ties — so the build was not actually
deterministic-within-a-day as documented.
Pre-existing latent flake in TestBuildSystemPlaylists_DailyNonceDeterminism
(passed in isolation / by luck in CI; deterministically reproduced when the
system-build tests run in sequence). Confirmed independent of the
You-might-like change by neutralizing buildYouMightLike — the flake
persisted.
Fix: sort the candidate slice by track id before assigning jitter, so the
jitter for a track is a function of (track, day) alone, independent of DB
return order. Verified: full playlists package green 4/4 and the build-test
sequence green 5/5 (was 0/4 before).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Build a persistent, decaying model of each user's taste, recomputed daily,
that later phases consume across every recommendation surface. Phase 1 only
BUILDS the object — no behaviour change to what's surfaced yet.
Core mechanic — graded engagement (replaces binary was_skipped for learning;
was_skipped stays for History): a play's completion ratio maps to a signal in
[-1,+1] via two linear ramps (instant-skip → -1, ~0.30 neutral, ≥0.90 → +1).
Time-decayed (half-life ~75d) so recent behaviour dominates and the profile
tracks drift.
Per operator constraints:
- No explicit dislike button — negatives come only from passive behaviour
(early skips). Nothing recorded to regret or opt out of.
- Negatives are track-scoped; artist/tag weight is the decayed SUM of their
tracks' engagement, so one skip nets out against many good plays (a
DB test asserts a liked artist stays positive despite an early-skipped
track). A floor clamp bounds how negative any single entity can get.
- migration 0035: taste_profile_artists / taste_profile_tags (signed weight,
indexed by (user, weight DESC)).
- internal/taste: engagement.go (pure curve + decay) + profile.go
(accumulate plays + like bonuses, floor damping, size caps, atomic-replace).
- scheduler: rebuildUserDaily recomputes the profile before the playlist
build (so phase 2 can read it), best-effort — a taste failure never blocks
playlist building. Wired into the daily job + startup catch-up only (not
manual/lazy rebuilds).
- tests: pure (engagement curve, decay, ranking, floor, genre split) +
DB-backed (positive/negative weights, aggregation-protects-artist, like
bonus, atomic replace). All green vs real Postgres.
Config knobs live in taste.DefaultConfig() for now; wiring them into the
server RecommendationConfig is a later follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
golangci-lint v2 (CI-only; local is v1) flagged two unused-parameter issues:
- BuildTasteProfile's `now` was genuinely dead — decay/windowing are computed
DB-side via now(), so no Go-side timestamp is threaded. Removed it (a
phase-3 context model that needs a pinned reference time would re-add it);
updated the scheduler call site.
- the degenerate-params engagement test ignored t; reworked it to assert the
result stays in [-1,1], which also strengthens the test.
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>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Server-side recommendation work. Three arcs, all CI-green on dev, no client changes.
"You might like" — in-library album/artist recommendation rows (#790)
Surfaces in-library albums/artists the listener doesn't actively spin but is
predicted to enjoy, derived from the same similarity + like-weighted engine that
powers For-You, rolled up from track scores to album/artist. Built in the daily
3am pass, atomic-replaced, read by
/api/home(+/api/home/index).artists, so a thin profile ships empty rather than near-random tiles.
read-time dedup vs Most Played / Rediscover / Last Played.
scoreAndSortCandidatesdrew jitter by slice position over an unstably-ordered candidate query, so
same-day rebuilds could reorder near-ties. Pinned candidate order by track id —
daily builds are now genuinely deterministic-within-a-day as documented.
Taste profile — phase 1 (learn) (#796)
A persistent, decaying per-user taste model, recomputed daily.
→ [-1,+1] (instant-skip → -1, ~0.30 neutral, ≥0.90 → +1), time-decayed
(~75d half-life) so recent listening dominates and drift is tracked.
track-scoped; artist/tag weight is the decayed SUM of its tracks' engagement, so
one skip nets out against many good plays (a loved artist can't be tanked by a
single skip). Floor clamp bounds magnitude.
internal/tastepackage; wired into the daily scheduler(best-effort, never blocks the playlist build).
Taste profile — phase 2a (apply) (#796)
The profile now changes what surfaces. A
TasteMatchscoring term re-ranks everycandidate by fit to learned taste (positive draws toward it, negative reflects
passive avoidance, 0 at cold start) — across system playlists, You-might-like, and
radio.
taste_weightis operator-tunable (1.5 daily mixes, 1.0 radio).Deferred: the You-might-like Android client row; taste phase 2b (taste_overlap
candidate arm + profile-seeded For-You) — best validated after the profile
accumulates real data; taste phases 3 (context) / 4 (observability).
🤖 Generated with Claude Code