# Recommendation & Identity v2

This document retires the v1 recommendation engine; it does not tune it.
Where the old `05-recommendation-spec.md` shipped a single CTE inside
`db.service.ts:getNextVibeChunk` (line ~715) that simultaneously did
candidate generation, scoring, evaluation, exploration policy, and
session composition, v2 splits that loading into five cooperating
systems. The CTE stays untouched (and buggy) until System D lands,
then is deleted.

The two philosophy docs — *Music Intelligence System* and *Discovery
Pipeline / Session Director* — describe the shape. This doc is the
engineering plan: schema, write paths, read paths, acceptance, and an
explicit *replaces* list per system.

### What survives from the old v2 plan

- **Phase 4 — Image candidates** (`image_candidates` table). Preserved
  verbatim in §F below. Orthogonal to recommendation; the bad-image
  problem is a provenance problem, unrelated to the engine.
- **§1.1's intent** (the engine must learn from plays, not from a Keep
  button the user does not press) — re-implemented under System B as
  evidence rows feeding per-profile beliefs, not as a
  `feedback(action='promoted')` row.

### What is retired by this plan

The old v2 **Phases 1, 2, 3, 5** are subsumed and displaced:

| Old phase | Retired by | Why |
|---|---|---|
| Phase 1 (engine tuning: recency, overplay, same-art, cap) | System D | All four are symptoms of doing the session director's job inside a scorer. In D they stop being tuning constants and become structural consequences of fatigue + budgets. |
| Phase 1 §1.1 (implicit promote) | System B | `feedback(action='promoted')` is the wrong shape; evidence rows under per-profile beliefs are the right shape. |
| Phase 2 (`album_artists` junction) | System A | A single probabilistic claims graph subsumes album ownership, MB credit, and identity groups as predicate triples. No separate `album_artists` table. |
| Phase 3 (MB authoritative credit, re-credit pass) | System A | MB is the structural *spine* (it seeds high-trust claims), not the *truth*. Re-credit pass becomes "fetch MB claims into the graph"; no destructive overwrite of `track_artists`. |
| Phase 5 (`artist_groups`) | System A | `alias_of` is a continuous belief (`P(DOOM ≡ Madvillain)`), not a curated flag table. |

### Architectural principles

1. **Music is a graph of entities + probabilistic claims**, not a
   folder of files or a table of flat similarity rows.
2. **Truth is probabilistic fusion.** Every claim is evidence, not
   fact. Conflicts coexist; resolution happens at read time, weighted by
   source trust and recency.
3. **MusicBrainz is the structural spine** (MBIDs + credit bands as
   high-trust claim seeds), never the truth by decree. When MB and tags
   disagree, both claims live in the graph with different trust weights.
4. **Aliasing is continuous** and evolves with listening behavior. A
   `alias_of` claim is a belief with a confidence value, reinforced
   when the listener plays both aliases back-to-back in a session.
5. **Listener identity is multidimensional**, keyed on `user_id` from
   the start (single user today, multi-user tomorrow — no retrofit).
   Multiple profiles coexist: long-term, current obsession, discovery,
   negative, forgotten, contextual.
6. **Sessions are directed, not scored.** The objective is the best
   next *hour*, not the best next *track*. Fatigue, diversity budgets,
   arcs, surprise, callbacks, and an entropy target all live in a
   planner that re-plans continuously.
7. **Discovery is autonomous and separate from playback.** Acquisition
   writes candidates into the graph; probation is a state on those
   candidates; the session director consumes probation-tracked tracks
   without knowing they are probation.
8. **Everything decays unless reinforced.** Beliefs, claims' confidence,
   and fatigue all weaken over time. This prevents permanent historical
   bias and keeps the system learning the *current* listener, not the
   listener of two years ago.

---

## The five systems

```
        A  Knowledge graph (probabilistic fusion)
        │
   ┌────┴────┐
   B         E
 Listener   Acquisition
 model      pipeline
   │
   C  Candidate generators
   │
   D  Session director
```

- **A** is the foundation; no other system can run without it.
- **B** and **E** depend only on A and may be built in parallel.
- **C** depends on A (graph traversal) and reads B (profiles inform
  generator selection, e.g. revival generator reads the forgotten
  profile, discovery generator reads the discovery profile).
- **D** depends on C (needs generators to populate the plan) and B
  (needs listener state from beliefs); it is the final piece.
- **Phase 4 (image candidates)** ships any time, independent of A–E.

Critical path: **A → {B, E} → C → D**.

---

## System A — Knowledge graph (probabilistic fusion)

Maintains the connected entity graph with probabilistic relationships.
Never contains user-specific knowledge (objective claims carry
`user_id = NULL`); listener-behavior-derived claims carry `user_id`
and fuse into the per-user graph view at read time.

### A.1 Schema

```sql
-- Source trust weights. One row per source of claims. Tunable.
CREATE TABLE source_trust (
  key         TEXT PRIMARY KEY,          -- 'mb' | 'discogs' | 'lastfm' | 'tag' | 'listener_behavior' | 'curated'
  trust       REAL NOT NULL CHECK (trust >= 0 AND trust <= 1.0),
  description TEXT NOT NULL
);

-- Claims: the spine of the graph. One row per (subject, predicate, object, source).
-- user_id is NULL for objective claims (MB, Discogs, tags), non-NULL for
-- listener-behavior-derived claims (e.g. weak alias_of from adjacent plays).
CREATE TABLE claims (
  id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id             UUID,                        -- NULL = objective
  subject_type        TEXT NOT NULL,               -- 'artist'|'album'|'track'|'label'|'genre'|'scene'
  subject_id          UUID NOT NULL,
  predicate           TEXT NOT NULL,               -- see A.2
  object_type         TEXT NOT NULL,
  object_id           UUID NOT NULL,
  source              TEXT NOT NULL REFERENCES source_trust(key),
  confidence          REAL NOT NULL DEFAULT 1.0 CHECK (confidence >= 0 AND confidence <= 1.0),
  evidence_at         TIMESTAMPTZ NOT NULL,        -- when the source asserted this
  last_reinforced_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  raw                 JSONB,                        -- original payload for audit
  created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE (subject_type, subject_id, predicate, object_type, object_id, source, user_id)
);
CREATE INDEX claims_subject_idx ON claims (subject_type, subject_id, predicate);
CREATE INDEX claims_object_idx  ON claims (object_type, object_id, predicate);
CREATE INDEX claims_user_idx    ON claims (user_id) WHERE user_id IS NOT NULL;

-- Every entity that participates in the graph carries an optional MBID as
-- the structural spine anchor. These columns already exist on artists/albums
-- today; we add to tracks. MBID presence raises the entity's identity
-- resolution priority (see A.5).
ALTER TABLE tracks ADD COLUMN IF NOT EXISTS recording_mbid UUID;
CREATE INDEX IF NOT EXISTS tracks_recording_mbid_idx ON tracks (recording_mbid) WHERE recording_mbid IS NOT NULL;
```

