Files
minstrel/internal/db/migrations/0035_taste_profile.up.sql
bvandeusen 6e0d0e5723
test-go / test (push) Failing after 25s
test-go / integration (push) Has been cancelled
feat(taste): phase 1 — persistent per-user taste profile from graded engagement (#796)
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>
2026-06-11 20:55:14 -04:00

34 lines
1.4 KiB
SQL

-- 0035_taste_profile.up.sql — per-user taste profile (#796 phase 1).
--
-- A persistent, decaying model of each user's taste, rebuilt daily from
-- graded play engagement (completion + skip position) plus explicit
-- likes. Two facet tables: weighted artists and weighted genre tags.
-- Weight is a signed float — positive = drawn to, negative = (passively)
-- avoided; magnitude reflects decayed engagement. Phase 1 only BUILDS
-- these; consumption (candidate sourcing + scoring) lands in phase 2.
--
-- CASCADE on user/artist deletes keeps them clean. Indexed by
-- (user_id, weight DESC) to serve "top taste artists/tags" reads cheaply.
CREATE TABLE taste_profile_artists (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
artist_id uuid NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
weight double precision NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, artist_id)
);
CREATE INDEX taste_profile_artists_user_weight_idx
ON taste_profile_artists (user_id, weight DESC);
CREATE TABLE taste_profile_tags (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tag text NOT NULL,
weight double precision NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, tag)
);
CREATE INDEX taste_profile_tags_user_weight_idx
ON taste_profile_tags (user_id, weight DESC);