54 KiB
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_candidatestable). 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
- Music is a graph of entities + probabilistic claims, not a folder of files or a table of flat similarity rows.
- Truth is probabilistic fusion. Every claim is evidence, not fact. Conflicts coexist; resolution happens at read time, weighted by source trust and recency.
- 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.
- Aliasing is continuous and evolves with listening behavior. A
alias_ofclaim is a belief with a confidence value, reinforced when the listener plays both aliases back-to-back in a session. - Listener identity is multidimensional, keyed on
user_idfrom the start (single user today, multi-user tomorrow — no retrofit). Multiple profiles coexist: long-term, current obsession, discovery, negative, forgotten, contextual. - 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.
- 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.
- 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
-- 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
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:
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:
- MB spine writer (in the worker enrichment pipeline): when
lookupRecordingresolves a recording MBID, fetch the fullartist-credit(not just the first entry — extend the MB client). Write onecredited_main_onclaim (artist = first credit) and onefeatured_onclaim per additional credit, withsource='mb',confidence=1.0,evidence_at=NOW(). For release-group MBIDs on albums, writecredited_main_on_album/featured_on_albumanalogously. For artist-relation ARs (member of,collaborations,vocal/instrument), writemember_of/featured_onclaims. 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. - Discogs writer:
discogs_idalready set on artists today; extend to writecredited_main_on_album/same_label_asclaims from the release's label and artist credits,source='discogs',confidence=0.7. - Last.fm writer: existing
artist_similarfetcher writessame_scene_asclaims (source='lastfm',confidence=match/100) instead of theartist_similartable. Last.fm tags writebelongs_to_genreclaims withconfidence=tag.count/100. - Tag-derived writer (scanner fallback): when no MBID, the
existing
parseArtistsFromMetadataheuristic writescredited_main_on/featured_onclaims withsource='tag',confidence=1.0(the trust weight, low at 0.30, is what dims it).resolveOrCreateArtistwrites the entity row; the claim carries the entity, not the name string. Re-keying when MB later resolves is an UPSERT, not an overwrite. - 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 writesame_scene_as(confidence 0.3); back-to-back play of two artists within a session writesalias_of(confidence 0.2). These are evidence, not conclusions; they fuse with the objectivealias_ofclaim from MB (if any) at read time. Behavioral claims decay bylast_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 acredited_main_onclaim from MB for "Madvillain" (if MB credits Madvillain) AND acredited_main_onclaim from the tag for "MF DOOM". Both coexist. The fusion view, weighted by trust (MB 0.90tag 0.30), shows "Madvillain" as the higher-confidence main credit.
- An album tagged
albumartist="A & B"has twocredited_main_on_albumclaims — A from tags, A and B from MB if MB credits both. The album appears on both A's and B's artist pages viaalbum_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-genrepath reaches tracks through thebelongs_to_genreclaims + hierarchical rollup, not exact-id match.
A.8 Risks
- Fusion reasoning cost. Every read now resolves a vote across
multiple claims. Mitigation:
claim_fusionmaterialised view, refreshed onclaimsinsert/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
-- 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' |
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
longtermaffinity belief for that track exists atvalue=+0.05,confidence=0.05. - After 5 completed replays within a week, that track's
longtermaffinity 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
longtermaffinity to ~50% of peak; it appears in theforgottenderived profile. SELECT value FROM listener_beliefs WHERE user_id=$1 AND profile='obsession' AND entity_type='artist' ORDER BY value DESC LIMIT 5returns 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
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.
- Comfort — reads
longtermaffinity beliefs, picks tracks by artist with affinity > +0.5, fuses withcredited_main_on/featured_onclaims to find tracks by those artists. Goal: maintain satisfaction. - 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. - Discovery — picks tracks whose artists have no
longterm/obsessionbelief (truly unfamiliar), filtered to those with at least one graph edge to a trusted artist (same_scene_as,same_label_as,producedby a producer who produced a favourite). Reads thediscoveryprofile'snovelty_toleranceto set how many to return. - Deep-dive — prioritises complete albums. Picks an album owned
(via
album_artists_v2) by an artist withobsessionaffinity and returns overlooked tracks (those with lowfamiliaritybelief) in album order. Prefers tracks with no play history. - Revival — reads the
forgottenderived profile, returns tracks whoselongtermaffinity is high butlast_reinforced_atis old (> 90 days). Time window is adaptive: nostalgia horizon scales with how established the longterm profile is. - Novelty — queries for tracks with
release_datein the last 60 days whose artists share asame_label_as/same_scene_asedge with a favourite, OR aproducededge from a known producer. Most recent first, gated bydiscoveryprofile tolerance. - Experimental — deliberately challenges current assumptions.
Finds genres with very few
longtermbeliefs 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). - Contextual — reads the
contextualprofile. If the listener state has a context tag (coding / driving / sleeping), returns tracks whoselistener_beliefscontext 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
explanationarray (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_onfrom DOOM tracks (i.e. Madlib's other projects) and viamember_offrom 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'sartist_sim > 0gate.
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:
-- 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):
{
"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)
trackfatigue: a track played in the last hourexp(-(0)/30d)=1; played yesterdayexp(-(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.artistfatigue: rolled up from track fatigue over the artist's tracks; collapses MF DOOM / Madvillain / Viktor Vaughn iff theiralias_ofclaims 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/vocalistfatigue: 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:
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):
{
"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=trueflag. - 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":
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:
- Graph exploration — walk the graph beyond the library. For each
favourite artist (per
longtermbeliefs), followfeatured_on/member_of/produced/same_label_as/same_scene_asedges to artists not in the library. Each traversal is a discovery path candidate. - Release monitoring — monitor favourite artists, related artists (graph adjacents), labels, and producers for new releases. New releases become discovery candidates at high priority.
- 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.
- Temporal exploration — search different musical eras for forgotten classics, overlooked releases, albums that became influential years later.
- Relationship expansion — instead of "people also listen to", prefer structural relationships: same producer, same composer, live band members, guest vocalists, touring partners, soundtrack contributors.
- 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:
-- 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:
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
discoveryprofile 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:
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'slongtermaffinity 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,
tracksrow markedretired. 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_asmay 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
retainedautomatically. - A downloaded probation track skipped on every injection retires to
retiredafter 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 insource_trustdeltas 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
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_pathandalbums.artwork_idremain 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:
- Wikidata via MBID (verified, broad coverage) — highest tier.
- TheAudioDB via MBID.
- Fanart via MBID.
- Cover Art Archive (albums) / Deezer (albums) — high-res.
- iTunes upscaled to 600 — broad coverage fallback.
- Last.fm — last resort.
- 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_candidatesrows older thanNdays (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 = Xshows every candidate considered.
Rollout
Recommended order, each system independently shippable; the old v1 CTE stays until D lands:
- 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. - 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.
- 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.
- D (session director). D switches over and the v1 CTE is deleted in the same release. Acceptance is the session-feel test (D.11).
- 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.tsgetNextVibeChunk(~220 lines).recordPlay's center-walk (UPDATE recommendation_batch SET seed_track_id = $2 ..., line ~580–590).- The
artist_play_countdecay term (line ~829). - The
W_SAMEART,W_ARTSIM,W_FEEDBCK,W_AUDIO,W_RANDOMconstants +local_pool/probation_poolCTEs (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
feedbacktable write paths (recordSkip,recordFeedback, hardDelete insert). Thefeedbacktable itself is dropped after backfill intoevidence. recommendation_batch_trackexclusion set (replaced by D's recent-exclusions set, in-memory per session).albums.artist_idsingle-FK pointer (kept as a denormalised trigger-maintained column off the fusion view; removed as a read source).artist_similartable (compat view retained briefly, then dropped).genre.parent_idcolumn (replaced byparent_ofclaims; 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
curatedclaim (trust 1.0) that overrides. Out of scope here. - Manual artist-group / alias curation UI.
alias_ofis a graph belief;curatedclaims (trust 1.0) override the learned belief when human assertion is needed — but the UI to do that is separate.