### A.2 Predicates

The enumerated set. Adding a predicate is a code change (a generator
or fusion view that reads it), not a schema migration — predicates
live in the `claims.predicate` free-text column, validated in code.

| Predicate | subject → object | Meaning |
|---|---|---|
| `credited_main_on` | artist → track | artist is the main credit on the recording |
| `featured_on` | artist → track | artist is a featured performer on the track |
| `credited_main_on_album` | artist → album | artist is the main credit on the album as a whole |
| `featured_on_album` | artist → album | artist is a co-owner / featured credit on the album |
| `alias_of` | artist → artist | subject is an alias of object (directional; confidence = belief) |
| `member_of` | artist → artist | subject is a member of the group object |
| `produced` | artist → track | subject produced the track |
| `composed` | artist → track | subject composed the track |
| `same_label_as` | artist → artist | both artists release on the same label |
| `same_scene_as` | artist → artist | both artists belong to the same scene |
| `influences` | artist → artist | subject influenced object |
| `remix_of` | track → track | subject is a remix of object |
| `cover_of` | track → track | subject is a cover of object |
| `soundtrack_contrib` | artist → franchise | subject contributed to a soundtrack (anime/game/film) |
| `belongs_to_genre` | track/artist → genre | subject belongs to genre (weighted; replaces exact-id match) |

### A.3 Source trust seed

```sql
INSERT INTO source_trust (key, trust, description) VALUES
  ('curated',          1.00, 'Manual / human-curated claim. Never decayed.'),
  ('mb',               0.90, 'MusicBrainz structural spine. High-trust seed; not infallible (re-credits disagree with tags).'),
  ('cover_art_archive',0.85,'Cover Art Archive, MB-backed.'),
  ('discogs',          0.75, 'Discogs release/artist credits. Strong for releases, weaker for person aliases.'),
  ('lastfm',           0.50, 'Last.fm tags + similar. Noisy; used as weak signal.'),
  ('listener_behavior',0.40, 'Derived from observed play patterns (e.g. back-to-back play → weak alias_of). User-keyed.'),
  ('tag',              0.30, 'File-tag-derived via scanner heuristic. Lowest trust; the &-split fallback.')
ON CONFLICT (key) DO NOTHING;
```

### A.4 Fusion read path

Truth is resolved at read time as a weighted vote across all claims
for a given `(subject, predicate, object)`. The fusion formula:

```
fused(subject, pred, object, user_id) =
  Σ over claims c with matching (subject, pred, object)
     where c.user_id IS NULL OR c.user_id = $user_id
  of  source_trust(c.source) * c.confidence * recency(c)
```

where `recency(c) = clamp(0.1, 1.0, days_since(c.last_reinforced_at) / 180)`,
so a claim never reinforced for 180+ days contributes at 10% floor.

A materialised view `claim_fusion` exposes the per-(subject, pred,
object, user) fused value. **View shims** over `claim_fusion` provide
the v1 shapes so existing reads survive the transition without rewrite:

```sql
CREATE OR REPLACE VIEW track_artists_v2 AS
SELECT t.id AS track_id,
       a.id AS artist_id,
       CASE cf.predicate WHEN 'credited_main_on' THEN 'main' ELSE 'featured' END AS role,
       cf.fused_value AS confidence
FROM tracks t
JOIN claim_fusion cf
  ON cf.subject_type = 'track' AND cf.subject_id = t.id
  AND cf.predicate IN ('credited_main_on','featured_on')
  AND cf.object_type = 'artist'
JOIN artists a ON a.id = cf.object_id;

-- Same shape for albums. Drops albums.artist_id reads over time.
CREATE OR REPLACE VIEW album_artists_v2 AS
SELECT al.id AS album_id,
       a.id AS artist_id,
       CASE cf.predicate WHEN 'credited_main_on_album' THEN 'main' ELSE 'featured' END AS role,
       cf.fused_value AS confidence
FROM albums al
JOIN claim_fusion cf
  ON cf.subject_type = 'album' AND cf.subject_id = al.id
  AND cf.predicate IN ('credited_main_on_album','featured_on_album')
  AND cf.object_type = 'artist'
JOIN artists a ON a.id = cf.object_id;
```

`artists.artist_similar` is retired in favour of the graph itself; a
compatibility view maps `same_scene_as` + `alias_of` fused edges to
the old `(artist_id, similar_artist_id, match)` shape for the lifetime
of any read that still wants it.

Genre hierarchy (`genre.parent_id`) is retired as a separate column.
`belongs_to_genre` claims carry a `confidence` weight; the hierarchy
becomes a `parent_of` claim series on genre entities, and the fusion
view's hierarchical rollup walks these claims instead of a column.

### A.5 Write paths

Five independent writers; all UPSERT into `claims`:

1. **MB spine writer** (in the worker enrichment pipeline): when
   `lookupRecording` resolves a recording MBID, fetch the full
   `artist-credit` (not just the first entry — extend the MB client).
   Write one `credited_main_on` claim (artist = first credit) and one
   `featured_on` claim per additional credit, with `source='mb'`,
   `confidence=1.0`, `evidence_at=NOW()`. For release-group MBIDs on
   albums, write `credited_main_on_album` / `featured_on_album`
   analogously. For artist-relation ARs (`member of`, ` collaborations`,
   `vocal`/`instrument`), write `member_of` / `featured_on` claims.
   MBIDs anchor identity: if the credited artist resolves to a known
   MBID, claims attach to that artist entity; otherwise a new artist
   is created with the MBID set.
2. **Discogs writer**: `discogs_id` already set on artists today;
   extend to write `credited_main_on_album` / `same_label_as` claims
   from the release's label and artist credits, `source='discogs'`,
   `confidence=0.7`.
3. **Last.fm writer**: existing `artist_similar` fetcher writes
   `same_scene_as` claims (`source='lastfm'`, `confidence=match/100`)
   instead of the `artist_similar` table. Last.fm tags write
   `belongs_to_genre` claims with `confidence=tag.count/100`.
4. **Tag-derived writer** (scanner fallback): when no MBID, the
   existing `parseArtistsFromMetadata` heuristic writes
   `credited_main_on` / `featured_on` claims with `source='tag'`,
   `confidence=1.0` (the trust weight, low at 0.30, is what dims it).
   `resolveOrCreateArtist` writes the entity row; the claim carries
   the entity, not the name string. Re-keying when MB later resolves
   is an UPSERT, not an overwrite.
