import { DbService, ListenerBelief } from './db.service.js'; // --------------------------------------------------------------------------- // System C — Candidate Generators // Each generator returns candidates with graph-path explanations. // No scoring — the session director handles ranking. // --------------------------------------------------------------------------- export interface ClaimEdge { subjectType: string; subjectId: string; predicate: string; objectType: string; objectId: string; fusedValue: number; } export interface Candidate { trackId: string; generatorId: string; explanation: ClaimEdge[]; relevance: number; } export interface GeneratorContext { userId: string; seedTrackId: string | null; seedArtistId: string | null; beliefs: ListenerBelief[]; recentExclusions: string[]; toleranceMap: Record; state: { energy: number; lastArtistIds: string[]; lastGenreIds: string[]; context: string | null; noveltyHunger: number; sessionAgeMin: number; }; } export type Generator = (db: DbService, ctx: GeneratorContext) => Promise; const OBJECTIVE_USER = '00000000-0000-0000-0000-000000000000'; // --------------------------------------------------------------------------- // 1. COMFORT — Top artists by longterm affinity > 0.5 // --------------------------------------------------------------------------- async function comfortGenerator(db: DbService, ctx: GeneratorContext): Promise { const topArtists = ctx.beliefs .filter(b => b.profile === 'longterm' && b.entity_type === 'artist' && b.dimension === 'affinity' && b.value > 0.5) .sort((a, b) => b.value - a.value) .slice(0, 20); if (topArtists.length === 0) return []; const artistValueMap = new Map(topArtists.map(b => [b.entity_id, b.value])); const artistIds = topArtists.map(b => b.entity_id); const res = await db.pgClient.query( `SELECT sub.id, sub.artist_id FROM ( SELECT t.id, cf.object_id AS artist_id, ROW_NUMBER() OVER (PARTITION BY cf.object_id ORDER BY cf.fused_value DESC) AS rn 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' AND cf.object_id = ANY($1::uuid[]) AND (cf.user_id = $2 OR cf.user_id = $3) WHERE t.state = 'LIBRARY' AND NOT (t.id = ANY($4::uuid[])) ) sub WHERE sub.rn <= 2 ORDER BY sub.artist_id, sub.rn`, [artistIds, OBJECTIVE_USER, ctx.userId, ctx.recentExclusions] ); return (res.rows as { id: string; artist_id: string }[]).map(row => { const value = artistValueMap.get(row.artist_id) ?? 0.5; return { trackId: row.id, generatorId: 'comfort', explanation: [{ subjectType: 'artist', subjectId: row.artist_id, predicate: 'credited_main_on', objectType: 'track', objectId: row.id, fusedValue: value, }], relevance: value, }; }); } // --------------------------------------------------------------------------- // 2. ADJACENT — Walk graph from seed artist, exclude comfort pool // --------------------------------------------------------------------------- async function adjacentGenerator(db: DbService, ctx: GeneratorContext): Promise { if (!ctx.seedArtistId) return []; const comfortArtistIds = new Set( ctx.beliefs .filter(b => b.profile === 'longterm' && b.entity_type === 'artist' && b.dimension === 'affinity' && b.value > 0.5) .map(b => b.entity_id) ); // Walk: seedArtist -> (credited_main_on|featured_on) -> track -> (credited_main_on|featured_on) -> reachedArtist // cf1 finds tracks where seed artist appears; cf2 finds OTHER artists on those same tracks const reachedRes = await db.pgClient.query( `SELECT DISTINCT cf2.object_id AS artist_id FROM claim_fusion cf1 JOIN claim_fusion cf2 ON cf2.subject_type = 'track' AND cf2.subject_id = cf1.subject_id AND cf2.predicate IN ('credited_main_on', 'featured_on') AND cf2.object_type = 'artist' AND cf2.object_id != $1 AND (cf2.user_id = $2 OR cf2.user_id = $3) WHERE cf1.subject_type = 'track' AND cf1.predicate IN ('credited_main_on', 'featured_on') AND cf1.object_type = 'artist' AND cf1.object_id = $1 AND (cf1.user_id = $2 OR cf1.user_id = $3) LIMIT 30`, [ctx.seedArtistId, OBJECTIVE_USER, ctx.userId] ); const reachedArtistIds = (reachedRes.rows as { artist_id: string }[]) .map(r => r.artist_id) .filter(id => !comfortArtistIds.has(id)); if (reachedArtistIds.length === 0) return []; const trackRes = await db.pgClient.query( `SELECT id FROM ( SELECT DISTINCT t.id 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' AND cf.object_id = ANY($1::uuid[]) AND (cf.user_id = $2 OR cf.user_id = $3) WHERE t.state = 'LIBRARY' AND NOT (t.id = ANY($4::uuid[])) ) sub ORDER BY RANDOM() LIMIT 20`, [reachedArtistIds, OBJECTIVE_USER, ctx.userId, ctx.recentExclusions] ); return (trackRes.rows as { id: string }[]).map(row => ({ trackId: row.id, generatorId: 'adjacent', explanation: [{ subjectType: 'artist', subjectId: ctx.seedArtistId!, predicate: 'credited_main_on', objectType: 'track', objectId: row.id, fusedValue: 0.6, }], relevance: 0.6, })); } // --------------------------------------------------------------------------- // 3. DISCOVERY — Unfamiliar artists via graph edges from trusted artists // --------------------------------------------------------------------------- async function discoveryGenerator(db: DbService, ctx: GeneratorContext): Promise { const trustedIds = ctx.beliefs .filter(b => b.profile === 'longterm' && b.entity_type === 'artist' && b.dimension === 'affinity' && b.value > 0.3) .map(b => b.entity_id); if (trustedIds.length === 0) return []; const noveltyTolerance = ctx.toleranceMap.novelty_tolerance ?? 0.3; const maxCandidates = Math.max(1, Math.floor(10 * noveltyTolerance)); const unfamiliarRes = await db.pgClient.query( `SELECT cf.object_id AS artist_id, MAX(cf.fused_value) AS edge_strength FROM claim_fusion cf WHERE cf.subject_type = 'artist' AND cf.subject_id = ANY($1::uuid[]) AND cf.predicate IN ('same_scene_as', 'same_label_as', 'produced') AND cf.object_type = 'artist' AND NOT EXISTS ( SELECT 1 FROM listener_beliefs lb WHERE lb.user_id = $2 AND lb.entity_type = 'artist' AND lb.entity_id = cf.object_id AND lb.profile IN ('longterm', 'obsession') ) GROUP BY cf.object_id LIMIT 30`, [trustedIds, ctx.userId] ); const unfamiliarRows = unfamiliarRes.rows as { artist_id: string; edge_strength: number }[]; const unfamiliarArtistIds = unfamiliarRows.map(r => r.artist_id); const edgeStrengthMap = new Map(unfamiliarRows.map(r => [r.artist_id, r.edge_strength])); if (unfamiliarArtistIds.length === 0) return []; const trackRes = await db.pgClient.query( `SELECT id, artist_id FROM ( SELECT DISTINCT ON (t.id) t.id, cf.object_id AS artist_id 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' AND cf.object_id = ANY($1::uuid[]) WHERE t.state = 'LIBRARY' AND NOT (t.id = ANY($2::uuid[])) ORDER BY t.id, cf.fused_value DESC NULLS LAST ) sub ORDER BY RANDOM() LIMIT $3`, [unfamiliarArtistIds, ctx.recentExclusions, maxCandidates] ); return (trackRes.rows as { id: string; artist_id: string }[]).map(row => { const relevance = edgeStrengthMap.get(row.artist_id) ?? 0.4; return { trackId: row.id, generatorId: 'discovery', explanation: [{ subjectType: 'artist', subjectId: row.artist_id, predicate: 'credited_main_on', objectType: 'track', objectId: row.id, fusedValue: relevance, }], relevance, }; }); } // --------------------------------------------------------------------------- // 4. DEEP-DIVE — Albums from obsession artists, unplayed tracks first // --------------------------------------------------------------------------- async function deepDiveGenerator(db: DbService, ctx: GeneratorContext): Promise { const obsessedIds = ctx.beliefs .filter(b => b.profile === 'obsession' && b.entity_type === 'artist' && b.dimension === 'affinity' && b.value > 0.3) .map(b => b.entity_id); if (obsessedIds.length === 0) return []; const albumRes = await db.pgClient.query( `SELECT al.id AS album_id, al.artist_id FROM albums al WHERE al.artist_id = ANY($1::uuid[]) ORDER BY al.year ASC NULLS LAST, al.title ASC LIMIT 20`, [obsessedIds] ); const albumRows = albumRes.rows as { album_id: string; artist_id: string }[]; if (albumRows.length === 0) return []; const albumIds = albumRows.map(a => a.album_id); const albumArtistMap = new Map(albumRows.map(a => [a.album_id, a.artist_id])); const obsessionValueMap = new Map( ctx.beliefs .filter(b => b.profile === 'obsession' && b.entity_type === 'artist' && b.dimension === 'affinity' && b.value > 0.3) .map(b => [b.entity_id, b.value]) ); const trackRes = await db.pgClient.query( `SELECT sub.id, sub.album_id FROM ( SELECT t.id, t.album_id, ROW_NUMBER() OVER (PARTITION BY t.album_id ORDER BY t.title ASC) AS rn FROM tracks t WHERE t.album_id = ANY($1::uuid[]) AND t.state = 'LIBRARY' AND NOT (t.id = ANY($2::uuid[])) ) sub WHERE sub.rn <= 5 ORDER BY sub.album_id, sub.rn`, [albumIds, ctx.recentExclusions] ); return (trackRes.rows as { id: string; album_id: string }[]).map(row => { const artistId = albumArtistMap.get(row.album_id) ?? 'unknown'; const relevance = obsessionValueMap.get(artistId) ?? 0.7; return { trackId: row.id, generatorId: 'deep-dive', explanation: [{ subjectType: 'artist', subjectId: artistId, predicate: 'credited_main_on', objectType: 'track', objectId: row.id, fusedValue: relevance, }], relevance, }; }); } // --------------------------------------------------------------------------- // 5. REVIVAL — Stale longterm affinity (last_reinforced > 90 days ago) // --------------------------------------------------------------------------- async function revivalGenerator(db: DbService, ctx: GeneratorContext): Promise { const staleRes = await db.pgClient.query( `SELECT lb.entity_id AS artist_id, lb.value AS affinity FROM listener_beliefs lb WHERE lb.user_id = $1 AND lb.profile = 'longterm' AND lb.entity_type = 'artist' AND lb.dimension = 'affinity' AND lb.value > 0.3 AND lb.last_reinforced_at < NOW() - INTERVAL '90 days' ORDER BY lb.value DESC LIMIT 20`, [ctx.userId] ); const staleArtists = staleRes.rows as { artist_id: string; affinity: number }[]; if (staleArtists.length === 0) return []; const staleArtistIds = staleArtists.map(a => a.artist_id); const affinityMap = new Map(staleArtists.map(a => [a.artist_id, a.affinity])); const trackRes = await db.pgClient.query( `SELECT id, artist_id FROM ( SELECT DISTINCT ON (t.id) t.id, cf.object_id AS artist_id 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' AND cf.object_id = ANY($1::uuid[]) AND (cf.user_id = $2 OR cf.user_id = $3) WHERE t.state = 'LIBRARY' AND NOT (t.id = ANY($4::uuid[])) ORDER BY t.id, cf.fused_value DESC NULLS LAST ) sub ORDER BY RANDOM() LIMIT 20`, [staleArtistIds, OBJECTIVE_USER, ctx.userId, ctx.recentExclusions] ); return (trackRes.rows as { id: string; artist_id: string }[]).map(row => { const relevance = affinityMap.get(row.artist_id) ?? 0.6; return { trackId: row.id, generatorId: 'revival', explanation: [{ subjectType: 'artist', subjectId: row.artist_id, predicate: 'credited_main_on', objectType: 'track', objectId: row.id, fusedValue: relevance, }], relevance, }; }); } // --------------------------------------------------------------------------- // 6. NOVELTY — Recently released tracks by graph-adjacent artists // --------------------------------------------------------------------------- async function noveltyGenerator(db: DbService, ctx: GeneratorContext): Promise { const trustedIds = ctx.beliefs .filter(b => b.entity_type === 'artist' && b.value > 0.3) .map(b => b.entity_id); if (trustedIds.length === 0) return []; const res = await db.pgClient.query( `SELECT id FROM ( SELECT DISTINCT t.id, t.release_date FROM tracks t JOIN claim_fusion cf_edge ON cf_edge.subject_type = 'artist' AND cf_edge.subject_id = ANY($2::uuid[]) AND cf_edge.predicate IN ('same_scene_as', 'same_label_as', 'produced') AND cf_edge.object_type = 'artist' JOIN claim_fusion cf_track ON cf_track.subject_type = 'track' AND cf_track.subject_id = t.id AND cf_track.predicate IN ('credited_main_on', 'featured_on') AND cf_track.object_type = 'artist' AND cf_track.object_id = cf_edge.object_id WHERE t.release_date IS NOT NULL AND t.release_date >= NOW() - INTERVAL '60 days' AND t.state = 'LIBRARY' AND NOT (t.id = ANY($1::uuid[])) ) sub ORDER BY release_date DESC LIMIT 20`, [ctx.recentExclusions.length > 0 ? ctx.recentExclusions : ['00000000-0000-0000-0000-000000000000'], trustedIds] ); return res.rows.map((row: { id: string }) => ({ trackId: row.id, generatorId: 'novelty', relevance: 0.5, explanation: [{ subjectType: 'track', subjectId: row.id, predicate: 'release_date', objectType: 'date', objectId: 'recent', fusedValue: 0.5, }], })); } // --------------------------------------------------------------------------- // 7. EXPERIMENTAL — Genres with high network distance from favourites // --------------------------------------------------------------------------- async function experimentalGenerator(db: DbService, ctx: GeneratorContext): Promise { const favArtistIds = ctx.beliefs .filter(b => b.entity_type === 'artist' && b.value > 0.4) .map(b => b.entity_id); if (favArtistIds.length === 0) return []; const favGenreIds = ctx.beliefs .filter(b => b.entity_type === 'genre' && b.value > 0.2) .map(b => b.entity_id); const result = await db.pgClient.query( `WITH unfamiliar_genres AS ( SELECT g.id, g.name, (SELECT COUNT(*) FROM track_genre tg2 WHERE tg2.genre_id = g.id) AS track_count FROM genre g WHERE NOT (g.id = ANY($1::uuid[])) AND EXISTS (SELECT 1 FROM track_genre tg WHERE tg.genre_id = g.id) ORDER BY RANDOM() LIMIT 3 ), candidate_tracks AS ( SELECT DISTINCT t.id, tg.genre_id FROM tracks t JOIN track_genre tg ON tg.track_id = t.id JOIN unfamiliar_genres ug ON ug.id = tg.genre_id WHERE t.state = 'LIBRARY' AND NOT (t.id = ANY($2::uuid[])) LIMIT 30 ) SELECT ct.id, ct.genre_id FROM candidate_tracks ct ORDER BY RANDOM() LIMIT 6`, [ favGenreIds.length > 0 ? favGenreIds : ['00000000-0000-0000-0000-000000000000'], ctx.recentExclusions.length > 0 ? ctx.recentExclusions : ['00000000-0000-0000-0000-000000000000'], ] ); return result.rows.map((row: { id: string; genre_id: string }) => ({ trackId: row.id, generatorId: 'experimental', relevance: 0.2, explanation: [{ subjectType: 'genre', subjectId: row.genre_id, predicate: 'belongs_to_genre', objectType: 'track', objectId: row.id, fusedValue: 0.2, }], })); } // --------------------------------------------------------------------------- // 8. CONTEXTUAL — Contextual profile beliefs // --------------------------------------------------------------------------- async function contextualGenerator(db: DbService, ctx: GeneratorContext): Promise { if (!ctx.state.context) return []; const contextualBeliefs = await db.getListenerBeliefs({ userId: ctx.userId, profile: 'contextual', limit: 30, orderBy: 'value', order: 'DESC', }); const targetBeliefs = contextualBeliefs.filter(b => b.entity_type === 'artist' && b.value > 0.2); const targetArtistIds = targetBeliefs.map(b => b.entity_id); const targetValueMap = new Map(targetBeliefs.map(b => [b.entity_id, b.value])); if (targetArtistIds.length === 0) return []; const trackRes = await db.pgClient.query( `SELECT id, artist_id FROM ( SELECT DISTINCT ON (t.id) t.id, cf.object_id AS artist_id 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' AND cf.object_id = ANY($1::uuid[]) AND (cf.user_id = $2 OR cf.user_id = $3) WHERE t.state = 'LIBRARY' AND NOT (t.id = ANY($4::uuid[])) ORDER BY t.id, cf.fused_value DESC NULLS LAST ) sub ORDER BY RANDOM() LIMIT 15`, [targetArtistIds, OBJECTIVE_USER, ctx.userId, ctx.recentExclusions] ); return (trackRes.rows as { id: string; artist_id: string }[]).map(row => { const relevance = targetValueMap.get(row.artist_id) ?? 0.5; return { trackId: row.id, generatorId: 'contextual', explanation: [{ subjectType: 'artist', subjectId: row.artist_id, predicate: 'credited_main_on', objectType: 'track', objectId: row.id, fusedValue: relevance, }], relevance, }; }); } // --------------------------------------------------------------------------- // All generators, ordered by priority (comfort first, experimental last) // --------------------------------------------------------------------------- export const ALL_GENERATORS: Generator[] = [ comfortGenerator, adjacentGenerator, deepDiveGenerator, revivalGenerator, discoveryGenerator, noveltyGenerator, contextualGenerator, experimentalGenerator, ];