"You might like" Home rows + taste profile (learn + apply) #91

Merged
bvandeusen merged 5 commits from dev into main 2026-06-11 21:41:18 -04:00
Owner

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

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)
bvandeusen added 5 commits 2026-06-11 21:41:07 -04:00
feat(server): "You might like" album/artist Home rows (#790)
test-go / test (push) Successful in 39s
test-go / integration (push) Failing after 4m39s
fdd14ef04c
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>
fix(playlists): pin candidate order before jitter to make daily builds deterministic
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 4m33s
752906b054
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>
feat(taste): phase 1 — persistent per-user taste profile from graded engagement (#796)
test-go / test (push) Failing after 25s
test-go / integration (push) Has been cancelled
6e0d0e5723
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>
fix(taste): drop unused now param + tighten test (golangci-lint revive)
test-go / test (push) Successful in 28s
test-go / integration (push) Successful in 4m25s
13b3fca949
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>
feat(taste): phase 2a — apply the taste profile via a TasteMatch scoring term (#796)
test-go / test (push) Successful in 39s
test-go / integration (push) Successful in 4m34s
aff346c731
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>
bvandeusen merged commit 3c646c6974 into main 2026-06-11 21:41:18 -04:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bvandeusen/minstrel#91