5. **Listener-behavior writer** (writes into the *user-keyed* region
   of `claims`): on play sessions, derive weak edges — adjacent plays
   within 30 min of two artists write `same_scene_as` (confidence
   0.3); back-to-back play of two artists within a session writes
   `alias_of` (confidence 0.2). These are *evidence*, not conclusions;
   they fuse with the objective `alias_of` claim from MB (if any) at
   read time. Behavioral claims decay by `last_reinforced_at`; the
   belief-strengthening path in System B reinforces them on repeat.

All writes UPSERT on `(subject, predicate, object, source, user_id)`:
re-fetching a source refreshes `last_reinforced_at` and `evidence_at`
without duplicating rows.

### A.6 Replaces

| Old surface | Status |
|---|---|
| `artist_similar` table | Retired; replaced by `same_scene_as` / `alias_of` claims. Compat view for the transition. |
| `track_artists` (as truth) | Retired as truth; survives as a *view* `track_artists_v2` over `claim_fusion`. No insert path; reads only. |
| `albums.artist_id` (single-FK ownership) | Survives as a denormalised pointer (written by a trigger off `claim_fusion`'s main credit) for back-compat. Truth lives in `album_artists_v2` view. |
| `genre.parent_id` (column) | Retired; replaced by `parent_of` claims on genre entities. |
| Scanner `resolveAlbumArtist` `parts[0]` behaviour | The heuristic now writes *claims*, not truth. `parts[1:]` become `featured_on_album` claims instead of being dropped. |
| MB client `best['artist-credit']?.[0]` first-credit-only read | Extended to read the full `artist-credit` array. |
| Old Phase 2 (`album_artists` junction table) | Not built — subsumed by A. |
| Old Phase 3 (re-credit pass over whole library) | Not built as a destructive overwrite. MB claims UPSERT into the graph; no `track_artists.source` column, no destructive re-credit. |
| Old Phase 5 (`artist_groups`, `artist_group_members`) | Not built — `alias_of` / `member_of` claims are the grouping. |

### A.7 Acceptance

- A track tagged `artist="MF DOOM & Madlib"` with a resolved recording
  MBID shows a `credited_main_on` claim from MB for "Madvillain" (if MB
  credits Madvillain) AND a `credited_main_on` claim from the tag for
  "MF DOOM". Both coexist. The fusion view, weighted by trust (MB 0.90
  > tag 0.30), shows "Madvillain" as the higher-confidence main credit.
- An album tagged `albumartist="A & B"` has two `credited_main_on_album`
  claims — A from tags, A and B from MB if MB credits both. The album
  appears on both A's and B's artist pages via `album_artists_v2`.
- `SELECT * FROM claims WHERE subject_type='artist' AND subject_id=$doom
  AND predicate='alias_of'` returns rows from MB (if it asserts an
  alias) and from listener_behavior (if the user has played DOOM and
  Madvillain back-to-back). Both decay; both reinforce.
- A never-played genre fished via the future `/vibe/from-genre` path
  reaches tracks through the `belongs_to_genre` claims + hierarchical
  rollup, not exact-id match.

### A.8 Risks

- **Fusion reasoning cost.** Every read now resolves a vote across
  multiple claims. Mitigation: `claim_fusion` materialised view,
  refreshed on `claims` insert/update; reads hit the view, not the raw
  table. Estimate: a single `(subject, predicate, object)` fused value
  is a point lookup on the materialised view.
- **MB rate limits during spine backfill.** Initial population walks the
  whole library (~3.9k tracks) fetching full `artist-credit`. Expected
  hours, not minutes, gated by MB's rate policy.
- **Display flips during transition.** When the fusion view's
  high-confidence main credit is "Madvillain" but the file tag says
  "MF DOOM & Madlib", the library view shows "Madvillain". This is
  intended (MB is the structural spine) but may surprise the user at
  first. Both claims remain auditable via `SELECT * FROM claims`.
- **Tag-only tracks** (no MBID) inherit the lowest-trust claims. This
  is correct: the graph is honest about how much it knows.

---

## System B — Listener model

Maintains probabilistic beliefs about the listener. Keyed on
`user_id` from the start; behaviours and beliefs cannot be retrofitted
later without painful re-keying once belief data accumulates.

### B.1 Schema

```sql
-- Evidence: every observed interaction that should influence a belief.
-- Append-only. Never edited or deleted (purge policy separate).
CREATE TABLE evidence (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id       UUID NOT NULL,
  entity_type   TEXT NOT NULL,            -- 'track'|'artist'|'genre'|'album'
  entity_id     UUID NOT NULL,
  signal        TEXT NOT NULL,            -- see B.3
  profile       TEXT NOT NULL,            -- which profile this evidence feeds; see B.2
  weight        REAL NOT NULL,            -- signal strength, set by the signal rule
  context       JSONB,                    -- optional: {session_id, hour, weekday, activity, ...}
  created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX evidence_user_entity_idx ON evidence (user_id, entity_type, entity_id, created_at DESC);
CREATE INDEX evidence_user_profile_idx ON evidence (user_id, profile, created_at DESC);

-- Listener beliefs: the derived state. Continuously decayed; reinforced by evidence.
CREATE TABLE listener_beliefs (
  user_id              UUID NOT NULL,
  profile              TEXT NOT NULL,           -- see B.2
  entity_type          TEXT NOT NULL,
  entity_id            UUID NOT NULL,
  dimension            TEXT NOT NULL,           -- 'affinity'|'fatigue'|'familiarity'|'novelty_tolerance'
  value               REAL NOT NULL CHECK (value >= -1.0 AND value <= 1.0),
  confidence           REAL NOT NULL CHECK (confidence >= 0 AND confidence <= 1.0),
  evidence_count       INTEGER NOT NULL DEFAULT 0,
  last_reinforced_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  last_decayed_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  PRIMARY KEY (user_id, profile, entity_type, entity_id, dimension)
);
CREATE INDEX listener_beliefs_user_profile_idx ON listener_beliefs (user_id, profile, entity_type, entity_id);
```

### B.2 Profiles

Enumerated. Adding a profile is a code change (an evaluator or
generator that reads it), not a schema migration.

| Profile | Decay half-life | Fed by signals | Read by |
|---|---|---|---|
| `longterm` | 365 days (slow) | replays, manual search, add-to-favorites, multiple sessions | comfort, deep-dive generators |
| `obsession` | 14 days (fast) | disproportionate play concentration | adjacent, novelty generators (to bound) |
| `discovery` | 30 days | play-of-never-before-seen-entity, accept-after-skip | discovery, experimental generators |
| `negative` | 180 days | skips, queue-removal, hide, manual-delete | all generators (exclusion) |
| `forgotten` | n/a (derived) | `longterm` high-affinity + 90d no plays | revival generator |
| `contextual` | 7 days | context-tagged play sessions | contextual generator |

`forgotten` is derived nightly from `longterm` beliefs that have not
been reinforced in 90 days; it is not written by signals directly.

### B.3 Signal → weight rules

A signal writes one `evidence` row with a `weight`. The weight feeds
into the belief update (B.4). Signal weights are constants, tunable:

| Signal | Profile | Weight | Direction |
|---|---|---|---|
| `playback_completed` | longterm | +0.10 | affinity up |
| `replay_within_24h` | longterm | +0.25 | affinity up |
| `replay_within_24h` | obsession | +0.40 | affinity up |
| `manual_search` | longterm | +0.50 | affinity up |
| `add_to_favorites` | longterm | +0.60 | affinity up |
| `shared` | longterm | +0.70 | affinity up |
| `play_of_never_seen` | discovery | +0.05 | discovery tolerance up |
| `accept_after_probe` | discovery | +0.30 | affinity up (mild) |
| `skip_quick` (≤ 30s) | negative | -0.20 | affinity down |
| `skip_repeated` | negative | -0.40 | affinity down |
| `queue_removed` | negative | -0.30 | affinity down |
| `hidden` | negative | -0.60 | affinity down |
| `manual_deleted` | negative | -0.90 | affinity down (strong) |

Neutral actions (seek, pause, volume) write no evidence. Lack of
interaction writes no evidence (a core principle: *absence of
interaction is not dislike*).

`weight` is per-signal; an event may write multiple `evidence` rows
across multiple profiles (a `replay_within_24h` writes both a
`longterm` +0.25 row and an `obsession` +0.40 row).

### B.4 Belief update + decay

On each new evidence row matching `(user, profile, entity, dimension)`:

```
belief.value       = clamp(-1, 1, belief.value + Σ new_evidence.weight * (1 - belief.confidence))
belief.confidence  = clamp(0, 1, belief.confidence + 0.05)
belief.evidence_count += count(new rows)
belief.last_reinforced_at = NOW()
```

Daily decay job (or on-read lazy decay):

```
h = (NOW() - belief.last_decayed_at) / profile.halflife_days
belief.value       *= 0.5 ^ h
belief.confidence  *= 0.5 ^ h          -- confidence also decays
belief.last_decayed_at = NOW()
```

A belief not reinforced for 3 halflives approaches zero. Old evidence
becomes irrelevant; new evidence dominates. This prevents the v1
failure mode where heavily-played artists keep winning forever.

### B.5 Replaces

| Old surface | Status |
|---|---|
| `feedback(action='promoted')` | Not written. The implicit-promote intent from old §1.1 lives as a `playback_completed` evidence row → longterm affinity up. |
| `feedback(action='disliked'|'skipped'|'deleted_permanent')` | Not written. Same signals now write `negative`-profile evidence rows. The `feedback` table is retired; existing data is backfilled into `evidence` once and then the table is dropped. |
| `favorites` table | Survives as a UI collection (the Keep button is a UI concept, separate from engine affinity). Old §1.1 explicitly kept this split; we keep it. Keep writes a `favorites` row AND a `add_to_favorites` evidence row. |
| The clamped genre-affinity term in `local_pool` | Retired. Affinity is now per-(user, profile, entity) in `listener_beliefs`; generators read beliefs directly. |
| `play_history` reads by nothing in v1 | `play_history` survives (it is the audit log of plays) but the scorer doesn't read it; the evidence writer does, once per play, converting a play row into evidence signals. |

### B.6 Acceptance

- After one completed play of a never-played track, a `longterm`
  affinity belief for that track exists at `value=+0.05`, `confidence=0.05`.
- After 5 completed replays within a week, that track's `longterm`
  affinity is above +0.30; the artist's affinity (rolled up from track
  beliefs) is above +0.20.
