diff --git a/backend/src/services/generators.service.ts b/backend/src/services/generators.service.ts index 40e2d92..d6bc9eb 100644 --- a/backend/src/services/generators.service.ts +++ b/backend/src/services/generators.service.ts @@ -82,6 +82,8 @@ export type Generator = (db: DbService, ctx: GeneratorContext) => Promise 0.5 @@ -97,22 +99,31 @@ async function comfortGenerator(db: DbService, ctx: GeneratorContext): Promise [b.entity_id, b.value])); const artistIds = topArtists.map(b => b.entity_id); + // Two tracks per artist, drawn at random from that artist's best known + // COMFORT_ARTIST_POOL. Taking the top two by fused value instead proposed the + // same handful of tracks in every session for as long as the listener's top + // artists held still, which is most of why a Vibe felt like it never moved. const res = await db.pgClient.query( - `SELECT sub.id, sub.artist_id + `SELECT sampled.id, sampled.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' OR (t.state = 'RECOMMENDED' AND t.probation_status = 'probation')) - AND NOT (t.id = ANY($4::uuid[])) - ) sub - WHERE sub.rn <= 2 - ORDER BY sub.artist_id, sub.rn`, + SELECT ranked.id, ranked.artist_id, + ROW_NUMBER() OVER (PARTITION BY ranked.artist_id ORDER BY RANDOM()) AS pick + 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' OR (t.state = 'RECOMMENDED' AND t.probation_status = 'probation')) + AND NOT (t.id = ANY($4::uuid[])) + ) ranked + WHERE ranked.rn <= ${COMFORT_ARTIST_POOL} + ) sampled + WHERE sampled.pick <= 2 + ORDER BY sampled.artist_id, sampled.pick`, [artistIds, OBJECTIVE_USER, ctx.userId, ctx.recentExclusions] ); @@ -567,9 +578,13 @@ async function contextualGenerator(db: DbService, ctx: GeneratorContext): Promis // --------------------------------------------------------------------------- async function libraryFallbackGenerator(db: DbService, ctx: GeneratorContext): Promise { const res = await db.pgClient.query( + // Probation belongs here too. This is the one generator that does not walk + // the graph, so it is the only way an acquired recommendation nothing has + // enriched yet can ever be offered — which is why Found tracks were sitting + // on disk unplayed. `SELECT t.id FROM tracks t - WHERE t.state = 'LIBRARY' + WHERE (t.state = 'LIBRARY' OR (t.state = 'RECOMMENDED' AND t.probation_status = 'probation')) AND NOT (t.id = ANY($1::uuid[])) ORDER BY RANDOM() LIMIT $2`, diff --git a/backend/src/services/generators.test.ts b/backend/src/services/generators.test.ts index 2594b8c..d3dbe13 100644 --- a/backend/src/services/generators.test.ts +++ b/backend/src/services/generators.test.ts @@ -66,6 +66,21 @@ describe('generators', () => { expect(results[0].generatorId).toBe('comfort'); }); + it('samples two tracks at random from each artist rather than fixing on their top two', async () => { + const db = makeMockDb(); + (db.pgClient.query as any).mockResolvedValue({ rows: [] }); + const ctx = makeCtx({ beliefs: [ + { entity_type: 'artist', entity_id: 'artist-1', value: 0.8, profile: 'longterm', dimension: 'affinity' } as any, + ] }); + + await generatorByName.comfort(db, ctx); + + const [sql] = (db.pgClient.query as any).mock.calls[0]; + expect(sql).toContain('ORDER BY RANDOM()'); + expect(sql).toContain('ranked.rn <= 10'); + expect(sql).toContain('sampled.pick <= 2'); + }); + it('returns empty when no high-affinity artists', async () => { const db = makeMockDb(); const ctx = makeCtx({ beliefs: [ { entity_type: 'artist', entity_id: 'a1', value: 0.3, profile: 'longterm', dimension: 'affinity' } as any ] }); @@ -168,6 +183,19 @@ describe('generators', () => { }); }); + describe('library fallback', () => { + it('offers probation recommendations, which no graph generator can reach yet', async () => { + const db = makeMockDb(); + (db.pgClient.query as any).mockResolvedValue({ rows: [{ id: 'probation-1' }] }); + + const results = await ALL_GENERATORS[8](db, makeCtx()); + + const [sql] = (db.pgClient.query as any).mock.calls[0]; + expect(sql).toContain("t.state = 'RECOMMENDED' AND t.probation_status = 'probation'"); + expect(results[0]).toMatchObject({ trackId: 'probation-1', generatorId: 'library-fallback' }); + }); + }); + describe('experimental', () => { it('returns tracks from unfamiliar genres', async () => { const db = makeMockDb(); diff --git a/backend/src/services/session-director.service.ts b/backend/src/services/session-director.service.ts index 8eb3e29..dc8b170 100644 --- a/backend/src/services/session-director.service.ts +++ b/backend/src/services/session-director.service.ts @@ -58,6 +58,8 @@ export interface CandidateConstraintMetadata { instrumental?: boolean; favorite?: boolean; newArtist?: boolean; + /** No play of this track has ever been recorded for this listener. */ + unheard?: boolean; energy?: number; bpm?: number; valence?: number; @@ -182,11 +184,35 @@ const W_FATIGUE = 0.4; const W_DIVERSITY = 0.3; const W_ENTROPY = 0.2; const W_REPETITION = 0.5; +/** What a never-played track is worth against a well-attested favourite. */ +const W_UNHEARD = 0.35; const PLAN_SIZE = 20; const MAX_ARTIST_PER_PLAN = 2; const MAX_ALBUM_PER_40_TRACKS = 3; const ALBUM_HORIZON_TRACKS = 40; +/** + * The share of a plan reserved for tracks the listener has never played. The + * score bonus alone leaves this to chance; a floor is what makes every Vibe + * move somewhere rather than most of them happening to. + */ +const UNHEARD_PLAN_SHARE = 0.25; + +/** + * How long a track stays out of the Vibe after the listener actually hears it. + * Hard exclusion used to reach back only 40 completed plays, so a track heard + * last night was a candidate again tonight. Counted from a durable `track_served` + * event or a play, never from a plan item — most planned tracks are replaced + * before anyone hears them, and excluding those would burn the library. + */ +const HEARD_COOLDOWN_DAYS = 14; + +/** + * A cooldown must never starve the pool. Whatever the window says, at most this + * share of the eligible library is held back, keeping the newest exclusions. + */ +const COOLDOWN_LIBRARY_SHARE = 0.4; + /** * Producer/label relationships live on artist nodes in the fused graph, not * on track nodes. The graph represents both directions of a relationship, @@ -1516,6 +1542,10 @@ export class SessionDirector { 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 heard + WHERE heard.user_id = $1 AND heard.track_id = t.id + ) AS unheard, NOT EXISTS( SELECT 1 FROM play_history ph JOIN track_artists_v2 old_artist ON old_artist.track_id = ph.track_id @@ -1552,6 +1582,7 @@ export class SessionDirector { ? (row.instrumentalness as number) >= 0.5 : undefined, favorite: row.favorite === true, newArtist: row.new_artist === true && row.artist_id != null, + unheard: row.unheard === true, energy: (row.energy as number | null) ?? undefined, bpm: (row.bpm as number | null) ?? undefined, valence: (row.valence as number | null) ?? undefined, @@ -1627,10 +1658,18 @@ export class SessionDirector { const explorationFit = 1 - Math.abs(novelty - (state.discoveryRadius ?? 0.38)); const sessionSimilarity = sessionSimilarityPenalty(item, c.generatorId, recentSessionFingerprints); + // A track the listener has never heard scores as if it carried real + // relevance. Without this the only generator that samples the whole + // library, library-fallback, entered at relevance 0.05 and lost every + // slot to the same few well-attested favourites — which is how four + // fifths of the library stayed unplayed. + const unheardBonus = item?.unheard ? 1 : 0; + let score = W_ENJOY * c.relevance - W_FATIGUE * avgFatigue + W_DIVERSITY * diversityBonus + W_ENTROPY * entropyBonus + + W_UNHEARD * unheardBonus + 0.08 * explorationFit - sessionSimilarity; @@ -1727,6 +1766,79 @@ export class SessionDirector { // --------------------------------------------------------------- // D.9 — Plan + replan loop // --------------------------------------------------------------- + /** + * Tracks the listener actually heard inside the cooldown window, newest + * first, capped so the exclusion can never hold back more than + * COOLDOWN_LIBRARY_SHARE of what is playable. Both halves matter: a Vibe + * serves tracks the listener never finishes, and the listener plays tracks + * outside any Vibe, and both count as "I have heard this recently". + */ + private async getHeardCooldownTrackIds(userId: string): Promise { + const res = await this.db.pgClient.query( + `WITH eligible AS ( + SELECT COUNT(*)::int AS total FROM tracks + WHERE state = 'LIBRARY' OR (state = 'RECOMMENDED' AND probation_status = 'probation') + ), + heard AS ( + SELECT track_id, MAX(heard_at) AS heard_at FROM ( + SELECT track_id, played_at AS heard_at + FROM play_history + WHERE user_id = $1 AND played_at > NOW() - ($2::int * INTERVAL '1 day') + UNION ALL + SELECT track_id, occurred_at AS heard_at + FROM vibe_events + WHERE user_id = $1 AND type = 'track_served' AND track_id IS NOT NULL + AND occurred_at > NOW() - ($2::int * INTERVAL '1 day') + ) heard_rows + GROUP BY track_id + ) + SELECT ranked.track_id FROM ( + SELECT heard.track_id, + ROW_NUMBER() OVER (ORDER BY heard.heard_at DESC) AS rn, + eligible.total + FROM heard CROSS JOIN eligible + ) ranked + WHERE ranked.rn <= FLOOR(ranked.total * $3::numeric)`, + [userId, HEARD_COOLDOWN_DAYS, COOLDOWN_LIBRARY_SHARE], + ); + return (res.rows as { track_id: string }[]).map(row => row.track_id); + } + + /** + * Guarantee the plan's share of never-played tracks. The ranker's bonus makes + * an unheard track competitive; this makes it certain, by substituting the + * best unheard candidates the ranker did not select for the lowest-priority + * heard entries. The first entry is never swapped — that is the track about + * to play, and the listener asked for it by seeding this Vibe. + */ + private enforceUnheardFloor( + plan: Candidate[], + pool: Candidate[], + metadata: Map, + retainedPlan: Candidate[], + ): Candidate[] { + const isUnheard = (candidate: Candidate) => metadata.get(candidate.trackId)?.unheard === true; + const total = plan.length + retainedPlan.length; + if (total === 0) return plan; + + const present = [...plan, ...retainedPlan].filter(isUnheard).length; + let missing = Math.ceil(total * UNHEARD_PLAN_SHARE) - present; + if (missing <= 0) return plan; + + const planned = new Set([...plan, ...retainedPlan].map(candidate => candidate.trackId)); + // `pool` arrives in ranked order, so this takes the best unused ones. + const spare = pool.filter(candidate => isUnheard(candidate) && !planned.has(candidate.trackId)); + if (spare.length === 0) return plan; + + const filled = [...plan]; + for (let index = filled.length - 1; index > 0 && missing > 0 && spare.length > 0; index--) { + if (isUnheard(filled[index])) continue; + filled[index] = spare.shift()!; + missing--; + } + return filled; + } + async buildPlan( userId: string, sessionId: string, @@ -1817,6 +1929,7 @@ export class SessionDirector { const recentExclusionSet = new Set([ ...recentPlays.map(p => p.trackId), ...durableSessionTrackIds, + ...(await this.getHeardCooldownTrackIds(userId)), ...(options.excludedTrackIds ?? []), ]); if (seedTrackId) recentExclusionSet.add(seedTrackId); @@ -1915,7 +2028,8 @@ export class SessionDirector { // objective snapshots. Do not silently hide a degraded sequence. console.warn('Vibe constraint relaxations', { sessionId, relaxations: constrained.relaxations }); } - return constrained.plan.slice(0, Math.max(0, planSize - retainedPlan.length)); + const plan = constrained.plan.slice(0, Math.max(0, planSize - retainedPlan.length)); + return this.enforceUnheardFloor(plan, deduped, metadata, retainedPlan); } /** Apply the durable delivery budget to fresh arc slots. Retained entries diff --git a/backend/src/services/session-director.test.ts b/backend/src/services/session-director.test.ts index 87a890f..22c9304 100644 --- a/backend/src/services/session-director.test.ts +++ b/backend/src/services/session-director.test.ts @@ -423,6 +423,91 @@ describe('SessionDirector', () => { expect(ranked[0].relevance).toBeGreaterThanOrEqual(ranked[ranked.length - 1].relevance); }); + it('lifts a never-played track above a familiar one of similar relevance', async () => { + const db = makeMockDb(); + (db.pgClient.query as any).mockResolvedValue({ + rows: [ + { track_id: 'heard', artist_id: 'a1', unheard: false, favorite: false }, + { track_id: 'unheard', artist_id: 'a2', unheard: true, favorite: false }, + ], + }); + const director = new SessionDirector(db); + + const candidates = [ + { trackId: 'heard', generatorId: 'comfort', relevance: 0.6, explanation: [] }, + { trackId: 'unheard', generatorId: 'library-fallback', relevance: 0.4, explanation: [] }, + ] as any; + const fatigue = { artist: new Map(), album: new Map(), genre: new Map(), track: new Map(), language: new Map(), vocal: 0 }; + const state = { energy: 0.5, noveltyHunger: 0.3, sessionAgeMin: 10, lastArtistIds: [], lastGenreIds: [], context: null }; + const repetitionState = { recentTrackIds: new Set(), recentArtistIds: new Set() }; + + const ranked = await director.rankCandidates(candidates, fatigue, [], state, repetitionState); + + expect(ranked[0].trackId).toBe('unheard'); + }); + }); + + describe('unheard floor', () => { + const director = new SessionDirector(makeMockDb()); + const meta = (unheard: boolean) => ({ unheard }); + + it('substitutes unheard candidates for the lowest-priority heard entries', () => { + const plan = ['p0', 'p1', 'p2', 'p3'].map(candidate); + const pool = [...plan, ...['u1', 'u2'].map(candidate)]; + const metadata = new Map([ + ...plan.map(item => [item.trackId, meta(false)] as const), + ['u1', meta(true)] as const, + ['u2', meta(true)] as const, + ]); + + const filled = (director as any).enforceUnheardFloor(plan, pool, metadata, []); + + // One in four of a four-item plan, taken from the tail. + expect(filled.map((item: any) => item.trackId)).toEqual(['p0', 'p1', 'p2', 'u1']); + }); + + it('never displaces the track about to play', () => { + const plan = [candidate('p0')]; + const pool = [candidate('p0'), candidate('u1')]; + const metadata = new Map([['p0', meta(false)] as const, ['u1', meta(true)] as const]); + + const filled = (director as any).enforceUnheardFloor(plan, pool, metadata, []); + + expect(filled.map((item: any) => item.trackId)).toEqual(['p0']); + }); + + it('leaves a plan that already meets the floor alone', () => { + const plan = [candidate('u1'), candidate('p1'), candidate('p2'), candidate('p3')]; + const pool = [...plan, candidate('u2')]; + const metadata = new Map([ + ['u1', meta(true)] as const, + ['p1', meta(false)] as const, + ['p2', meta(false)] as const, + ['p3', meta(false)] as const, + ['u2', meta(true)] as const, + ]); + + const filled = (director as any).enforceUnheardFloor(plan, pool, metadata, []); + + expect(filled.map((item: any) => item.trackId)).toEqual(['u1', 'p1', 'p2', 'p3']); + }); + + it('counts the retained tail towards the floor', () => { + const plan = [candidate('p0'), candidate('p1')]; + const retained = [candidate('u0'), candidate('r1')]; + const pool = [...plan, candidate('u1')]; + const metadata = new Map([ + ['p0', meta(false)] as const, + ['p1', meta(false)] as const, + ['u0', meta(true)] as const, + ['r1', meta(false)] as const, + ['u1', meta(true)] as const, + ]); + + const filled = (director as any).enforceUnheardFloor(plan, pool, metadata, retained); + + expect(filled.map((item: any) => item.trackId)).toEqual(['p0', 'p1']); + }); }); describe('recent session fingerprint penalty', () => { @@ -776,6 +861,41 @@ describe('SessionDirector', () => { }); }); + describe('heard cooldown', () => { + it('hands generators every track heard inside the window as a hard exclusion', async () => { + const db = makeMockDb(); + (db.pgClient.query as any).mockImplementation((sql: string) => { + if (sql.includes('heard_rows')) return Promise.resolve({ rows: [{ track_id: 'heard-last-week' }] }); + return Promise.resolve({ rows: [] }); + }); + const director = new SessionDirector(db); + 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 as any, 'loadArtistMap').mockResolvedValue(new Map()); + (db as any).getVibeSessionTrackIds = vi.fn().mockResolvedValue([]); + + const originalGenerators = [...ALL_GENERATORS]; + let seenExclusions: string[] = []; + ALL_GENERATORS.splice(0, ALL_GENERATORS.length, async (_db, ctx) => { + seenExclusions = [...ctx.recentExclusions]; + return [{ ...candidate('heard-last-week'), generatorId: 'comfort' }]; + }); + try { + const plan = await director.buildPlan('user-1', 'session-1'); + expect(seenExclusions).toContain('heard-last-week'); + expect(plan.map(item => item.trackId)).not.toContain('heard-last-week'); + } 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();