import { DbService } from './db.service.js'; export interface DiscoveryCandidate { id: string; source: string; externalId: string; title: string | null; artistCredit: unknown; notes: unknown; status: string; relevance: number; explanation: string; } export class DiscoveryService { constructor(private db: DbService) {} // --------------------------------------------------------------- // E.1 — Graph exploration: walk the graph beyond the library // --------------------------------------------------------------- async walkGraphForDiscovery(userId: string): Promise { const beliefs = await this.db.getListenerBeliefs({ userId, profile: 'longterm', entityType: 'artist', dimension: 'affinity', limit: 100, orderBy: 'value', order: 'DESC', }); const highAffinity = beliefs.filter((b) => b.value > 0.3); let newCount = 0; for (const belief of highAffinity) { const candidates = await this.db.pgClient.query<{ candidate_artist_id: string }>( `SELECT cf.object_id AS candidate_artist_id FROM claim_fusion cf WHERE cf.subject_id = $1::uuid AND cf.predicate IN ('same_scene_as', 'featured_on') AND cf.object_type = 'artist' AND NOT EXISTS ( SELECT 1 FROM tracks t JOIN claim_fusion cf2 ON cf2.subject_id = t.id WHERE cf2.object_id = cf.object_id AND cf2.predicate = 'credited_main_on' ) LIMIT 20`, [belief.entity_id] ); for (const row of candidates.rows) { const dcRes = await this.db.pgClient.query<{ id: string }>( `INSERT INTO discovery_candidates (source, external_id, artist_credit, notes) VALUES ($1, $2, $3, $4) ON CONFLICT (source, external_id) DO NOTHING RETURNING id`, [ 'graph_exploration', row.candidate_artist_id, JSON.stringify([{ artist_id: row.candidate_artist_id }]), JSON.stringify({ discovery_source: 'graph_exploration', path: [ { entity_id: belief.entity_id, predicate: 'affinity_source', profile: 'longterm', affinity: belief.value, }, { entity_id: row.candidate_artist_id, predicate: 'same_scene_as', }, ], source_artist_belief_id: belief.entity_id, }), ] ); if (dcRes.rows.length === 0) continue; const dcId = dcRes.rows[0].id; const relevance = Math.min(belief.value, 0.8); await this.db.upsertClaim({ subject_type: 'track', subject_id: dcId, predicate: 'discovery_candidate', object_type: 'artist', object_id: row.candidate_artist_id, source: 'graph_exploration', confidence: relevance, raw: { discovery_source: 'graph_exploration', path: [ { entity_id: belief.entity_id, relationship: 'affinity_source', belief_value: belief.value }, { entity_id: row.candidate_artist_id, relationship: 'same_scene_as' }, ], }, }); newCount++; } } return newCount; } // --------------------------------------------------------------- // E.3 — Evaluate discovery candidates for acquisition // --------------------------------------------------------------- async evalCandidates( userId: string, limit?: number ): Promise<{ candidateId: string; shouldAcquire: boolean; reason: string }[]> { const cap = limit ?? 20; const results: { candidateId: string; shouldAcquire: boolean; reason: string }[] = []; const candidates = await this.db.pgClient.query( `SELECT * FROM discovery_candidates WHERE status = 'candidate' ORDER BY first_seen_at ASC LIMIT $1`, [cap] ); const backlogRes = await this.db.pgClient.query( `SELECT COUNT(*)::int AS cnt FROM discovery_candidates WHERE status = 'acquiring'` ); let backlog = backlogRes.rows[0]?.cnt as number ?? 0; for (const row of candidates.rows) { const claimRes = await this.db.pgClient.query<{ object_id: string; fused_value: number }>( `SELECT object_id, fused_value FROM claim_fusion WHERE subject_type = 'track' AND subject_id = $1::uuid AND predicate = 'discovery_candidate' LIMIT 1`, [row.id] ); const relevance = claimRes.rows[0]?.fused_value ?? 0; const candidateArtistId = claimRes.rows[0]?.object_id; const noveltyBeliefs = await this.db.getListenerBeliefs({ userId, profile: 'discovery', entityType: 'artist', entityId: candidateArtistId, dimension: 'tolerance', limit: 1, }); const tolerance = noveltyBeliefs.length > 0 ? noveltyBeliefs[0].value : 0.5; let artistCount = 0; if (candidateArtistId) { const acRes = await this.db.pgClient.query( `SELECT COUNT(*)::int AS cnt FROM discovery_candidates dc JOIN claims c ON c.subject_id = dc.id WHERE dc.status = 'acquiring' AND c.predicate = 'discovery_candidate' AND c.object_id = $1::uuid`, [candidateArtistId] ); artistCount = acRes.rows[0]?.cnt as number ?? 0; } const shouldAcquire = relevance > 0.3 && tolerance > 0.2 && backlog < 20 && artistCount < 3; let reason: string; if (shouldAcquire) { await this.db.pgClient.query( `UPDATE discovery_candidates SET status = 'acquiring', last_eval_at = NOW() WHERE id = $1`, [row.id] ); backlog++; reason = 'meets criteria'; } else { if (relevance <= 0.3) reason = 'relevance too low'; else if (tolerance <= 0.2) reason = 'novelty tolerance exceeded'; else if (backlog >= 20) reason = 'backlog full'; else if (artistCount >= 3) reason = 'artist diversity limit'; else reason = 'unknown'; await this.db.pgClient.query( `UPDATE discovery_candidates SET status = 'retired', last_eval_at = NOW() WHERE id = $1`, [row.id] ); } results.push({ candidateId: row.id, shouldAcquire, reason, }); } return results; } // --------------------------------------------------------------- // E.4 — Probation lifecycle // --------------------------------------------------------------- async evalProbation(trackId: string): Promise<'retained' | 'retired' | 'probation'> { const completedRes = await this.db.pgClient.query( `SELECT COUNT(*)::int AS cnt FROM evidence WHERE entity_type = 'track' AND entity_id = $1 AND signal = 'playback_completed'`, [trackId] ); const completedPlays = completedRes.rows[0]?.cnt as number ?? 0; const skipRes = await this.db.pgClient.query( `SELECT COUNT(*)::int AS cnt FROM evidence WHERE entity_type = 'track' AND entity_id = $1 AND signal = 'skip_quick'`, [trackId] ); const skips = skipRes.rows[0]?.cnt as number ?? 0; const trackRes = await this.db.pgClient.query<{ probation_entered_at: Date | null }>( `SELECT probation_entered_at FROM tracks WHERE id = $1`, [trackId] ); const probTrack = trackRes.rows[0]; if (completedPlays >= 3) { await this.db.pgClient.query( `UPDATE tracks SET probation_status = 'retained' WHERE id = $1`, [trackId] ); const claimRes = await this.db.pgClient.query<{ source: string }>( `SELECT source FROM claims WHERE subject_type = 'track' AND subject_id = $1 AND predicate = 'discovery_candidate' LIMIT 1`, [trackId] ); if (claimRes.rows[0]) { await this.db.pgClient.query( `UPDATE source_trust SET trust = LEAST(1.0, trust + 0.05) WHERE key = $1`, [claimRes.rows[0].source] ); } return 'retained'; } const daysSinceProbation = probTrack?.probation_entered_at ? (Date.now() - new Date(probTrack.probation_entered_at).getTime()) / (1000 * 86400) : 0; if (completedPlays === 0 && skips >= 3 && daysSinceProbation > 7) { await this.db.pgClient.query( `UPDATE tracks SET probation_status = 'retired' WHERE id = $1`, [trackId] ); return 'retired'; } return 'probation'; } async sweepProbation(): Promise<{ retained: number; retired: number }> { const res = await this.db.pgClient.query( `SELECT id FROM tracks WHERE probation_status = 'probation'` ); let retained = 0; let retired = 0; for (const row of res.rows) { const result = await this.evalProbation(row.id as string); if (result === 'retained') retained++; else if (result === 'retired') retired++; } return { retained, retired }; } // --------------------------------------------------------------- // E.5 — Meta-learning stub // --------------------------------------------------------------- async runMetaLearning(): Promise { const res = await this.db.pgClient.query( `SELECT c.source, COUNT(*)::int AS cnt FROM claims c JOIN tracks t ON t.id = c.subject_id WHERE c.predicate = 'discovery_candidate' AND t.probation_status = 'retained' GROUP BY c.source ORDER BY cnt DESC` ); console.log('[MetaLearning] Discovery source retention counts:', JSON.stringify(res.rows)); } }