- A track skipped 3× in 30 days has a `negative`-profile affinity below
  -0.50. The session director's exclusion filter consults this.
- A track not played for 365 days has decayed its `longterm` affinity
  to ~50% of peak; it appears in the `forgotten` derived profile.
- `SELECT value FROM listener_beliefs WHERE user_id=$1 AND profile='obsession'
  AND entity_type='artist' ORDER BY value DESC LIMIT 5` returns the current
  obsessions, which the session director uses to bound overplay.

---

## System C — Candidate generators

Recommendations originate from independent generators. Each proposes
candidates without knowledge of final ranking — the session director
(D) does the ranking, mixing, and session composition. Each generator
returns candidates with a **graph-path explanation** (the chain of
claims that led to this candidate), so every recommendation is
auditable.

### C.1 Generator interface

```ts
interface Generator {
  id: string;                                     // 'comfort' | 'adjacent' | 'discovery' | ...
  run(ctx: GeneratorContext): Promise<Candidate[]>;
}

interface GeneratorContext {
  userId: string;
  listenerState: ListenerState;                   // from D's state builder
  beliefs: BeliefReader;                           // reads listener_beliefs
  graph: GraphReader;                              // reads claim_fusion (A)
  profile: ProfileName;                           // which profile this generator prefers
  recentExclusions: Set<string>;                  // (entityType, entityId) already churned this session
}

interface Candidate {
  trackId: string;
  generatorId: string;
  explanation: ClaimEdge[];                       // the graph path that produced this candidate
  // No score. Generators don't rank; the director does.
}
interface ClaimEdge {
  subjectType: string; subjectId: string;
  predicate: string;
  objectType: string; objectId: string;
  fusedValue: number;
}
```

### C.2 The generators

Each generator wraps a graph query. All read `claim_fusion` and
`listener_beliefs`; all return `Candidate[]` with explanations.

1. **Comfort** — reads `longterm` affinity beliefs, picks tracks by
   artist with affinity > +0.5, fuses with `credited_main_on` /
   `featured_on` claims to find tracks by those artists. Goal:
   maintain satisfaction.
2. **Adjacent** — for each seed artist in current session state, walks
   1–2 graph hops: `seed → credited_main_on → track → featured_on →
   artist → member_of → group → member_of → artist`. Returns tracks
   by reached artists, excluding those in the comfort pool.
3. **Discovery** — picks tracks whose artists have no `longterm` /
   `obsession` belief (truly unfamiliar), filtered to those with at
   least one graph edge to a trusted artist (`same_scene_as`,
   `same_label_as`, `produced` by a producer who produced a favourite).
   Reads the `discovery` profile's `novelty_tolerance` to set how many
   to return.
4. **Deep-dive** — prioritises complete albums. Picks an album owned
   (via `album_artists_v2`) by an artist with `obsession` affinity and
   returns overlooked tracks (those with low `familiarity` belief) in
   album order. Prefers tracks with no play history.
