diff --git a/backend/src/services/session-director.service.ts b/backend/src/services/session-director.service.ts index 296c5e0..4e07647 100644 --- a/backend/src/services/session-director.service.ts +++ b/backend/src/services/session-director.service.ts @@ -4,6 +4,7 @@ import { AUDIO_PREFERENCE_BUCKETS } from '../db/types.js'; export interface FatigueState { artist: Map; + album: Map; genre: Map; language: Map; track: Map; @@ -17,9 +18,42 @@ export interface RecentPlay { bpm: number | null; energy: number | null; language: string | null; - vocal: boolean; + /** null means audio analysis is unavailable; it is not a vocal track. */ + vocal: boolean | null; decade: number | null; valence: number | null; + albumId?: string | null; + producerIds?: string[]; + labelIds?: string[]; +} + +/** Metadata used only by the planner. A missing value is deliberately not + * counted as satisfying a diversity target. */ +export interface CandidateConstraintMetadata { + artistId?: string; + albumId?: string; + genreId?: string; + language?: string; + instrumental?: boolean; + favorite?: boolean; + newArtist?: boolean; + energy?: number; + bpm?: number; + valence?: number; + decade?: number; + producerIds?: string[]; + labelIds?: string[]; +} + +export interface ConstraintRelaxation { + constraint: string; + stage: 'soft_budget' | 'arc_precision' | 'freshness'; + reason: string; +} + +export interface ConstrainedPlanResult { + plan: Candidate[]; + relaxations: ConstraintRelaxation[]; } export interface DiversityBudget { @@ -27,6 +61,10 @@ export interface DiversityBudget { budgetShare: number; horizonMin: number; spent: number; + /** Exact completed-play counts in this budget's configured time horizon. + * These are planner-only projections, not API contract fields. */ + historicalTotal?: number; + historicalValues?: Map; } export interface RepetitionState { @@ -34,9 +72,19 @@ export interface RepetitionState { recentArtistIds: Set; } +export interface AntiLoopSignal { + dimension: string; + /** Every dominant identity. Producer/label candidates are excluded when + * they match any of these values, not merely the first claim. */ + values: string[]; +} + export interface PlanBuildOptions { /** Tracks already exposed during this Vibe session; they are ineligible. */ excludedTrackIds?: Iterable; + /** Unplayed queue tail retained during a replan. It is part of the same + * sequence and must consume hard caps and diversity budgets. */ + retainedPlan?: Candidate[]; } const W_ENJOY = 1.0; @@ -45,6 +93,29 @@ const W_DIVERSITY = 0.3; const W_ENTROPY = 0.2; const W_REPETITION = 0.5; const PLAN_SIZE = 20; +const MAX_ARTIST_PER_PLAN = 2; +const MAX_ALBUM_PER_40_TRACKS = 3; +const ALBUM_HORIZON_TRACKS = 40; + +/** + * Producer/label relationships live on artist nodes in the fused graph, not + * on track nodes. The graph represents both directions of a relationship, + * so a track inherits every adjacent lineage identity from its resolved main + * artist regardless of which artist was recorded as the edge subject. + */ +function lineageIdsSql(predicate: 'produced' | 'same_label_as', artistAlias: string): string { + return `COALESCE(( + SELECT array_agg(DISTINCT CASE + WHEN cf.subject_id = ${artistAlias}.artist_id THEN cf.object_id::text + ELSE cf.subject_id::text + END) + FROM claim_fusion cf + WHERE cf.subject_type = 'artist' + AND cf.object_type = 'artist' + AND cf.predicate = '${predicate}' + AND (cf.subject_id = ${artistAlias}.artist_id OR cf.object_id = ${artistAlias}.artist_id) + ), ARRAY[]::text[])`; +} /** * Preserve the existing queue, append only genuinely new candidates, and @@ -70,7 +141,248 @@ export function mergeUniquePlan( return merged; } +function valuesForDimension( + metadata: CandidateConstraintMetadata | undefined, + dimension: string, +): string[] { + if (!metadata) return []; + switch (dimension) { + case 'artist': return metadata.artistId ? [metadata.artistId] : []; + case 'album': return metadata.albumId ? [metadata.albumId] : []; + case 'genre': return metadata.genreId ? [metadata.genreId] : []; + case 'language': return metadata.language ? [metadata.language] : []; + case 'instrumental': return metadata.instrumental === undefined ? [] : [String(metadata.instrumental)]; + case 'new_artist': return metadata.newArtist ? ['true'] : []; + case 'favorite': return metadata.favorite ? ['true'] : []; + case 'vocal': return metadata.instrumental === undefined ? [] : [String(!metadata.instrumental)]; + case 'producer': return metadata.producerIds ?? []; + case 'label': return metadata.labelIds ?? []; + case 'energy': return metadata.energy === undefined ? [] : [String(Math.min(3, Math.floor(metadata.energy / 0.25)))]; + case 'bpm': return metadata.bpm === undefined ? [] : [String(Math.floor(metadata.bpm / 20))]; + case 'decade': return metadata.decade === undefined ? [] : [String(metadata.decade)]; + case 'mood': return metadata.valence === undefined ? [] : [String(metadata.valence > 0.5)]; + default: return []; + } +} + +function valueForDimension(metadata: CandidateConstraintMetadata | undefined, dimension: string): string | undefined { + return valuesForDimension(metadata, dimension)[0]; +} + +/** Most common known value, but only when it is actually concentrated. */ +export function dominantRecentValue(recent: RecentPlay[], dimension: string): string | undefined { + const counts = new Map(); + for (const play of recent) { + const values = dimension === 'producer' ? play.producerIds + : dimension === 'label' ? play.labelIds + : [dimension === 'artist' ? play.artistId + : dimension === 'album' ? play.albumId + : dimension === 'genre' ? play.genreId + : dimension === 'language' ? play.language + : dimension === 'vocal' && play.vocal != null ? String(play.vocal) + : dimension === 'energy' && play.energy != null ? String(Math.min(3, Math.floor(play.energy / 0.25))) + : dimension === 'bpm' && play.bpm != null ? String(Math.floor(play.bpm / 20)) + : dimension === 'mood' && play.valence != null ? String(play.valence > 0.5) + : dimension === 'decade' && play.decade != null ? String(play.decade) : undefined]; + for (const value of values ?? []) { + if (value) counts.set(value, (counts.get(value) ?? 0) + 1); + } + } + let dominant: string | undefined; + let max = 0; + for (const [value, count] of counts) { + if (count > max) { dominant = value; max = count; } + } + return max >= 2 ? dominant : undefined; +} + +function metadataFromRecentPlay(play: RecentPlay): CandidateConstraintMetadata { + return { + artistId: play.artistId ?? undefined, + albumId: play.albumId ?? undefined, + genreId: play.genreId ?? undefined, + language: play.language ?? undefined, + // RecentPlay stores vocal rather than instrumental. Preserve missing audio + // analysis as unknown so it neither triggers nor dodges vocal rules. + instrumental: play.vocal == null ? undefined : !play.vocal, + energy: play.energy ?? undefined, + bpm: play.bpm ?? undefined, + valence: play.valence ?? undefined, + decade: play.decade ?? undefined, + producerIds: play.producerIds ?? [], + labelIds: play.labelIds ?? [], + }; +} + +/** + * The constraint layer is intentionally pure. Ranking supplies its candidate + * order; this layer chooses a feasible sequence and returns every soft rule it + * had to relax. Hard session exclusions are applied before this function and + * are never relaxed here. + */ +export function selectConstrainedSequence(params: { + candidates: Candidate[]; + slots: { position: number; role: string }[]; + metadata: Map; + budgets: DiversityBudget[]; + roleToGeneratorIds: (role: string) => string[]; + /** Already committed queue entries which remain in front of this refill. */ + retainedPlan?: Candidate[]; + /** Completed plays, newest first, used only for the rolling 40-track album cap. */ + albumHistory?: CandidateConstraintMetadata[]; + explicitIntent?: boolean; + loopDimension?: string | null; + loopedValues?: Iterable; + /** @deprecated use loopedValues; retained for callers during migration. */ + loopedValue?: string; +}): ConstrainedPlanResult { + const { + candidates, slots, metadata, budgets, roleToGeneratorIds, retainedPlan = [], + albumHistory = [], explicitIntent = false, loopDimension, loopedValues = [], loopedValue, + } = params; + const relaxations: ConstraintRelaxation[] = []; + const selected: Candidate[] = []; + const selectedIds = new Set(retainedPlan.map(candidate => candidate.trackId)); + const counts = new Map>(); + const budgetByDimension = new Map(budgets.map(b => [b.dimension, b])); + const lowerDimensions = ['instrumental', 'new_artist', 'favorite']; + const softDimensions = ['artist', 'genre', 'language']; + const planLength = retainedPlan.length + slots.length; + const loopedValueSet = new Set([...loopedValues, ...(loopedValue ? [loopedValue] : [])]); + // Candidate ranking is already stable. Build each role's preferred pool + // once, preserving that order, rather than sorting the whole pool for every + // slot in a long plan. + const preferredPoolCache = new Map(); + const orderedForRole = (role: string): Candidate[] => { + const preferred = roleToGeneratorIds(role); + const key = preferred.join('\u0000'); + const cached = preferredPoolCache.get(key); + if (cached) return cached; + const preferredIds = new Set(preferred); + const ordered = [ + ...candidates.filter(candidate => preferredIds.has(candidate.generatorId)), + ...candidates.filter(candidate => !preferredIds.has(candidate.generatorId)), + ]; + preferredPoolCache.set(key, ordered); + return ordered; + }; + + const count = (dimension: string, value: string | undefined) => + value ? (counts.get(dimension)?.get(value) ?? 0) : 0; + const increment = (dimension: string, values: Iterable) => { + const knownValues = [...values]; + if (knownValues.length === 0) return; + const dimensionCounts = counts.get(dimension) ?? new Map(); + for (const value of knownValues) { + dimensionCounts.set(value, (dimensionCounts.get(value) ?? 0) + 1); + } + counts.set(dimension, dimensionCounts); + }; + const historicalCount = (dimension: string, value: string) => + budgetByDimension.get(dimension)?.historicalValues?.get(value) ?? 0; + const historicalTotal = (dimension: string) => + budgetByDimension.get(dimension)?.historicalTotal ?? 0; + const target = (dimension: string) => { + const budget = budgetByDimension.get(dimension); + if (!budget || explicitIntent) return 0; + // Lower budgets are projected over the actual completed-play sample in the + // configured horizon plus the retained/replacement sequence. `spent` is a + // diagnostic share; counts keep this calculation exact. + const desired = Math.ceil(budget.budgetShare * (historicalTotal(dimension) + planLength)); + return Math.max(0, desired - historicalCount(dimension, 'true')); + }; + const lowerDeficit = (dimension: string) => Math.max(0, target(dimension) - count(dimension, 'true')); + const exceedsUpperLimit = (dimension: string, values: string[]) => { + const budget = budgetByDimension.get(dimension); + if (!budget || explicitIntent || values.length === 0) return false; + const denominator = historicalTotal(dimension) + planLength; + // Preserve a non-zero allowance for a configured category in a short plan. + const limit = Math.max(1, Math.floor(budget.budgetShare * denominator)); + return values.some(value => historicalCount(dimension, value) + count(dimension, value) + 1 > limit); + }; + + // Retained tracks are already exposed to the client, so they must consume + // every sequence budget before a replacement is selected. + for (const retained of retainedPlan) { + const retainedMetadata = metadata.get(retained.trackId); + for (const dimension of ['artist', 'genre', 'language', ...lowerDimensions]) { + increment(dimension, valuesForDimension(retainedMetadata, dimension)); + } + } + // Album is a rolling 40-track horizon, not merely a 20-track plan. Unknown + // identities are deliberately not collapsed into one synthetic album: that + // would reject unrelated tracks. Known IDs can never evade this cap. + // Leave room for the retained/replacement sequence so the window is exactly + // forty tracks at the end of this plan, rather than incorrectly treating a + // 20-track tail as a 60-track lookback. + for (const history of albumHistory.slice(0, Math.max(0, ALBUM_HORIZON_TRACKS - planLength))) { + increment('album', valuesForDimension(history, 'album')); + } + for (const retained of retainedPlan) { + increment('album', valuesForDimension(metadata.get(retained.trackId), 'album')); + } + + for (let position = 0; position < slots.length; position++) { + const preferred = roleToGeneratorIds(slots[position].role); + const ordered = orderedForRole(slots[position].role); + let chosen: Candidate | undefined; + let relaxedSoft = false; + let relaxedArc = false; + + for (let pass = 0; pass < 3 && !chosen; pass++) { + // pass 0: all soft constraints + arc role; pass 1: soft budgets/loop; + // pass 2: permit an arc-source fallback. Hard sequence caps remain. + const relaxSoft = pass >= 1; + const relaxArc = pass >= 2; + for (const candidate of ordered) { + if (selectedIds.has(candidate.trackId)) continue; + const m = metadata.get(candidate.trackId); + const artist = valueForDimension(m, 'artist'); + const album = valueForDimension(m, 'album'); + if (artist && count('artist', artist) >= MAX_ARTIST_PER_PLAN) continue; + if (album && count('album', album) >= MAX_ALBUM_PER_40_TRACKS) continue; + if (!relaxArc && !preferred.includes(candidate.generatorId)) continue; + + if (!relaxSoft && !explicitIntent) { + if (softDimensions.some(d => exceedsUpperLimit(d, valuesForDimension(m, d)))) continue; + if (loopDimension && valuesForDimension(m, loopDimension).some(value => loopedValueSet.has(value))) continue; + const remaining = slots.length - position; + const deficits = lowerDimensions.filter(d => lowerDeficit(d) > 0); + if (deficits.length > 0 && remaining <= deficits.reduce((sum, d) => sum + lowerDeficit(d), 0)) { + if (!deficits.some(d => valuesForDimension(m, d).includes('true'))) continue; + } + } + chosen = candidate; + relaxedSoft = relaxSoft; + relaxedArc = relaxArc; + break; + } + } + if (!chosen) break; + if (relaxedSoft && !relaxations.some(r => r.stage === 'soft_budget')) { + relaxations.push({ constraint: loopDimension ?? 'diversity_budget', stage: 'soft_budget', reason: 'eligible inventory could not satisfy projected soft constraints' }); + } + if (relaxedArc && !relaxations.some(r => r.stage === 'arc_precision')) { + relaxations.push({ constraint: 'arc_source', stage: 'arc_precision', reason: 'no hard-feasible candidate matched the requested arc slot' }); + } + selected.push(chosen); + selectedIds.add(chosen.trackId); + const m = metadata.get(chosen.trackId); + for (const dimension of ['artist', 'album', 'genre', 'language', ...lowerDimensions]) { + increment(dimension, valuesForDimension(m, dimension)); + } + } + if (selected.length < slots.length) { + relaxations.push({ constraint: 'freshness', stage: 'freshness', reason: 'hard exclusions and sequence caps left too few eligible candidates' }); + } + return { plan: selected, relaxations }; +} + export class SessionDirector { + // Kept as an instance seam so build/replan integration tests can inspect + // the exact constraint horizon without replacing the planner itself. + private readonly constrainedSequence = selectConstrainedSequence; + constructor(private db: DbService) {} // --------------------------------------------------------------- @@ -235,6 +547,24 @@ export class SessionDirector { artist.set(row.artist_id, row.fatigue); } + // Album fatigue uses the same recent horizon as artist fatigue. It is a + // separate signal: several tracks from a compilation should not exhaust + // every contributing artist, but should still be spread across hours. + const albumRes = await this.db.pgClient.query( + `SELECT t.album_id, + LEAST(1.0, SUM(EXP(-EXTRACT(EPOCH FROM (NOW() - ph.played_at)) / $2::float8))) AS fatigue + FROM play_history ph + JOIN tracks t ON t.id = ph.track_id + WHERE ph.user_id = $1 AND ph.played_at > NOW() - INTERVAL '24 hours' + AND ph.completed = true AND t.album_id IS NOT NULL + GROUP BY t.album_id`, + [userId, ARTIST_DECAY_SEC] + ); + const album = new Map(); + for (const row of albumRes.rows as { album_id: string; fatigue: number }[]) { + album.set(row.album_id, row.fatigue); + } + // Genre fatigue: last 24h, decay time constant 8h (e-folding, not half-life; half-life ≈ 5.5h) const genreRes = await this.db.pgClient.query( `SELECT tg.genre_id, @@ -279,7 +609,7 @@ export class SessionDirector { ); const vocal = (vocalRes.rows[0]?.vocal_fatigue as number) ?? 0.5; - return { artist, genre, language, track, vocal }; + return { artist, album, genre, language, track, vocal }; } // --------------------------------------------------------------- @@ -305,115 +635,82 @@ export class SessionDirector { const budgets: DiversityBudget[] = []; for (const row of rows) { - const spent = await this.calcBudgetSpent(userId, row.dimension, row.horizon_min); + const usage = await this.loadBudgetUsage(userId, row.dimension, row.horizon_min); budgets.push({ dimension: row.dimension, budgetShare: row.budget_share, horizonMin: row.horizon_min, - spent, + spent: usage.spent, + historicalTotal: usage.total, + historicalValues: usage.values, }); } return budgets; } - private async calcBudgetSpent(userId: string, dimension: string, horizonMin: number): Promise { + private async loadBudgetUsage(userId: string, dimension: string, horizonMin: number): Promise<{ + spent: number; + total: number; + values: Map; + }> { const interval = `${horizonMin} minutes`; - - switch (dimension) { - case 'artist': { - const res = await this.db.pgClient.query( - `WITH sub AS ( - SELECT COUNT(*) AS cnt - FROM play_history ph - JOIN track_artists_v2 ta ON ta.track_id = ph.track_id AND ta.role = 'main' - WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true - GROUP BY ta.artist_id - ) - SELECT COALESCE(MAX(cnt)::float8 / NULLIF((SELECT SUM(cnt) FROM sub), 0), 0) AS spent - FROM sub`, - [userId, interval] - ); - return (res.rows[0]?.spent as number) ?? 0; + // Each source produces at most one known value per completed track. The + // completed-play CTE is deliberately kept separate from classification: + // tracks with sparse metadata stay in the denominator, while only known + // values contribute to a dimension's numerator. + const sourceByDimension: Record = { + artist: `SELECT artist.artist_id::text AS value + FROM completed_plays ph + LEFT JOIN LATERAL (SELECT ta.artist_id FROM track_artists_v2 ta + WHERE ta.track_id = ph.track_id AND ta.role = 'main' + ORDER BY ta.confidence DESC, ta.artist_id LIMIT 1) artist ON true`, + genre: `SELECT genre.genre_id::text AS value + FROM completed_plays ph + LEFT JOIN LATERAL (SELECT tg.genre_id FROM track_genre tg + WHERE tg.track_id = ph.track_id ORDER BY tg.weight DESC, tg.genre_id LIMIT 1) genre ON true`, + language: `SELECT tl.language::text AS value + FROM completed_plays ph LEFT JOIN track_lyrics tl ON tl.track_id = ph.track_id`, + instrumental: `SELECT CASE WHEN taf.instrumentalness >= 0.5 THEN 'true' ELSE 'false' END AS value + FROM completed_plays ph LEFT JOIN track_audio_features taf ON taf.track_id = ph.track_id`, + new_artist: `SELECT CASE WHEN artist.artist_id IS NULL THEN NULL WHEN NOT EXISTS ( + SELECT 1 FROM play_history old_ph + JOIN track_artists_v2 old_ta ON old_ta.track_id = old_ph.track_id AND old_ta.role = 'main' + WHERE old_ph.user_id = $1 AND old_ph.completed = true + AND old_ph.played_at <= NOW() - $2::interval AND old_ta.artist_id = artist.artist_id + ) THEN 'true' ELSE 'false' END AS value + FROM completed_plays ph + LEFT JOIN LATERAL (SELECT ta.artist_id FROM track_artists_v2 ta + WHERE ta.track_id = ph.track_id AND ta.role = 'main' + ORDER BY ta.confidence DESC, ta.artist_id LIMIT 1) artist ON true`, + favorite: `SELECT CASE WHEN f.track_id IS NOT NULL THEN 'true' ELSE 'false' END AS value + FROM completed_plays ph LEFT JOIN favorites f ON f.track_id = ph.track_id AND f.user_id = $1`, + }; + const source = sourceByDimension[dimension]; + if (!source) return { spent: 0, total: 0, values: new Map() }; + const res = await this.db.pgClient.query( + `WITH completed_plays AS ( + SELECT ph.track_id + FROM play_history ph + WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true + ), values_per_track AS (${source}) + SELECT value, COUNT(*)::int AS cnt FROM values_per_track + GROUP BY value ORDER BY cnt DESC, value ASC`, + [userId, interval], + ); + const values = new Map(); + let total = 0; + let max = 0; + for (const row of res.rows as { value: string | null; cnt: number | string }[]) { + const count = Number(row.cnt); + total += count; + if (row.value != null) { + values.set(row.value, count); + max = Math.max(max, count); } - case 'genre': { - const res = await this.db.pgClient.query( - `WITH sub AS ( - SELECT COUNT(*) AS cnt - FROM play_history ph - JOIN track_genre tg ON tg.track_id = ph.track_id - WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true - GROUP BY tg.genre_id - ) - SELECT COALESCE(MAX(cnt)::float8 / NULLIF((SELECT SUM(cnt) FROM sub), 0), 0) AS spent - FROM sub`, - [userId, interval] - ); - return (res.rows[0]?.spent as number) ?? 0; - } - case 'language': { - const res = await this.db.pgClient.query( - `WITH sub AS ( - SELECT tl.language, COUNT(*) AS cnt - FROM play_history ph - JOIN track_lyrics tl ON tl.track_id = ph.track_id - WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true - AND tl.language IS NOT NULL - GROUP BY tl.language - ) - SELECT COALESCE(MAX(cnt)::float8 / NULLIF((SELECT SUM(cnt) FROM sub), 0), 0) AS spent - FROM sub`, - [userId, interval] - ); - return (res.rows[0]?.spent as number) ?? 0; - } - case 'instrumental': { - const res = await this.db.pgClient.query( - `SELECT COALESCE( - COUNT(*) FILTER (WHERE COALESCE(taf.instrumentalness, 0) > 0.5)::float8 / NULLIF(COUNT(*), 0), - 0) AS spent - FROM play_history ph - LEFT JOIN track_audio_features taf ON taf.track_id = ph.track_id - WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true`, - [userId, interval] - ); - return (res.rows[0]?.spent as number) ?? 0; - } - case 'new_artist': { - const res = await this.db.pgClient.query( - `WITH recent_artists AS ( - SELECT DISTINCT ta.artist_id - FROM play_history ph - JOIN track_artists_v2 ta ON ta.track_id = ph.track_id AND ta.role = 'main' - WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true - ) - SELECT COALESCE( - SUM(CASE WHEN NOT EXISTS ( - SELECT 1 FROM play_history ph3 - JOIN track_artists_v2 ta3 ON ta3.track_id = ph3.track_id AND ta3.role = 'main' - WHERE ph3.user_id = $1 AND ph3.played_at <= NOW() - $2::interval - AND ta3.artist_id = ra.artist_id - ) THEN 1 ELSE 0 END)::float8 / NULLIF(COUNT(*), 0), - 0) AS spent - FROM recent_artists ra`, - [userId, interval] - ); - return (res.rows[0]?.spent as number) ?? 0; - } - case 'favorite': { - const res = await this.db.pgClient.query( - `SELECT COALESCE( - COUNT(*) FILTER (WHERE f.track_id IS NOT NULL)::float8 / NULLIF(COUNT(*), 0), - 0) AS spent - FROM play_history ph - LEFT JOIN favorites f ON f.track_id = ph.track_id AND f.user_id = $1 - WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true`, - [userId, interval] - ); - return (res.rows[0]?.spent as number) ?? 0; - } - default: - return 0; } + const lowerBoundDimension = ['instrumental', 'new_artist', 'favorite'].includes(dimension); + const numerator = lowerBoundDimension ? (values.get('true') ?? 0) : max; + return { spent: total > 0 ? numerator / total : 0, total, values }; } // --------------------------------------------------------------- @@ -514,7 +811,7 @@ export class SessionDirector { fatigue: FatigueState, budgets: DiversityBudget[], recentPlays: RecentPlay[] - ): Promise { + ): Promise { const n = recentPlays.length; if (n < 3) return null; @@ -523,8 +820,8 @@ export class SessionDirector { for (const p of recentPlays) { if (p.artistId) artistCounts.set(p.artistId, (artistCounts.get(p.artistId) ?? 0) + 1); } - for (const count of artistCounts.values()) { - if (count / n > 0.3) return 'artist'; + for (const [value, count] of artistCounts) { + if (count / n > 0.3) return { dimension: 'artist', values: [value] }; } // 2. GENRE: single genre > 40% of recent plays @@ -532,8 +829,8 @@ export class SessionDirector { for (const p of recentPlays) { if (p.genreId) genreCounts.set(p.genreId, (genreCounts.get(p.genreId) ?? 0) + 1); } - for (const count of genreCounts.values()) { - if (count / n > 0.4) return 'genre'; + for (const [value, count] of genreCounts) { + if (count / n > 0.4) return { dimension: 'genre', values: [value] }; } // 3. LANGUAGE: single language > 50% of recent plays @@ -541,8 +838,8 @@ export class SessionDirector { for (const p of recentPlays) { if (p.language) langCounts.set(p.language, (langCounts.get(p.language) ?? 0) + 1); } - for (const count of langCounts.values()) { - if (count / n > 0.5) return 'language'; + for (const [value, count] of langCounts) { + if (count / n > 0.5) return { dimension: 'language', values: [value] }; } // 4. ENERGY: >60% of plays in same energy quartile @@ -553,7 +850,8 @@ export class SessionDirector { const q = Math.min(Math.floor(e / 0.25), 3); quartileCounts[q]++; } - if (Math.max(...quartileCounts) / energies.length > 0.6) return 'energy'; + const dominant = quartileCounts.indexOf(Math.max(...quartileCounts)); + if (quartileCounts[dominant] / energies.length > 0.6) return { dimension: 'energy', values: [String(dominant)] }; } // 5. BPM: all plays within 20 BPM of each other @@ -561,14 +859,17 @@ export class SessionDirector { if (bpms.length >= 3) { const bpmMin = Math.min(...bpms); const bpmMax = Math.max(...bpms); - if (bpmMax - bpmMin <= 20) return 'bpm'; + if (bpmMax - bpmMin <= 20) return { dimension: 'bpm', values: [String(Math.floor(bpms[0] / 20))] }; } // 6. VOCAL: >80% all-vocal or all-instrumental if (n >= 3) { - const vocalCount = recentPlays.filter(p => p.vocal).length; - const vocalRatio = vocalCount / n; - if (vocalRatio > 0.8 || vocalRatio < 0.2) return 'vocal'; + const knownVocal = recentPlays.filter(p => p.vocal != null); + const vocalCount = knownVocal.filter(p => p.vocal).length; + const vocalRatio = knownVocal.length === 0 ? 0.5 : vocalCount / knownVocal.length; + if (knownVocal.length >= 3 && (vocalRatio > 0.8 || vocalRatio < 0.2)) { + return { dimension: 'vocal', values: [String(vocalRatio > 0.8)] }; + } } // 7. DECADE: >50% from same decade @@ -576,44 +877,77 @@ export class SessionDirector { for (const p of recentPlays) { if (p.decade != null) decadeCounts.set(p.decade, (decadeCounts.get(p.decade) ?? 0) + 1); } - for (const count of decadeCounts.values()) { - if (count / n > 0.5) return 'decade'; + for (const [value, count] of decadeCounts) { + if (count / n > 0.5) return { dimension: 'decade', values: [String(value)] }; } - // 8. PRODUCER: single producer > 3 tracks + // 8. PRODUCER: repeated graph lineage across resolved main artists. These + // edges are artist-to-artist in claim_fusion, so track-subject claims would + // silently never match real enriched data. const trackIds = recentPlays.map(p => p.trackId).filter(Boolean); if (trackIds.length > 0) { const prodRes = await this.db.pgClient.query( - `SELECT c.object_id - FROM claims c - WHERE c.predicate = 'produced' - AND c.subject_id = ANY($1::uuid[]) - GROUP BY c.object_id - HAVING COUNT(DISTINCT c.subject_id) > 3`, + `WITH recent_main_artists AS ( + SELECT DISTINCT ON (ph.track_id) ph.track_id, artist.artist_id + FROM play_history ph + JOIN LATERAL ( + SELECT ta.artist_id FROM track_artists_v2 ta + WHERE ta.track_id = ph.track_id AND ta.role = 'main' + ORDER BY ta.confidence DESC, ta.artist_id + LIMIT 1 + ) artist ON true + WHERE ph.track_id = ANY($1::uuid[]) + ) + SELECT CASE WHEN cf.subject_id = recent.artist_id THEN cf.object_id ELSE cf.subject_id END AS lineage_id + FROM recent_main_artists recent + JOIN claim_fusion cf ON cf.subject_type = 'artist' AND cf.object_type = 'artist' + AND cf.predicate = 'produced' + AND (cf.subject_id = recent.artist_id OR cf.object_id = recent.artist_id) + GROUP BY lineage_id + HAVING COUNT(DISTINCT recent.track_id) > 3`, [trackIds] ); - if (prodRes.rows.length > 0) return 'producer'; + if (prodRes.rows.length > 0) { + return { dimension: 'producer', values: prodRes.rows.map((row: { lineage_id: string }) => row.lineage_id) }; + } } - // 9. LABEL: single label > 3 tracks + // 9. LABEL: same representation and bidirectional handling as producer + // lineage. `same_label_as` is a graph relationship between artists. if (trackIds.length > 0) { const labelRes = await this.db.pgClient.query( - `SELECT c.object_id - FROM claims c - WHERE c.predicate = 'same_label_as' - AND c.subject_id = ANY($1::uuid[]) - GROUP BY c.object_id - HAVING COUNT(DISTINCT c.subject_id) > 3`, + `WITH recent_main_artists AS ( + SELECT DISTINCT ON (ph.track_id) ph.track_id, artist.artist_id + FROM play_history ph + JOIN LATERAL ( + SELECT ta.artist_id FROM track_artists_v2 ta + WHERE ta.track_id = ph.track_id AND ta.role = 'main' + ORDER BY ta.confidence DESC, ta.artist_id + LIMIT 1 + ) artist ON true + WHERE ph.track_id = ANY($1::uuid[]) + ) + SELECT CASE WHEN cf.subject_id = recent.artist_id THEN cf.object_id ELSE cf.subject_id END AS lineage_id + FROM recent_main_artists recent + JOIN claim_fusion cf ON cf.subject_type = 'artist' AND cf.object_type = 'artist' + AND cf.predicate = 'same_label_as' + AND (cf.subject_id = recent.artist_id OR cf.object_id = recent.artist_id) + GROUP BY lineage_id + HAVING COUNT(DISTINCT recent.track_id) > 3`, [trackIds] ); - if (labelRes.rows.length > 0) return 'label'; + if (labelRes.rows.length > 0) { + return { dimension: 'label', values: labelRes.rows.map((row: { lineage_id: string }) => row.lineage_id) }; + } } // 10. MOOD: all plays same mood (valence > 0.5 = positive, <= 0.5 = negative) const valences = recentPlays.filter(p => p.valence != null).map(p => p.valence!); if (valences.length >= 3) { const positiveCount = valences.filter(v => v > 0.5).length; - if (positiveCount === valences.length || positiveCount === 0) return 'mood'; + if (positiveCount === valences.length || positiveCount === 0) { + return { dimension: 'mood', values: [String(positiveCount === valences.length)] }; + } } return null; @@ -717,6 +1051,71 @@ export class SessionDirector { return artistMap; } + /** + * Load the small, planner-facing metadata projection in one round trip. + * This deliberately uses nullable fields: unknown metadata cannot earn a + * lower-bound budget credit, but it remains eligible unless a hard rule has + * enough metadata to apply. + */ + private async loadConstraintMetadata( + userId: string, + trackIds: string[], + ): Promise> { + const metadata = new Map(); + if (trackIds.length === 0) return metadata; + const res = await this.db.pgClient.query( + `SELECT t.id AS track_id, t.album_id, t.release_date, + artist.artist_id, genre.genre_id, tl.language, + taf.instrumentalness, + EXISTS(SELECT 1 FROM favorites f WHERE f.user_id = $1 AND f.track_id = t.id) AS favorite, + NOT EXISTS( + SELECT 1 FROM play_history ph + JOIN track_artists_v2 old_artist ON old_artist.track_id = ph.track_id + AND old_artist.role = 'main' + WHERE ph.user_id = $1 AND ph.completed = true + AND old_artist.artist_id = artist.artist_id + ) AS new_artist, + taf.energy, taf.bpm, taf.valence, + ${lineageIdsSql('produced', 'artist')} AS producer_ids, + ${lineageIdsSql('same_label_as', 'artist')} AS label_ids + FROM tracks t + LEFT JOIN LATERAL ( + SELECT ta.artist_id FROM track_artists_v2 ta + WHERE ta.track_id = t.id AND ta.role = 'main' + ORDER BY ta.confidence DESC, ta.artist_id LIMIT 1 + ) artist ON true + LEFT JOIN LATERAL ( + SELECT tg.genre_id FROM track_genre tg + WHERE tg.track_id = t.id ORDER BY tg.weight DESC LIMIT 1 + ) genre ON true + LEFT JOIN track_lyrics tl ON tl.track_id = t.id + LEFT JOIN track_audio_features taf ON taf.track_id = t.id + WHERE t.id = ANY($2::uuid[])`, + [userId, trackIds], + ); + for (const row of res.rows as Array>) { + const id = row.track_id as string; + metadata.set(id, { + artistId: (row.artist_id as string | null) ?? undefined, + albumId: (row.album_id as string | null) ?? undefined, + genreId: (row.genre_id as string | null) ?? undefined, + language: (row.language as string | null) ?? undefined, + instrumental: typeof row.instrumentalness === 'number' + ? (row.instrumentalness as number) >= 0.5 : undefined, + favorite: row.favorite === true, + newArtist: row.new_artist === true && row.artist_id != null, + energy: (row.energy as number | null) ?? undefined, + bpm: (row.bpm as number | null) ?? undefined, + valence: (row.valence as number | null) ?? undefined, + decade: row.release_date + ? Math.floor(new Date(row.release_date as string).getFullYear() / 10) * 10 : undefined, + producerIds: Array.isArray(row.producer_ids) ? row.producer_ids as string[] : [], + labelIds: Array.isArray(row.label_ids) ? row.label_ids as string[] : [], + }); + } + return metadata; + } + // --------------------------------------------------------------- // D.8 — Multi-objective ranking // --------------------------------------------------------------- @@ -725,25 +1124,16 @@ export class SessionDirector { fatigue: FatigueState, budgets: DiversityBudget[], state: GeneratorContext['state'], - repetitionState: RepetitionState + repetitionState: RepetitionState, + userId = '', ): Promise { if (candidates.length === 0) return []; const trackIds = [...new Set(candidates.map(c => c.trackId))]; - const artistMap = await this.loadArtistMap(trackIds); - - const genreMap = new Map(); - if (trackIds.length > 0) { - const genreRes = await this.db.pgClient.query( - `SELECT DISTINCT ON (tg.track_id) tg.track_id, tg.genre_id - FROM track_genre tg - WHERE tg.track_id = ANY($1::uuid[]) - ORDER BY tg.track_id, tg.weight DESC`, - [trackIds] - ); - for (const row of genreRes.rows as { track_id: string; genre_id: string }[]) { - genreMap.set(row.track_id, row.genre_id); - } + const metadata = await this.loadConstraintMetadata(userId, trackIds); + const artistMap = new Map(); + for (const [trackId, item] of metadata) { + if (item.artistId) artistMap.set(trackId, item.artistId); } const currentEntropy = this.computeEntropy(candidates, c => artistMap.get(c.trackId) ?? 'unknown'); @@ -757,15 +1147,22 @@ export class SessionDirector { artistBatchCounts.set(aid, (artistBatchCounts.get(aid) ?? 0) + 1); } - const scored: { candidate: Candidate; score: number }[] = []; - for (const c of candidates) { - const artistId = artistMap.get(c.trackId) ?? ''; - const genreId = genreMap.get(c.trackId) ?? ''; + const scored: { candidate: Candidate; score: number; inputIndex: number }[] = []; + for (const [inputIndex, c] of candidates.entries()) { + const item = metadata.get(c.trackId); + const artistId = item?.artistId ?? ''; + const genreId = item?.genreId ?? ''; + const albumId = item?.albumId ?? ''; + const language = item?.language ?? ''; const trackFatigue = fatigue.track.get(c.trackId) ?? 0; const artistFatigue = fatigue.artist.get(artistId) ?? 0; + const albumFatigue = fatigue.album.get(albumId) ?? 0; const genreFatigue = fatigue.genre.get(genreId) ?? 0; - const avgFatigue = (trackFatigue + artistFatigue + genreFatigue) / 3; + const languageFatigue = fatigue.language.get(language) ?? 0; + const vocalFatigue = item?.instrumental === undefined ? 0 + : (item.instrumental ? 1 - fatigue.vocal : fatigue.vocal); + const avgFatigue = (trackFatigue + artistFatigue + albumFatigue + genreFatigue + languageFatigue + vocalFatigue) / 6; // diversityBonus: this artist's own fatigue-weighted share — varies per candidate. const diversityBonus = 1 - artistFatigue; @@ -785,7 +1182,7 @@ export class SessionDirector { score *= 0.1; } - scored.push({ candidate: c, score }); + scored.push({ candidate: c, score, inputIndex }); } const entropyDrift = Math.abs(currentEntropy - targetEntropy); @@ -801,7 +1198,7 @@ export class SessionDirector { } } - scored.sort((a, b) => b.score - a.score); + scored.sort((a, b) => b.score - a.score || a.inputIndex - b.inputIndex || a.candidate.trackId.localeCompare(b.candidate.trackId)); return scored.map(s => s.candidate); } @@ -835,6 +1232,7 @@ export class SessionDirector { seedTrackId?: string, options: PlanBuildOptions = {} ): Promise { + const retainedPlan = options.retainedPlan ?? []; // Durable events outlive any process-local queue. Fetch them here rather // than trusting callers to remember the boundary, so a served, skipped, // disliked, or otherwise exposed track can never leak into a replacement @@ -852,22 +1250,28 @@ export class SessionDirector { // Fetch recent completed plays for anti-loop detection const recentPlaysRes = await this.db.pgClient.query( - `SELECT t.id AS track_id, ta.artist_id, tg.genre_id, + `SELECT t.id AS track_id, t.album_id, artist.artist_id, tg.genre_id, af.bpm, af.energy, af.valence, af.instrumentalness, tl.language, - t.release_date + t.release_date, + ${lineageIdsSql('produced', 'artist')} AS producer_ids, + ${lineageIdsSql('same_label_as', 'artist')} AS label_ids FROM play_history ph JOIN tracks t ON t.id = ph.track_id LEFT JOIN track_audio_features af ON af.track_id = t.id - LEFT JOIN track_artists_v2 ta ON ta.track_id = t.id AND ta.role = 'main' + LEFT JOIN LATERAL ( + SELECT ta.artist_id FROM track_artists_v2 ta + WHERE ta.track_id = t.id AND ta.role = 'main' + ORDER BY ta.confidence DESC, ta.artist_id LIMIT 1 + ) artist ON true LEFT JOIN track_genre tg ON tg.track_id = t.id AND tg.weight = ( SELECT MAX(weight) FROM track_genre WHERE track_id = t.id ) LEFT JOIN track_lyrics tl ON tl.track_id = t.id WHERE ph.user_id = $1 AND ph.completed = true ORDER BY ph.played_at DESC - LIMIT 20`, - [userId] + LIMIT $2`, + [userId, ALBUM_HORIZON_TRACKS] ); const recentPlays: RecentPlay[] = recentPlaysRes.rows.map((r: any) => ({ trackId: r.track_id, @@ -876,9 +1280,12 @@ export class SessionDirector { bpm: r.bpm ?? null, energy: r.energy ?? null, language: r.language ?? null, - vocal: (r.instrumentalness == null) ? false : r.instrumentalness < 0.5, + vocal: (r.instrumentalness == null) ? null : r.instrumentalness < 0.5, decade: r.release_date ? Math.floor(new Date(r.release_date).getFullYear() / 10) * 10 : null, valence: r.valence ?? null, + albumId: r.album_id ?? null, + producerIds: r.producer_ids ?? [], + labelIds: r.label_ids ?? [], })); const state = await this.buildState(userId, sessionId); @@ -888,7 +1295,7 @@ export class SessionDirector { const arcType = this.pickArc(state); const planSize = PLAN_SIZE; - const slots = this.getArcSlots(arcType, planSize); + const slots = this.getArcSlots(arcType, Math.max(0, planSize - retainedPlan.length)); let seedArtistId: string | null = null; if (seedTrackId) { @@ -948,20 +1355,10 @@ export class SessionDirector { return []; } const ranked = await this.rankCandidates( - eligibleCandidates, fatigue, budgets, state, repetitionState + eligibleCandidates, fatigue, budgets, state, repetitionState, userId ); const loopDim = await this.detectAntiLoop(state, fatigue, budgets, recentPlays); - let forcedExperimental = false; - if (loopDim && ranked.length > 0) { - // Anti-loop candidates are already present in `ranked` — just pull them to the - // front instead of re-running all generators and re-ranking from scratch. - const injected = ranked.filter( - c => c.generatorId === 'experimental' || c.generatorId === 'discovery' - ); - ranked.unshift(...injected); - forcedExperimental = true; - } const seen = new Set(); const deduped: Candidate[] = []; @@ -972,43 +1369,29 @@ export class SessionDirector { } } - const plan: Candidate[] = []; - const usedTrackIds = new Set(); - - if (!forcedExperimental) { - const unused = [...deduped]; - for (const slot of slots) { - const prefGenIds = this.roleToGeneratorIds(slot.role); - let idx = unused.findIndex( - c => prefGenIds.includes(c.generatorId) && !usedTrackIds.has(c.trackId) - ); - if (idx === -1) { - idx = unused.findIndex(c => !usedTrackIds.has(c.trackId)); - } - if (idx === -1) break; - const chosen = unused[idx]; - usedTrackIds.add(chosen.trackId); - plan.push(chosen); - unused.splice(idx, 1); - } - - if (plan.length < planSize) { - for (const c of deduped) { - if (plan.length >= planSize) break; - if (!usedTrackIds.has(c.trackId)) { - usedTrackIds.add(c.trackId); - plan.push(c); - } - } - } - } else { - for (const c of deduped) { - if (plan.length >= planSize) break; - plan.push(c); - } + const metadata = await this.loadConstraintMetadata(userId, [ + ...new Set([...deduped.map(c => c.trackId), ...retainedPlan.map(c => c.trackId)]), + ]); + const constrained = this.constrainedSequence({ + candidates: deduped, + slots, + metadata, + budgets, + roleToGeneratorIds: role => this.roleToGeneratorIds(role), + retainedPlan, + albumHistory: recentPlays.map(metadataFromRecentPlay), + // A seed is an explicit direction. It narrows soft diversity targets but + // cannot bypass served-track exclusions or artist/album sequence caps. + explicitIntent: !!seedTrackId, + loopDimension: loopDim?.dimension, + loopedValues: loopDim?.values, + }); + if (constrained.relaxations.length > 0) { + // Structured diagnostics stay internal until the durable plan API exposes + // objective snapshots. Do not silently hide a degraded sequence. + console.warn('Vibe constraint relaxations', { sessionId, relaxations: constrained.relaxations }); } - - return plan.slice(0, planSize); + return constrained.plan.slice(0, Math.max(0, planSize - retainedPlan.length)); } async replan( @@ -1041,14 +1424,20 @@ export class SessionDirector { // Fetch recent plays for anti-loop const recentPlaysRes = await this.db.pgClient.query( - `SELECT t.id AS track_id, ta.artist_id, tg.genre_id, + `SELECT t.id AS track_id, t.album_id, artist.artist_id, tg.genre_id, af.bpm, af.energy, af.valence, af.instrumentalness, tl.language, - t.release_date + t.release_date, + ${lineageIdsSql('produced', 'artist')} AS producer_ids, + ${lineageIdsSql('same_label_as', 'artist')} AS label_ids FROM play_history ph JOIN tracks t ON t.id = ph.track_id LEFT JOIN track_audio_features af ON af.track_id = t.id - LEFT JOIN track_artists_v2 ta ON ta.track_id = t.id AND ta.role = 'main' + LEFT JOIN LATERAL ( + SELECT ta.artist_id FROM track_artists_v2 ta + WHERE ta.track_id = t.id AND ta.role = 'main' + ORDER BY ta.confidence DESC, ta.artist_id LIMIT 1 + ) artist ON true LEFT JOIN track_genre tg ON tg.track_id = t.id AND tg.weight = ( SELECT MAX(weight) FROM track_genre WHERE track_id = t.id ) @@ -1065,15 +1454,19 @@ export class SessionDirector { bpm: r.bpm ?? null, energy: r.energy ?? null, language: r.language ?? null, - vocal: (r.instrumentalness == null) ? false : r.instrumentalness < 0.5, + vocal: (r.instrumentalness == null) ? null : r.instrumentalness < 0.5, decade: r.release_date ? Math.floor(new Date(r.release_date).getFullYear() / 10) * 10 : null, valence: r.valence ?? null, + albumId: r.album_id ?? null, + producerIds: r.producer_ids ?? [], + labelIds: r.label_ids ?? [], })); const loopDim = await this.detectAntiLoop(state, fatigue, budgets, recentPlays); if (loopDim) { const rebuilt = await this.buildPlan(userId, sessionId, seedTrackId, { excludedTrackIds: new Set([...excludedTrackIds, ...remainingSlots.map(c => c.trackId)]), + retainedPlan: remainingSlots, }); return mergeUniquePlan(remainingSlots, rebuilt, excludedTrackIds, PLAN_SIZE); } @@ -1083,6 +1476,7 @@ export class SessionDirector { if (Math.abs(entropy - 0.55) > 0.2) { const rebuilt = await this.buildPlan(userId, sessionId, seedTrackId, { excludedTrackIds: new Set([...excludedTrackIds, ...remainingSlots.map(c => c.trackId)]), + retainedPlan: remainingSlots, }); return mergeUniquePlan(remainingSlots, rebuilt, excludedTrackIds, PLAN_SIZE); } @@ -1096,6 +1490,7 @@ export class SessionDirector { // queued. Keep the valid tail and append only fresh candidates. const rebuilt = await this.buildPlan(userId, sessionId, seedTrackId, { excludedTrackIds: new Set([...excludedTrackIds, ...remainingSlots.map(c => c.trackId)]), + retainedPlan: remainingSlots, }); return mergeUniquePlan(remainingSlots, rebuilt, excludedTrackIds, PLAN_SIZE); } diff --git a/backend/src/services/session-director.test.ts b/backend/src/services/session-director.test.ts index 1874694..227f417 100644 --- a/backend/src/services/session-director.test.ts +++ b/backend/src/services/session-director.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; -import { mergeUniquePlan, SessionDirector } from './session-director.service.js'; +import { mergeUniquePlan, selectConstrainedSequence, SessionDirector } from './session-director.service.js'; import { DbService } from './db.service.js'; +import { ALL_GENERATORS } from './generators.service.js'; function makeMockDb(overrides: Record = {}): DbService { const mockQuery = vi.fn(); @@ -60,6 +61,8 @@ describe('SessionDirector', () => { ); const refillExclusions = (buildPlan.mock.calls[0][3] as any).excludedTrackIds as Set; expect(refillExclusions).toEqual(new Set(['older-skip', 'skipped', 'already-queued'])); + expect((buildPlan.mock.calls[0][3] as any).retainedPlan.map((item: any) => item.trackId)) + .toEqual(['already-queued']); }); it('does not append anything when a refill contains only queued or excluded tracks', async () => { @@ -159,7 +162,7 @@ describe('SessionDirector', () => { { trackId: 't1', generatorId: 'a', relevance: 0.9, explanation: [{ subjectType: 'artist', subjectId: 'a1', predicate: 'credited_main_on', objectType: 'track', objectId: 't1', fusedValue: 1 }] }, { trackId: 't2', generatorId: 'b', relevance: 0.3, explanation: [{ subjectType: 'artist', subjectId: 'a2', predicate: 'credited_main_on', objectType: 'track', objectId: 't2', fusedValue: 1 }] }, ]; - const fatigue = { artist: new Map(), genre: new Map(), track: new Map(), language: new Map(), vocal: 0 }; + const fatigue = { artist: new Map(), album: new Map(), genre: new Map(), track: new Map(), language: new Map(), vocal: 0 }; const budgets = [{ dimension: 'artist', budgetShare: 0.2, horizonMin: 30, spent: 0 }]; const state = { energy: 0.5, noveltyHunger: 0.3, sessionAgeMin: 10, lastArtistIds: [], lastGenreIds: [], context: null }; @@ -170,6 +173,339 @@ describe('SessionDirector', () => { }); + describe('sequence constraints', () => { + const slots = Array.from({ length: 10 }, (_, position) => ({ position, role: 'known' })); + const budgets = [ + { dimension: 'artist', budgetShare: 0.2, horizonMin: 30, spent: 0 }, + { dimension: 'genre', budgetShare: 0.4, horizonMin: 30, spent: 0 }, + { dimension: 'language', budgetShare: 0.6, horizonMin: 30, spent: 0 }, + { dimension: 'instrumental', budgetShare: 0.1, horizonMin: 30, spent: 0 }, + { dimension: 'new_artist', budgetShare: 0.15, horizonMin: 60, spent: 0 }, + { dimension: 'favorite', budgetShare: 0.25, horizonMin: 60, spent: 0 }, + ]; + const roleToGeneratorIds = () => ['comfort']; + + it('projects budgets while enforcing artist and album caps across the sequence', () => { + const candidates = Array.from({ length: 15 }, (_, i) => ({ ...candidate(`t${i}`), generatorId: 'comfort' })); + const metadata = new Map(candidates.map((item, i) => [item.trackId, { + artistId: i < 5 ? 'overplayed-artist' : `artist-${i}`, + albumId: i < 4 ? 'overplayed-album' : `album-${i}`, + genreId: i < 6 ? 'genre-a' : 'genre-b', + language: i < 7 ? 'ja' : 'en', + instrumental: i === 7, + newArtist: i === 8 || i === 9, + favorite: i === 10 || i === 11 || i === 12, + }])); + + const result = selectConstrainedSequence({ candidates, slots, metadata, budgets, roleToGeneratorIds }); + expect(result.plan).toHaveLength(10); + const ids = result.plan.map(item => item.trackId); + expect(ids.filter(id => metadata.get(id)?.artistId === 'overplayed-artist')).toHaveLength(2); + expect(ids.filter(id => metadata.get(id)?.albumId === 'overplayed-album').length).toBeLessThanOrEqual(3); + expect(ids.filter(id => metadata.get(id)?.instrumental)).toHaveLength(1); + expect(ids.filter(id => metadata.get(id)?.newArtist)).toHaveLength(2); + expect(ids.filter(id => metadata.get(id)?.favorite)).toHaveLength(3); + }); + + it('corrects the detected dimension directly before relaxing it', () => { + const candidates = ['ja-1', 'ja-2', 'en-1', 'en-2'].map(trackId => ({ ...candidate(trackId), generatorId: 'comfort' })); + const metadata = new Map([ + ['ja-1', { artistId: 'a1', albumId: 'x1', language: 'ja' }], + ['ja-2', { artistId: 'a2', albumId: 'x2', language: 'ja' }], + ['en-1', { artistId: 'a3', albumId: 'x3', language: 'en' }], + ['en-2', { artistId: 'a4', albumId: 'x4', language: 'en' }], + ]); + const result = selectConstrainedSequence({ + candidates, + slots: slots.slice(0, 2), + metadata, + budgets: [], + roleToGeneratorIds, + loopDimension: 'language', + loopedValue: 'ja', + }); + expect(result.plan.map(item => item.trackId)).toEqual(['en-1', 'en-2']); + expect(result.relaxations).toEqual([]); + }); + + it('records a structured soft relaxation without violating hard album caps', () => { + const candidates = Array.from({ length: 5 }, (_, i) => ({ ...candidate(`t${i}`), generatorId: 'comfort' })); + const metadata = new Map(candidates.map((item, i) => [item.trackId, { + artistId: `artist-${i}`, + albumId: i < 4 ? 'single-album' : `album-${i}`, + language: 'ja', + }])); + const result = selectConstrainedSequence({ + candidates, slots: slots.slice(0, 5), metadata, budgets: [], roleToGeneratorIds, + loopDimension: 'language', loopedValue: 'ja', + }); + expect(result.plan.filter(item => metadata.get(item.trackId)?.albumId === 'single-album')).toHaveLength(3); + expect(result.relaxations).toContainEqual(expect.objectContaining({ stage: 'soft_budget' })); + }); + + it('counts the retained queue tail against hard artist caps before selecting replacements', () => { + const retained = [candidate('queued-a1'), candidate('queued-a2')]; + const candidates = [candidate('same-artist'), candidate('other-artist')].map(item => ({ ...item, generatorId: 'comfort' })); + const metadata = new Map([ + ['queued-a1', { artistId: 'artist-a', albumId: 'queued-album-1' }], + ['queued-a2', { artistId: 'artist-a', albumId: 'queued-album-2' }], + ['same-artist', { artistId: 'artist-a', albumId: 'replacement-album' }], + ['other-artist', { artistId: 'artist-b', albumId: 'replacement-album-2' }], + ]); + + const result = selectConstrainedSequence({ + candidates, slots: slots.slice(0, 1), metadata, budgets: [], roleToGeneratorIds, retainedPlan: retained, + }); + expect(result.plan.map(item => item.trackId)).toEqual(['other-artist']); + }); + + it('enforces the three-track album limit across the rolling 40-play history', () => { + const candidates = [candidate('same-album'), candidate('new-album')].map(item => ({ ...item, generatorId: 'comfort' })); + const metadata = new Map([ + ['same-album', { artistId: 'a4', albumId: 'album-a' }], + ['new-album', { artistId: 'a5', albumId: 'album-b' }], + ]); + const albumHistory = Array.from({ length: 40 }, (_, index) => ({ + artistId: `history-${index}`, + albumId: index < 3 ? 'album-a' : `history-album-${index}`, + })); + + const result = selectConstrainedSequence({ + candidates, slots: slots.slice(0, 1), metadata, budgets: [], roleToGeneratorIds, albumHistory, + }); + expect(result.plan.map(item => item.trackId)).toEqual(['new-album']); + }); + + it('projects budgets over historical counts and the planned horizon with track-consistent denominators', () => { + const candidates = [candidate('ja'), candidate('en')].map(item => ({ ...item, generatorId: 'comfort' })); + const metadata = new Map([ + ['ja', { artistId: 'a1', albumId: 'x1', genreId: 'j-pop' }], + ['en', { artistId: 'a2', albumId: 'x2', genreId: 'rock' }], + ]); + const historicalValues = new Map([['j-pop', 4], ['rock', 1]]); + const result = selectConstrainedSequence({ + candidates, + slots: slots.slice(0, 1), + metadata, + budgets: [{ dimension: 'genre', budgetShare: 0.6, horizonMin: 30, spent: 0.8, historicalTotal: 5, historicalValues }], + roleToGeneratorIds, + }); + // 4 / 5 becomes 4 / 6 if rock is selected; a fifth j-pop track would + // exceed the 60% cap. The selector must use history + proposal, not only + // the one-track replacement queue. + expect(result.plan.map(item => item.trackId)).toEqual(['en']); + }); + + it('does not treat unknown instrumentation as a vocal/instrumental budget credit', () => { + const candidates = [candidate('unknown'), candidate('instrumental')].map(item => ({ ...item, generatorId: 'comfort' })); + const metadata = new Map([ + ['unknown', { artistId: 'a1', albumId: 'x1' }], + ['instrumental', { artistId: 'a2', albumId: 'x2', instrumental: true }], + ]); + const result = selectConstrainedSequence({ + candidates, + slots: slots.slice(0, 1), + metadata, + budgets: [{ dimension: 'instrumental', budgetShare: 1, horizonMin: 30, spent: 0, historicalTotal: 0, historicalValues: new Map() }], + roleToGeneratorIds, + }); + expect(result.plan.map(item => item.trackId)).toEqual(['instrumental']); + }); + + it('excludes candidates matching any detected producer or label, not only their first claim', () => { + const candidates = [candidate('producer-match'), candidate('label-match'), candidate('safe')].map(item => ({ ...item, generatorId: 'comfort' })); + const metadata = new Map([ + ['producer-match', { artistId: 'a1', albumId: 'x1', producerIds: ['other', 'producer-loop'] }], + ['label-match', { artistId: 'a2', albumId: 'x2', labelIds: ['other', 'label-loop'] }], + ['safe', { artistId: 'a3', albumId: 'x3', producerIds: ['safe-producer'], labelIds: ['safe-label'] }], + ]); + const producerResult = selectConstrainedSequence({ + candidates, slots: slots.slice(0, 1), metadata, budgets: [], roleToGeneratorIds, + loopDimension: 'producer', loopedValues: ['producer-loop'], + }); + const labelResult = selectConstrainedSequence({ + candidates, slots: slots.slice(0, 1), metadata, budgets: [], roleToGeneratorIds, + loopDimension: 'label', loopedValues: ['label-loop'], + }); + expect(producerResult.plan.map(item => item.trackId)).not.toContain('producer-match'); + expect(labelResult.plan.map(item => item.trackId)).not.toContain('label-match'); + }); + + it('keeps stable candidate order while reusing a role preference pool', () => { + const candidates = [ + { ...candidate('comfort-first'), generatorId: 'comfort' }, + { ...candidate('adjacent-first'), generatorId: 'adjacent' }, + { ...candidate('comfort-second'), generatorId: 'comfort' }, + { ...candidate('adjacent-second'), generatorId: 'adjacent' }, + ]; + const metadata = new Map(candidates.map((item, index) => [item.trackId, { + artistId: `artist-${index}`, albumId: `album-${index}`, + }])); + const result = selectConstrainedSequence({ + candidates, + slots: Array.from({ length: 4 }, (_, position) => ({ position, role: position % 2 ? 'adjacent' : 'known' })), + metadata, + budgets: [], + roleToGeneratorIds: role => role === 'adjacent' ? ['adjacent'] : ['comfort'], + }); + expect(result.plan.map(item => item.trackId)).toEqual([ + 'comfort-first', 'adjacent-first', 'comfort-second', 'adjacent-second', + ]); + }); + }); + + describe('anti-loop signals', () => { + const variedRecentPlays = Array.from({ length: 4 }, (_, index) => ({ + trackId: `00000000-0000-0000-0000-00000000000${index + 1}`, + artistId: `artist-${index}`, + genreId: `genre-${index}`, + language: `lang-${index}`, + bpm: 80 + index * 30, + energy: index / 3, + vocal: null, + decade: 1980 + index * 10, + valence: index % 2, + albumId: `album-${index}`, + producerIds: [], + labelIds: [], + })); + + it('returns fused producer lineage from resolved main artists', async () => { + const db = makeMockDb(); + (db.pgClient.query as any).mockResolvedValue({ rows: [{ lineage_id: 'producer-a' }, { lineage_id: 'producer-b' }] }); + const director = new SessionDirector(db); + const signal = await director.detectAntiLoop({} as any, {} as any, [], variedRecentPlays); + expect(signal).toEqual({ dimension: 'producer', values: ['producer-a', 'producer-b'] }); + const sql = (db.pgClient.query as any).mock.calls[0][0] as string; + expect(sql).toContain('claim_fusion cf'); + expect(sql).toContain("cf.subject_type = 'artist'"); + expect(sql).toContain('cf.object_id = recent.artist_id'); + expect(sql).toContain('ORDER BY ta.confidence DESC, ta.artist_id'); + }); + + it('returns label identities after a producer check finds no loop', async () => { + const db = makeMockDb(); + (db.pgClient.query as any) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [{ lineage_id: 'label-a' }] }); + const director = new SessionDirector(db); + const signal = await director.detectAntiLoop({} as any, {} as any, [], variedRecentPlays); + expect(signal).toEqual({ dimension: 'label', values: ['label-a'] }); + const sql = (db.pgClient.query as any).mock.calls[1][0] as string; + expect(sql).toContain("cf.predicate = 'same_label_as'"); + expect(sql).toContain('cf.subject_id = recent.artist_id OR cf.object_id = recent.artist_id'); + }); + }); + + describe('planner metadata and integration boundaries', () => { + it('counts every completed play in a budget horizon while only classifying known values', async () => { + const db = makeMockDb(); + (db.pgClient.query as any).mockResolvedValue({ + rows: [{ value: 'rock', cnt: 3 }, { value: null, cnt: 2 }], + }); + const director = new SessionDirector(db); + const usage = await (director as any).loadBudgetUsage('user-1', 'genre', 30); + expect(usage).toMatchObject({ total: 5, spent: 0.6 }); + expect(usage.values).toEqual(new Map([['rock', 3]])); + const sql = (db.pgClient.query as any).mock.calls[0][0] as string; + expect(sql).toContain('WITH completed_plays AS'); + expect(sql).not.toContain('WHERE value IS NOT NULL'); + }); + + it('loads producer and label lineage from fused relationships of the resolved main artist', async () => { + const db = makeMockDb(); + (db.pgClient.query as any).mockResolvedValue({ + rows: [{ + track_id: 'track-1', artist_id: 'artist-1', album_id: 'album-1', genre_id: null, + language: null, instrumentalness: null, favorite: false, new_artist: false, + energy: null, bpm: null, valence: null, release_date: null, + producer_ids: ['producer-from-object', 'producer-from-subject'], + label_ids: ['label-from-object', 'label-from-subject'], + }], + }); + const director = new SessionDirector(db); + const metadata = await (director as any).loadConstraintMetadata('user-1', ['track-1']); + expect(metadata.get('track-1')).toMatchObject({ + artistId: 'artist-1', + producerIds: ['producer-from-object', 'producer-from-subject'], + labelIds: ['label-from-object', 'label-from-subject'], + }); + const sql = (db.pgClient.query as any).mock.calls[0][0] as string; + expect(sql).toContain('FROM claim_fusion cf'); + expect(sql).toContain("cf.predicate = 'produced'"); + expect(sql).toContain("cf.predicate = 'same_label_as'"); + expect(sql).toContain('cf.subject_id = artist.artist_id OR cf.object_id = artist.artist_id'); + expect(sql).toContain('ORDER BY ta.confidence DESC, ta.artist_id'); + }); + + it('carries the retained tail and all 40 album-history plays through replan into constraint selection', async () => { + const db = makeMockDb({ + getVibeSessionTrackIds: vi.fn().mockResolvedValue([]), + getListenerBeliefs: vi.fn().mockResolvedValue([]), + }); + const director = new SessionDirector(db); + const history = Array.from({ length: 40 }, (_, index) => ({ + track_id: `history-${index}`, album_id: index < 3 ? 'history-album' : `old-album-${index}`, + artist_id: `history-artist-${index}`, genre_id: null, bpm: null, energy: null, + valence: null, instrumentalness: null, language: null, release_date: null, + producer_ids: [], label_ids: [], + })); + (db.pgClient.query as any).mockImplementation((sql: string, params: unknown[] = []) => { + if (sql.includes('FROM play_history ph') && sql.includes('LIMIT $2')) return Promise.resolve({ rows: history }); + if (sql.includes('WHERE t.id = ANY($2::uuid[])')) { + const ids = params[1] as string[]; + return Promise.resolve({ rows: ids.map(trackId => ({ + track_id: trackId, + artist_id: `artist-${trackId}`, + album_id: trackId === 'history-album-candidate' ? 'history-album' : `album-${trackId}`, + genre_id: null, language: null, instrumentalness: null, favorite: false, + new_artist: false, energy: null, bpm: null, valence: null, release_date: null, + producer_ids: trackId === 'producer-loop-candidate' ? ['producer-loop'] : [], + label_ids: [], + })) }); + } + return Promise.resolve({ rows: [] }); + }); + vi.spyOn(director, 'buildState').mockResolvedValue({ + energy: 0.5, noveltyHunger: 0.3, sessionAgeMin: 0, lastArtistIds: [], lastGenreIds: [], context: null, + }); + vi.spyOn(director, 'computeFatigue').mockResolvedValue({ + artist: new Map(), album: new Map(), genre: new Map(), language: new Map(), track: new Map(), vocal: 0.5, + }); + vi.spyOn(director, 'getBudgets').mockResolvedValue([]); + vi.spyOn(director, 'buildRepetitionState').mockResolvedValue({ recentTrackIds: new Set(), recentArtistIds: new Set() }); + vi.spyOn(director, 'rankCandidates').mockImplementation(async candidates => candidates); + vi.spyOn(director, 'detectAntiLoop').mockResolvedValue({ dimension: 'producer', values: ['producer-loop'] }); + vi.spyOn(director as any, 'loadArtistMap').mockResolvedValue(new Map()); + + const originalGenerators = [...ALL_GENERATORS]; + ALL_GENERATORS.splice(0, ALL_GENERATORS.length, async () => [ + { ...candidate('producer-loop-candidate'), generatorId: 'comfort' }, + { ...candidate('history-album-candidate'), generatorId: 'comfort' }, + ...Array.from({ length: 5 }, (_, index) => ({ ...candidate(`safe-candidate-comfort-${index}`), generatorId: 'comfort' })), + ...Array.from({ length: 4 }, (_, index) => ({ ...candidate(`safe-candidate-adjacent-${index}`), generatorId: 'adjacent' })), + ...Array.from({ length: 2 }, (_, index) => ({ ...candidate(`safe-candidate-favorite-${index}`), generatorId: 'deep-dive' })), + ]); + let captured: any; + vi.spyOn(director as any, 'constrainedSequence').mockImplementation((params: any) => { + captured = params; + return selectConstrainedSequence(params); + }); + try { + const retained = Array.from({ length: 9 }, (_, index) => ({ ...candidate(`queued-${index}`), generatorId: 'comfort' })); + const plan = await director.replan('user-1', 'session-1', retained, []); + expect(captured.retainedPlan.map((item: { trackId: string }) => item.trackId)).toEqual(retained.map(item => item.trackId)); + expect(captured.albumHistory).toHaveLength(40); + expect(captured.loopDimension).toBe('producer'); + expect(captured.metadata.get('producer-loop-candidate').producerIds).toEqual(['producer-loop']); + expect(plan.map(item => item.trackId)).not.toContain('history-album-candidate'); + expect(plan.map(item => item.trackId)).toContain('safe-candidate-comfort-0'); + } finally { + ALL_GENERATORS.splice(0, ALL_GENERATORS.length, ...originalGenerators); + } + }); + }); + describe('buildState', () => { it('returns state with default values when no prior session', async () => { const db = makeMockDb();