5. **Revival** — reads the `forgotten` derived profile, returns tracks
   whose `longterm` affinity is high but `last_reinforced_at` is old
   (> 90 days). Time window is adaptive: nostalgia horizon scales with
   how established the longterm profile is.
6. **Novelty** — queries for tracks with `release_date` in the last 60
   days whose artists share a `same_label_as` / `same_scene_as` edge
   with a favourite, OR a `produced` edge from a known producer. Most
   recent first, gated by `discovery` profile tolerance.
7. **Experimental** — deliberately challenges current assumptions.
   Finds genres with very few `longterm` beliefs of any sign (i.e.
   the system is uncertain), picks tracks from those genres with the
   highest network-distance from favourites. Goal: learning, not
   satisfaction. Run rate is low (one track per N, configurable).
8. **Contextual** — reads the `contextual` profile. If the listener
   state has a context tag (coding / driving / sleeping), returns
   tracks whose `listener_beliefs` context entries match that
   context.

### C.3 Replaces

| Old surface | Status |
|---|---|
| `local_pool` CTE | Retired. Comfort and adjacent generators together cover what local_pool tried to be (genre-overlap + artist_sim + same-artist + audio + jitter). |
| `probation_pool` CTE (gated on `artist_sim > 0`) | Retired. Discovery + deep-dive generators cover the intended-but-unshipped behavior. |
| `getVibeChunkFromGenre` (stateless genre seed) | Survives briefly as a thin wrapper over the discovery generator seeded with a genre; cleaned up when D lands. |

### C.4 Acceptance

- Each candidate returned by any generator carries a non-empty
  `explanation` array (graph path). A recommendation with no graph
  path is invalid; generators refuse to return it.
- Seeding a DOOM track: adjacent generator returns tracks by artists
  reached via `featured_on` from DOOM tracks (i.e. Madlib's other
  projects) and via `member_of` from DOOM (i.e. Madvillain tracks) —
  *all as candidates*, with explanations; the session director decides
  whether to use them given the fatigue model.
- A genre with no library coverage (seeded via `/vibe/from-genre`)
  returns zero comfort candidates and a non-empty discovery candidate
  list — surfacing fresh material, not the empty result of v1's
  `artist_sim > 0` gate.

---

## System D — Session director

The planner. Replaces `getNextVibeChunk` entirely. Where the old CTE
selected the highest-scoring 20 tracks in one query, D maintains a
rolling 20–50-track plan that is rewritten on every feedback event,
pursuing invisible long-term goals (finish an album over days,
introduce an artist gradually, balance decades) while optimising
multiple objectives simultaneously.

### D.1 Listener state

Built at the start of each session and updated on each play/skip:

```sql
-- Per-session state; persisted across heartbeats so resumes stay coherent.
CREATE TABLE session_state (
  session_id        UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id           UUID NOT NULL,
  started_at        TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  last_interaction  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  context           TEXT,                    -- 'coding'|'driving'|'sleeping'|NULL (auto-detected or manual)
  state_vector      JSONB NOT NULL           -- the computed state; see below
);
```

`state_vector` fields (computed on state build, refreshed on each event):

```jsonc
{
  "energy": 0.62,                 // avg energy of last 5 plays
  "focus": 0.40,                  // manual focus intent, 0..1
  "novelty_hunger": 0.30,         // from discovery profile's novelty_tolerance
  "artist_fatigue": { "<artistId>": 0.71, ... },   // see D.2
  "genre_fatigue":  { "<genreId>":  0.55, ... },
  "language_fatigue": { "ja": 0.83, "en": 0.10 },
  "vocal_fatigue": 0.40,          // 0 = vocals ok, 1 = want instrumental
  "session_age_min": 73,
  "current_mood": "energetic",
  "target_entropy": 0.55          // see D.5
}
```

### D.2 Fatigue model

Everything gets fatigued. Everything recovers over time. Fatigues are
per-dimension cumulative decays over recent play history, NOT session
counters (v1's `artist_play_count` batch counter is retired).

For each dimension X (track / artist / genre / language / vocalist):

```
fatigue_X(entity, t) = Σ over plays p of X in last T_window
                       of  exp( -(t - p.played_at) / decay_X )

T_window = 24h (artist, genre) | 7d (track) | 2h (language, vocalist)
decay_X  = 8h (artist, genre) | 30d (track) | 1h (language, vocalist)
```

- `track` fatigue: a track played in the last hour `exp(-(0)/30d)=1`;
  played yesterday `exp(-(1d)/30d)≈0.97` — *strong* recent-play penalty
  on tracks; this is the v1 missing cross-session overplay fix, made
  structural. Played a month ago: `exp(-(30d)/30d)≈0.37`.
- `artist` fatigue: rolled up from track fatigue over the artist's
  tracks; collapses MF DOOM / Madvillain / Viktor Vaughn **iff** their
  `alias_of` claims fuse them at read time (which depends on whether
  MB or listener-behavior has asserted the alias). This is the honest
  v1 Phase 5 fix — aliasing is a graph belief, not a flag.
- `genre` / `language` / `vocalist` fatigue: same formula, same
  recency-aware decay.

A candidate's final rank incorporates `1 - fatigue_X(candidate, now)`
as multipliers per dimension; the v1 `GREATEST(0.15, ...)` floor
becomes a tunable per-dimension floor in `session_floor` config.

### D.3 Diversity budgets

Instead of hard caps ("max 1 per artist per chunk", "max 2 per genre"),
a budget the planner spends. Per-session, refreshed at session start:

```sql
INSERT INTO source_trust VALUES ('budget_default', 0.0, 'non-graph config sentinel') ON CONFLICT DO NOTHING;

CREATE TABLE diversity_budgets (
  user_id      UUID NOT NULL,
  dimension    TEXT NOT NULL,         -- 'artist'|'genre'|'language'|'instrumental'|'new_artist'|'favorite'
  budget_share REAL NOT NULL,         -- fraction of session, e.g. 0.20
  horizon_min  INTEGER NOT NULL,      -- budget window, e.g. 30 (min)
  PRIMARY KEY (user_id, dimension, horizon_min)
);
```

Default budgets (seeded on first session per user):

```jsonc
{
  "artist":        { "share": 0.20, "horizon": 30 },
  "genre":         { "share": 0.40, "horizon": 30 },
  "language":      { "share": 0.60, "horizon": 30 },
  "instrumental":  { "share": 0.10, "horizon": 30 },
  "new_artist":    { "share": 0.15, "horizon": 60 },
  "favorite":      { "share": 0.25, "horizon": 60 }
}
```

"Not more than 2 songs of the same artist in the last 30 min" becomes
a 20%-of-30min budget. The planner spends, replenishes at window edge.
If recent listening has blown a budget, the planner refuses further
spends in that dimension — the structural replacement for v1's
diversity cap.

### D.4 Arcs

The planner doesn't pick songs; it picks *arcs* and slots songs into
them. Templates:

- **Comfort arc**: known → known → adjacent → favorite.
- **Discovery arc**: favorite → similar → new → favorite.
- **Energetic arc**: medium → high → peak → cooldown.
- **Late-night arc**: soft → ambient → acoustic → slow electronic.

At session start, given the state vector and the long-term schedule
(D.7), the planner picks an arc template and fills it. The plan is a
list of "slots" (`{arc_position, role}`, e.g. `{1, "peak"}`); the
planner queries the matching generator for each slot. On replan,
remaining slots can shift arc.

### D.5 Surprise, callbacks, entropy target, anti-loop

- **Surprise budget**: ~1 per hour (configurable). Reserved slot in
  the arc for a forgotten favorite, live version, cover, acoustic
  version, producer side project, or old obsession. Surfaced via the
  revival generator with a `surprise=true` flag.
- **Callbacks**: every N tracks the planner intentionally re-introduces
  an artist / theme / energy level from earlier in the session (or
  earlier session that day). Makes the session feel intentional.
- **Entropy target**: the state vector carries `target_entropy`. If
  the autocorrelation of last-20 chosen-track features is too high
  (predictable), entropy goes up (planner prefers experimental /
  discovery candidates). If too low (chaotic), planner injects
  comfort. Target is **controlled unpredictability**, not randomness.
- **Anti-loop detector**: continuously monitor the last-50 plan
  choices for collapsing into a narrow graph region (same artist /
  label / producer / genre / decade / BPM / mood / language). If
  collapse detected, the planner forcibly expands: zero-out the
  dominant dimension's budget for the next window and surge a
  non-dominant generator.

### D.6 Repetition rules

Adaptive minimum-distance, not absolute "don't repeat":

```sql
CREATE TABLE repetition_rules (
  user_id      UUID NOT NULL,
  dimension    TEXT NOT NULL,    -- 'track'|'artist'|'album'|'genre'|'energy'
  min_distance INTEGER NOT NULL, -- adaptive; minutes
  PRIMARY KEY (user_id, dimension)
);
```

Defaults: `track=2h, artist=20min, album=no immediate; spread across
hours, genre=don't-dominate, energy=smooth transitions`. All adapt:
if a listener shows high `focus` (deep work signal), distances relax
(loop tolerance up); if skipping-after-replay pattern appears,
distances tighten.

### D.7 Long-term scheduling + invisible goals

The planner also has week-scale objectives, tracked in
`session_state.state_vector.goals`:

- Finish an album over several days (track which album is "in
  progress"; the deep-dive generator keeps returning its overlooked
  tracks until the album is fully played).
- Introduce a new artist gradually (e.g. one track per session for a
  week, escalating if survival rate is high).
- Revisit old favorites monthly.
- Balance decades, languages, producers (the budgets cover most of
  this; the planner periodically nudges an under-represented decade to
  surge).
- Complete discovery probation (see System E).
- Guarantee at least one surprise per hour.

The listener should never notice these goals directly.

### D.8 Don't maximise enjoyment

The planner optimises multiple objectives simultaneously:

```
maximise:
  enjoyment           (predicted from beliefs × relevance)
  discovery           (fraction of unfamiliar entities in the plan)
  diversity           (1 - Herfindahl index across artists in horizon)
  learning            (information gain on uncertain beliefs)
  session coherence   (arc-template adherence)
  long-term freshness (entropy target met)

minimise:
  fatigue             (cumulative per-dimension fatigue)
  repetition          (autocorrelation of recent choices)
  predictability      (1 - entropy)
  wasted discoveries  (candidates surfaced then immediately skipped)
```

This is why single-objective score maximisation (the v1 approach)
collapses to "ADO, Yoasobi, ADO, Zutomayo, ADO" — those are the
"optimal" tracks by predicted enjoyment alone.

### D.9 Plan + replan loop

```
session start
  ↓
build state_vector (D.1)
  ↓
pick arc template (D.4) + target entropy (D.5)
  ↓
for each slot in arc:
  query matching generator (C) → candidates
  rank candidates across the D.8 objectives
  pick winner, respecting budgets (D.3) + repetition rules (D.6)
  ↓
20–50 track plan
  ↓
playback
  ↓
on play / skip / manual action:
  write evidence (B.3)
  refresh state_vector fatigue (D.2)
  if plan slot < 10 remaining OR anti-loop fires OR entropy drift > 0.2:
    replan from current state
  ↓
loop
```

### D.10 Replaces

| Old surface | Status |
|---|---|
| `getNextVibeChunk` CTE (~220 lines in `db.service.ts`) | Retired entirely on D ship. |
| `recommendation_batch_track` exclusion set | Survives as the recent-exclusions `Set` passed to generators; no longer the source of artist-play-count. |
| `recommendation_batch` row + `seed_track_id` center-walk in `recordPlay` | Retired. The center-walk was a hack for "engine can't escape the seed neighbourhood"; D's arc + fatigue together replace it. |
| Old §1.2 (recency term added to local_pool) | Not a scored term anymore; recency is a fatigue-dimension multiplier in D.2. |
| Old §1.3 (track-level overplay penalty) | D.2's `track` fatigue, structural. |
| Old §1.4 (artist-level overplay penalty, identity-best-effort) | D.2's `artist` fatigue rolled up via alias fusion in A. Identity collapse happens *iff the claims graph says so*, not as a separate code path. |
| Old §1.5 (lower W_SAMEART) | No `W_SAMEART` to tune; the comfort generator alone handles "more of this artist" and is naturally bounded by D.3's artist budget. |
| Old §1.7 (cap on `track_artists.artist_id` not name string) | Subsumed by D.3's budgets (artist dimension). |

### D.11 Acceptance

- After 6 hours of listening, the listener is still engaged, has
  discovered at least one unfamiliar but tolerable track, has not
  become fatigued by any single artist / genre / language, and the
  next session would still feel fresh.
- Seeding a DOOM track does not collapse the next chunks into DOOM
  pseudonyms even though alias fusion may treat them as one artist —
  because D.2's artist fatigue rises fast in the session, D.3's
  artist budget blocks further spends, and D.4's arc pulls toward
  adjacent generators (Madlib's other projects reached via graph
  hops, not DOOM).
- Within a session, tracks played in the last hour do not re-appear
  (track fatigue multiplier ≈ 0 after recent plays).
- Across sessions, the same top-20 does not return: track fatigue
  half-life of 30 days means yesterday's plays still dampen today's
  rank.
- Forgotten favorites resurface naturally (revival generator + monthly
  long-term goal).
- Anti-loop detector fires when the dominant dimension's share exceeds
  budget × 1.5, forcibly diversifying the next window.

---

## System E — Acquisition pipeline

The library is not the universe. E continuously searches beyond the
current collection, identifies music worth evaluating, acquires it
(via the unbuilt yt-dlp worker, `progress.md:29`), validates it, and
either permanently integrates it into the graph (A) or discards it.
Discovery is independent of playback; it writes into A.

### E.1 Discovery sources

Six independent strategies run continuously as low-priority worker
jobs:

1. **Graph exploration** — walk the graph beyond the library. For each
   favourite artist (per `longterm` beliefs), follow `featured_on` /
   `member_of` / `produced` / `same_label_as` / `same_scene_as` edges
   to artists not in the library. Each traversal is a discovery path
   candidate.
2. **Release monitoring** — monitor favourite artists, related artists
   (graph adjacents), labels, and producers for new releases. New
   releases become discovery candidates at high priority.
3. **Scene exploration** — discover music through communities rather
   than artists: city scenes, internet communities, niche genres,
   underground movements, independent labels. Avoids recommendation
   loops around the same popular artists.
4. **Temporal exploration** — search different musical eras for
   forgotten classics, overlooked releases, albums that became
   influential years later.
5. **Relationship expansion** — instead of "people also listen to",
   prefer structural relationships: same producer, same composer,
   live band members, guest vocalists, touring partners, soundtrack
   contributors.
6. **Curiosity exploration** — dedicated exploration budget for
   unfamiliar genres, different languages, experimental music,
   geographically distant scenes. Success is measured by learning,
   not immediate satisfaction.

### E.2 Candidate universe

Before downloading, discoveries live as `claims` rows with
`subject_type='track'` and a special marker — they are *candidate*
tracks, not library tracks. The candidate carries the discovery
source, the relationship path that led to it, an estimated relevance,
and an explanation.

This reuses `claims` rather than a dedicated table, with a dedicated
predicate:

```sql
-- A discovery candidate is a claim: subject=track (candidate), predicate='discovery_candidate', object=source artist / scene / label.
-- The 'confidence' field is the estimated relevance; 'raw' holds the full path + explanation.
INSERT INTO claims (subject_type, subject_id, predicate, object_type, object_id, source, confidence, raw)
VALUES ('track', $candidateId, 'discovery_candidate', 'artist', $relatedArtistId, 'graph_exploration', $relevance, $pathJson)
ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id) DO NOTHING;
```

Candidate tracks themselves are stored as stub rows in a
`discovery_candidates` table — thin rows holding the external identity
only, no library path / audio / metadata yet:

```sql
CREATE TABLE discovery_candidates (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  source          TEXT NOT NULL,           -- 'mb'|'discogs'|'lastfm'|'spotify'|...
  external_id     TEXT NOT NULL,           -- MBID / discogs_id / etc.
  title           TEXT,
  artist_credit   JSONB,                   -- the full artist-credit array from the source
  notes           JSONB,                   -- discovery path, source-only fields
  first_seen_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  last_eval_at   TIMESTAMPTZ,
  status          TEXT NOT NULL DEFAULT 'candidate',  -- 'candidate'|'acquiring'|'probation'|'retained'|'retired'
  UNIQUE (source, external_id)
);

-- Keeping the graph reference:
-- discovery_candidates.id is referenced by claims rows with subject_type='track' (UUID aligns).
```

### E.3 Acquisition policy + queue

Not every candidate downloads. Decision factors:

- expected usefulness (relevance confidence from the discovery claim)
- novelty (does the listener's `discovery` profile tolerate this
  territory?)
- storage budget
- artist diversity (don't acquire 10 tracks from one new artist in a
  day)
- existing backlog depth
- current listener fatigue (don't acquire more of a fatigued artist)
- current exploration budget share

The decision maximises **expected information gain**, not download
count.

Priority queue:

| Priority | Source |
|---|---|
| 1 (highest) | favourite artists' new releases |
| 1 | active obsession new releases |
| 2 | adjacent artists, collaborations, graph discoveries |
| 3 (lowest) | experimental discoveries, curiosity experiments |

Downloads happen invisibly (yt-dlp worker, throttled, low bandwidth).
On successful download, the audio is scanned (existing scanner), which
writes `credited_main_on` / `featured_on` claims and a `tracks` row.
The discovery_candidate row transitions to `probation`.

### E.4 Probation

Downloaded music is never trusted immediately. Every acquisition
enters probation. During probation, the session director (D)
occasionally injects probation tracks into normal sessions — the
listener should not feel they are being tested.

Probation is a `tracks.probation_status` column (new), one short
migration:

```sql
ALTER TABLE tracks ADD COLUMN IF NOT EXISTS probation_status TEXT
  DEFAULT 'retained' CHECK (probation_status IN ('probation','retained','retired'));
ALTER TABLE tracks ADD COLUMN IF NOT EXISTS probation_entered_at TIMESTAMPTZ;
CREATE INDEX tracks_probation_idx ON tracks (probation_status) WHERE probation_status = 'probation';
```

Existing library tracks default to `retained`. Newly acquired tracks
are `probation` with `probation_entered_at = NOW()`.

Each probation track accumulates evidence (B.3) over multiple
sessions — single interactions rarely provide enough. Possible
outcomes:

- **retain** — survival threshold met; probation_status → `retained`.
  Associated artist's `longterm` affinity gets a small bump, the
  discovery path that produced this candidate gets reinforced (a meta
  signal for E.5).
- **archive** — kept on disk but hidden from normal sessions; exempt
  from the planner.
- **delete** — file removed, `tracks` row marked `retired`. Library
  is not an ever-growing archive.
- **ignore temporarily** — back to candidate state for re-evaluation
  later; rarer path.

Probation duration adapts to confidence: a candidate discovered via a
trusted path (favourite producer's new signing) gets a longer
probation than a curiosity-experiment candidate.

### E.5 Meta-learning

The discovery system continuously evaluates itself. A periodic job
writes claims back into the graph about which *strategies* and *graph
paths* have produced long-term retainers, vs which consistently retire:
`source_trust` already tunes per-source; meta-learning additionally
tunes per-predicate-path:

- which discovery sources (graph_exploration, release_monitoring,
  scene_exploration, ...) produce long-term favourites?
- which graph paths consistently fail? (e.g. `same_label_as` may be a
  weak edge; down-weight it.)
- which labels repeatedly introduce successful artists?
- which exploration depth performs best?
- which experiments produce the highest information gain?

This meta-learning itself writes back as `source_trust` adjustments
and as tunable per-path-weight constants. Discovery learns how to
discover better, not just what to recommend.

### E.6 Acceptance

- A discovery candidate surfaced via "producer A produced favourite B
  AND new artist C" is auditable: `SELECT raw FROM claims WHERE
  subject_id=$candidateId AND predicate='discovery_candidate'` shows
  the full path.
- A downloaded probation track on which the listener completed 3 plays
  in its first 2 sessions transitions to `retained` automatically.
- A downloaded probation track skipped on every injection retires to
  `retired` after its probation window; file is removed; library does
  not accumulate indefinitely.
- The meta-learning job, run weekly, down-weights a discovery strategy
  (e.g. `scene_exploration`) whose recent candidates have a <20%
  retention rate, observable in `source_trust` deltas or per-path
  weight constants.

### E.7 Replaces

Nothing — E is net new. It consumes A (graph) and writes back into A.
Built on top of the yet-unbuilt yt-dlp worker (`progress.md:29`),
independent of B/C/D.

---

## §F — Phase 4: Image quality (preserved from old v2)

**This section is preserved verbatim from the previous v2 doc.** It is
orthogonal to recommendation; the bad-image problem is a provenance
problem, unrelated to the engine. Ships any time, independent of A–E.

### F.1 Schema

```sql
CREATE TABLE image_candidates (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  entity_type TEXT NOT NULL CHECK (entity_type IN ('artist','album')),
  entity_id UUID NOT NULL,
  source TEXT NOT NULL,
  url TEXT,
  width INTEGER,
  verified BOOLEAN DEFAULT FALSE,
  fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE (entity_type, entity_id, source)
);
CREATE INDEX image_candidates_entity_idx ON image_candidates (entity_type, entity_id);
```

- `artists.image_path` and `albums.artwork_id` remain as the
  "currently preferred" denormalised pointer, written by the selection
  step. Existing queries keep working.

### F.2 Enrichment write

Each image-fetch step (Wikidata, TheAudioDB, Fanart, iTunes, Deezer,
Discogs, Last.fm, Cover Art Archive) writes a `image_candidates` row
even on failure to find one — a "negative" row with `url=NULL` so we
don't re-fetch that source for that entity until the row is aged out.

### F.3 Selection

A selection step (worker job or enrichment sub-step) picks the
preferred URL by tier:

1. Wikidata via MBID (verified, broad coverage) — highest tier.
2. TheAudioDB via MBID.
3. Fanart via MBID.
4. Cover Art Archive (albums) / Deezer (albums) — high-res.
5. iTunes upscaled to 600 — broad coverage fallback.
6. Last.fm — last resort.
7. Wikimedia via name match — excluded (historically wrong; migrations
   cleared these twice).

Tiers are a config table or constants, not magic strings in code.
Selection writes the winner into `artists.image_path` /
`albums.artwork_id`.

### F.4 Re-evaluation

- `image_candidates` rows older than `N` days (config, default 90) are
  eligible for re-fetch. A periodic job re-runs enrichment for stale
  candidates, replacing rows.
- The selector re-runs whenever candidates change. So if a low-tier
  winner was selected and a higher-tier candidate lands later, the
  preferred pointer is upgraded in place.

### F.5 Acceptance

- After re-enrichment, an artist that previously showed a 100×100
  Last.fm thumbnail shows the Wikidata/TheAudioDB image instead.
- Re-running enrichment does not re-fetch sources that already returned
  (negative cache).
- Selection is auditable: `SELECT * FROM image_candidates WHERE
  entity_id = X` shows every candidate considered.

---

## Rollout

Recommended order, each system independently shippable; the old v1
CTE stays until D lands:

1. **A** (knowledge graph + claim_fusion view). Existing queries move
   to the compat views (`track_artists_v2`, `album_artists_v2`); v1
   engine keeps running against the views. MB spine backfill runs in
   the background.
2. **B and E in parallel** (B listener model + evidence writer; E
   acquisition pipeline + yt-dlp worker + probation). Both need only
   A. The evidence writer starts converting play_history into
   evidence; the existing CTE ignores evidence for now.
3. **C** (generators). Built and run in shadow mode alongside the v1
   CTE — both produce chunks; the UI shows v1 chunks, but C's outputs
   are logged for comparison. Generators don't replace v1 reads until
   D ships.
4. **D** (session director). D switches over and the v1 CTE is
   deleted in the same release. Acceptance is the session-feel test
   (D.11).
5. **Phase 4 (image candidates)** at any point. Independent.

No phase is blocked except B/E on A, C on A+B, D on C+B. Phase 4 is
fully independent.

## Retiring v1 — explicit deletion list

On System D ship, this code goes:

- `db.service.ts` `getNextVibeChunk` (~220 lines).
- `recordPlay`'s center-walk (`UPDATE recommendation_batch SET
  seed_track_id = $2 ...`, line ~580–590).
- The `artist_play_count` decay term (line ~829).
- The `W_SAMEART`, `W_ARTSIM`, `W_FEEDBCK`, `W_AUDIO`, `W_RANDOM`
  constants + `local_pool` / `probation_pool` CTEs (lines 717–866).
- The "max 1 per artist per chunk" cap, replaced by D.3's budgets.
- `getVibeChunkFromGenre` (it becomes a thin wrapper over the discovery
  generator; then collapses into D's session-by-genre entry).
- The `feedback` table write paths (`recordSkip`, `recordFeedback`,
  hardDelete insert). The `feedback` table itself is dropped after
  backfill into `evidence`.
- `recommendation_batch_track` exclusion set (replaced by D's
  recent-exclusions set, in-memory per session).
- `albums.artist_id` single-FK pointer (kept as a denormalised
  trigger-maintained column off the fusion view; removed as a *read*
  source).
- `artist_similar` table (compat view retained briefly, then dropped).
- `genre.parent_id` column (replaced by `parent_of` claims; column
  dropped after a backfill claims-migration).

## Out of scope

- **Filesystem reorganisation.** Confirmed out of scope. The bind is
  read-only; the DB is the index; reorganising the FS inverts the
  dependency in the wrong direction.
- **Auth itself.** Schemas are `user_id`-keyed from the start so no
  retrofit is needed later, but building auth is a separate project
  (progress.md #30).
- **A user-facing override UI for MB credits.** Competing claims
  coexist in the graph; resolution at read time is weighted. A future
  UI can show "per MB" vs "per tag" and let the user assert a
  `curated` claim (trust 1.0) that overrides. Out of scope here.
- **Manual artist-group / alias curation UI.** `alias_of` is a graph
  belief; `curated` claims (trust 1.0) override the learned belief
  when human assertion is needed — but the UI to do that is separate.