fix vibe engine audit findings: pg.Pool, plan replan, dead exclusions, legacy engine removal

Backend:
- app.ts: switch shared pg.Client to pg.Pool with per-transaction clients (#205)
- v2.routes.ts: replace plan instead of appending on replan, fixing self-duplication (#206)
- session-director: populate recentExclusions, per-candidate ranking, batch repetition checks (#209/#211/#213/#215 + minor)
- db.service.ts: claim-fusion watermark, legacy recommendation_batch engine removed (#216/#219/#232)
- app.ts: drop test enqueue-job endpoint (#234)

Frontend:
- AudioEngine/Vibe/usePlaybackStore: dedupe completed feedback, gate feedback to vibe sessions, End Vibe stops playback, Keep toast, shuffle played-set (#207/#236/#237/#238/#239/#240)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
kami
2026-07-17 13:22:06 +04:00
parent 9eb25311c8
commit c41316ee99
10 changed files with 465 additions and 922 deletions
File diff suppressed because it is too large Load Diff
+88 -63
View File
@@ -178,7 +178,7 @@ async function discoveryGenerator(db: DbService, ctx: GeneratorContext): Promise
const maxCandidates = Math.max(1, Math.floor(10 * noveltyTolerance));
const unfamiliarRes = await db.pgClient.query(
`SELECT DISTINCT cf.object_id AS artist_id
`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[])
@@ -191,16 +191,19 @@ async function discoveryGenerator(db: DbService, ctx: GeneratorContext): Promise
AND lb.entity_id = cf.object_id
AND lb.profile IN ('longterm', 'obsession')
)
GROUP BY cf.object_id
LIMIT 30`,
[trustedIds, ctx.userId]
);
const unfamiliarArtistIds = (unfamiliarRes.rows as { artist_id: string }[]).map(r => r.artist_id);
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 FROM (
SELECT DISTINCT t.id
`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')
@@ -208,25 +211,29 @@ async function discoveryGenerator(db: DbService, ctx: GeneratorContext): Promise
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 }[]).map(row => ({
trackId: row.id,
generatorId: 'discovery',
explanation: [{
subjectType: 'artist',
subjectId: unfamiliarArtistIds[0],
predicate: 'credited_main_on',
objectType: 'track',
objectId: row.id,
fusedValue: 0.4,
}],
relevance: 0.4,
}));
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,
};
});
}
// ---------------------------------------------------------------------------
@@ -253,6 +260,11 @@ async function deepDiveGenerator(db: DbService, ctx: GeneratorContext): Promise<
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
@@ -269,19 +281,23 @@ async function deepDiveGenerator(db: DbService, ctx: GeneratorContext): Promise<
[albumIds, ctx.recentExclusions]
);
return (trackRes.rows as { id: string; album_id: string }[]).map(row => ({
trackId: row.id,
generatorId: 'deep-dive',
explanation: [{
subjectType: 'artist',
subjectId: albumArtistMap.get(row.album_id) ?? 'unknown',
predicate: 'credited_main_on',
objectType: 'track',
objectId: row.id,
fusedValue: 0.7,
}],
relevance: 0.7,
}));
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,
};
});
}
// ---------------------------------------------------------------------------
@@ -306,10 +322,11 @@ async function revivalGenerator(db: DbService, ctx: GeneratorContext): Promise<C
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 FROM (
SELECT DISTINCT t.id
`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')
@@ -318,25 +335,29 @@ async function revivalGenerator(db: DbService, ctx: GeneratorContext): Promise<C
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 }[]).map(row => ({
trackId: row.id,
generatorId: 'revival',
explanation: [{
subjectType: 'artist',
subjectId: staleArtistIds[0],
predicate: 'credited_main_on',
objectType: 'track',
objectId: row.id,
fusedValue: 0.6,
}],
relevance: 0.6,
}));
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,
};
});
}
// ---------------------------------------------------------------------------
@@ -455,15 +476,15 @@ async function contextualGenerator(db: DbService, ctx: GeneratorContext): Promis
order: 'DESC',
});
const targetArtistIds = contextualBeliefs
.filter(b => b.entity_type === 'artist' && b.value > 0.2)
.map(b => b.entity_id);
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 FROM (
SELECT DISTINCT t.id
`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')
@@ -472,25 +493,29 @@ async function contextualGenerator(db: DbService, ctx: GeneratorContext): Promis
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 }[]).map(row => ({
trackId: row.id,
generatorId: 'contextual',
explanation: [{
subjectType: 'artist',
subjectId: targetArtistIds[0],
predicate: 'credited_main_on',
objectType: 'track',
objectId: row.id,
fusedValue: 0.5,
}],
relevance: 0.5,
}));
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,
};
});
}
// ---------------------------------------------------------------------------
+131 -46
View File
@@ -28,6 +28,11 @@ export interface DiversityBudget {
spent: number;
}
export interface RepetitionState {
recentTrackIds: Set<string>;
recentArtistIds: Set<string>;
}
const W_ENJOY = 1.0;
const W_FATIGUE = 0.4;
const W_DIVERSITY = 0.3;
@@ -152,7 +157,7 @@ export class SessionDirector {
// D.2 — Fatigue model
// ---------------------------------------------------------------
async computeFatigue(userId: string): Promise<FatigueState> {
// Track fatigue: last 7 days, decay half-life 30d (2592000 seconds)
// Track fatigue: last 7 days, decay time constant 30d (e-folding; half-life ≈ 20.8d)
const TRACK_DECAY_SEC = 30 * 24 * 3600;
const trackRes = await this.db.pgClient.query(
`SELECT ph.track_id,
@@ -167,7 +172,8 @@ export class SessionDirector {
track.set(row.track_id, row.fatigue);
}
// Artist fatigue: last 24h, decay half-life 8h (28800 seconds)
// Artist fatigue: last 24h, decay time constant 8h (28800s) — this is an e-folding
// time (EXP(-t/tau)), not a half-life; the actual half-life is tau*ln(2) ≈ 5.5h
const ARTIST_DECAY_SEC = 8 * 3600;
const artistRes = await this.db.pgClient.query(
`SELECT ta.artist_id,
@@ -183,7 +189,7 @@ export class SessionDirector {
artist.set(row.artist_id, row.fatigue);
}
// Genre fatigue: last 24h, decay half-life 8h
// 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,
LEAST(1.0, SUM(EXP(-EXTRACT(EPOCH FROM (NOW() - ph.played_at)) / $2::float8))) AS fatigue
@@ -198,7 +204,7 @@ export class SessionDirector {
genre.set(row.genre_id, row.fatigue);
}
// Language fatigue: last 2h, decay half-life 1h (3600 seconds)
// Language fatigue: last 2h, decay time constant 1h (3600s, e-folding; half-life ≈ 0.7h)
const LANG_DECAY_SEC = 3600;
const langRes = await this.db.pgClient.query(
`SELECT tl.language,
@@ -427,14 +433,25 @@ export class SessionDirector {
// ---------------------------------------------------------------
// D.5 — Entropy, anti-loop
// ---------------------------------------------------------------
computeEntropy(candidates: Candidate[]): number {
// NOTE: despite the name, this computes the Herfindahl-Hirschman Index (artist
// concentration, 0 = maximally diverse, 1 = single artist) — not entropy.
// `artistIdOf` should resolve the candidate's actual attributed artist; without it
// this falls back to guessing from the first artist-typed explanation edge, which
// for some generators (discovery/revival/contextual) isn't the real artist —
// pass a resolver when a real artist map is available (see rankCandidates).
computeEntropy(candidates: Candidate[], artistIdOf?: (c: Candidate) => string): number {
if (candidates.length === 0) return 0;
const artistCounts = new Map<string, number>();
for (const c of candidates) {
const mainEdge = c.explanation.find(
e => e.subjectType === 'artist' || e.objectType === 'artist'
);
const key = mainEdge?.subjectId ?? mainEdge?.objectId ?? 'unknown';
let key: string;
if (artistIdOf) {
key = artistIdOf(c);
} else {
const mainEdge = c.explanation.find(
e => e.subjectType === 'artist' || e.objectType === 'artist'
);
key = mainEdge?.subjectId ?? mainEdge?.objectId ?? 'unknown';
}
artistCounts.set(key, (artistCounts.get(key) ?? 0) + 1);
}
const n = candidates.length;
@@ -599,6 +616,61 @@ export class SessionDirector {
return false;
}
// Batched version of checkRepetition for ranking a whole candidate pool:
// loads repetition_rules once, then one query for recently-played
// tracks/artists within the max window, and checks membership in JS
// instead of 2-3 sequential queries per candidate.
async buildRepetitionState(userId: string): Promise<RepetitionState> {
const rulesRes = await this.db.pgClient.query(
'SELECT dimension, min_distance FROM repetition_rules WHERE user_id = $1',
[userId]
);
const ruleMap = new Map<string, number>();
for (const row of rulesRes.rows as { dimension: string; min_distance: number }[]) {
ruleMap.set(row.dimension, row.min_distance);
}
const trackMin = ruleMap.get('track') ?? 120;
const artistMin = ruleMap.get('artist') ?? 20;
const recentTrackIds = new Set<string>();
const recentArtistIds = new Set<string>();
const maxMin = Math.max(trackMin, artistMin);
if (maxMin > 0) {
const res = await this.db.pgClient.query(
`SELECT ph.track_id, ta.artist_id, ph.played_at
FROM play_history ph
LEFT JOIN track_artists_v2 ta ON ta.track_id = ph.track_id AND ta.role = 'main'
WHERE ph.user_id = $1 AND ph.completed = true
AND ph.played_at > NOW() - ($2 || ' minutes')::interval`,
[userId, String(maxMin)]
);
const now = Date.now();
for (const row of res.rows as { track_id: string; artist_id: string | null; played_at: Date }[]) {
const ageMin = (now - new Date(row.played_at).getTime()) / 60000;
if (trackMin > 0 && ageMin <= trackMin) recentTrackIds.add(row.track_id);
if (artistMin > 0 && row.artist_id && ageMin <= artistMin) recentArtistIds.add(row.artist_id);
}
}
return { recentTrackIds, recentArtistIds };
}
// Shared track_id -> main artist_id lookup, used by rankCandidates and replan.
private async loadArtistMap(trackIds: string[]): Promise<Map<string, string>> {
const artistMap = new Map<string, string>();
if (trackIds.length === 0) return artistMap;
const artRes = await this.db.pgClient.query(
`SELECT DISTINCT ON (ta.track_id) ta.track_id, ta.artist_id
FROM track_artists_v2 ta
WHERE ta.track_id = ANY($1::uuid[]) AND ta.role = 'main'`,
[trackIds]
);
for (const row of artRes.rows as { track_id: string; artist_id: string }[]) {
artistMap.set(row.track_id, row.artist_id);
}
return artistMap;
}
// ---------------------------------------------------------------
// D.8 — Multi-objective ranking
// ---------------------------------------------------------------
@@ -607,23 +679,12 @@ export class SessionDirector {
fatigue: FatigueState,
budgets: DiversityBudget[],
state: GeneratorContext['state'],
repetitionCheck: (trackId: string, artistId: string) => Promise<boolean>
repetitionState: RepetitionState
): Promise<Candidate[]> {
if (candidates.length === 0) return [];
const trackIds = [...new Set(candidates.map(c => c.trackId))];
const artistMap = new Map<string, string>();
if (trackIds.length > 0) {
const artRes = await this.db.pgClient.query(
`SELECT DISTINCT ON (ta.track_id) ta.track_id, ta.artist_id
FROM track_artists_v2 ta
WHERE ta.track_id = ANY($1::uuid[]) AND ta.role = 'main'`,
[trackIds]
);
for (const row of artRes.rows as { track_id: string; artist_id: string }[]) {
artistMap.set(row.track_id, row.artist_id);
}
}
const artistMap = await this.loadArtistMap(trackIds);
const genreMap = new Map<string, string>();
if (trackIds.length > 0) {
@@ -639,10 +700,17 @@ export class SessionDirector {
}
}
const artistBudget = budgets.find(b => b.dimension === 'artist');
const currentEntropy = this.computeEntropy(candidates);
const currentEntropy = this.computeEntropy(candidates, c => artistMap.get(c.trackId) ?? 'unknown');
const targetEntropy = 0.55;
// Per-candidate artist share within this batch, for a real per-candidate
// entropy contribution instead of the batch-wide constant.
const artistBatchCounts = new Map<string, number>();
for (const c of candidates) {
const aid = artistMap.get(c.trackId) ?? '';
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) ?? '';
@@ -653,10 +721,14 @@ export class SessionDirector {
const genreFatigue = fatigue.genre.get(genreId) ?? 0;
const avgFatigue = (trackFatigue + artistFatigue + genreFatigue) / 3;
const artistSpendRatio = artistBudget ? artistBudget.spent : 0;
const diversityBonus = 1 - artistSpendRatio;
const entropyBonus = 1 - Math.abs(currentEntropy - targetEntropy);
const wouldRepeat = await repetitionCheck(c.trackId, artistId);
// diversityBonus: this artist's own fatigue-weighted share — varies per candidate.
const diversityBonus = 1 - artistFatigue;
// entropyBonus: reward candidates whose artist is underrepresented in this batch.
const artistShare = (artistBatchCounts.get(artistId) ?? 0) / candidates.length;
const entropyBonus = 1 - artistShare;
const wouldRepeat =
repetitionState.recentTrackIds.has(c.trackId) ||
(!!artistId && repetitionState.recentArtistIds.has(artistId));
let score = W_ENJOY * c.relevance
- W_FATIGUE * avgFatigue
@@ -687,6 +759,27 @@ export class SessionDirector {
return scored.map(s => s.candidate);
}
// session_state is otherwise only written once at /v2/vibe/start — persist the
// freshly-computed state vector here so it evolves across the session instead of
// buildState always reading back the boot defaults.
async persistState(sessionId: string, userId: string, state: GeneratorContext['state']): Promise<void> {
await this.db.pgClient.query(
`UPDATE session_state
SET state_vector = $3::jsonb, last_interaction = NOW()
WHERE session_id = $1 AND user_id = $2`,
[
sessionId,
userId,
JSON.stringify({
energy: state.energy,
lastArtistIds: state.lastArtistIds,
lastGenreIds: state.lastGenreIds,
noveltyHunger: state.noveltyHunger,
}),
]
);
}
// ---------------------------------------------------------------
// D.9 — Plan + replan loop
// ---------------------------------------------------------------
@@ -725,6 +818,7 @@ export class SessionDirector {
}));
const state = await this.buildState(userId, sessionId);
await this.persistState(sessionId, userId, state);
const fatigue = await this.computeFatigue(userId);
const budgets = await this.getBudgets(userId);
@@ -737,7 +831,7 @@ export class SessionDirector {
seedArtistId = await this.resolveSeedArtistId(seedTrackId) ?? null;
}
const recentExclusions: string[] = [];
const recentExclusions: string[] = recentPlays.map(p => p.trackId);
const toleranceMap: Record<string, number> = {};
const discoveryBeliefs = allBeliefs.filter(b => b.profile === 'discovery');
for (const b of discoveryBeliefs) {
@@ -764,28 +858,17 @@ export class SessionDirector {
return [];
}
const repetitionCheckFn = (tid: string, aid: string) =>
this.checkRepetition(tid, aid, userId);
const repetitionState = await this.buildRepetitionState(userId);
const ranked = await this.rankCandidates(
allCandidates, fatigue, budgets, state, repetitionCheckFn
allCandidates, fatigue, budgets, state, repetitionState
);
const loopDim = await this.detectAntiLoop(state, fatigue, budgets, recentPlays);
let forcedExperimental = false;
if (loopDim && ranked.length > 0) {
const expCtx: GeneratorContext = {
...ctx,
recentExclusions: ctx.recentExclusions.slice(0, Math.min(ctx.recentExclusions.length, 50)),
};
const extraCandidates: Candidate[] = [];
for (const gen of ALL_GENERATORS) {
const result = await gen(this.db, expCtx);
extraCandidates.push(...result);
}
const expRanked = await this.rankCandidates(
extraCandidates, fatigue, budgets, state, repetitionCheckFn
);
const injected = expRanked.filter(
// 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);
@@ -855,6 +938,7 @@ export class SessionDirector {
const fatigue = await this.computeFatigue(userId);
const budgets = await this.getBudgets(userId);
const state = await this.buildState(userId, sessionId);
await this.persistState(sessionId, userId, state);
// Fetch recent plays for anti-loop
const recentPlaysRes = await this.db.pgClient.query(
@@ -892,7 +976,8 @@ export class SessionDirector {
return this.buildPlan(userId, sessionId, seedTrackId);
}
const entropy = this.computeEntropy(currentPlan);
const planArtistMap = await this.loadArtistMap([...new Set(currentPlan.map(c => c.trackId))]);
const entropy = this.computeEntropy(currentPlan, c => planArtistMap.get(c.trackId) ?? 'unknown');
if (Math.abs(entropy - 0.55) > 0.2) {
return this.buildPlan(userId, sessionId, seedTrackId);
}
@@ -95,7 +95,8 @@ describe('SessionDirector', () => {
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 };
const ranked = await director.rankCandidates(candidates, fatigue, budgets, state, async () => false);
const repetitionState = { recentTrackIds: new Set<string>(), recentArtistIds: new Set<string>() };
const ranked = await director.rankCandidates(candidates, fatigue, budgets, state, repetitionState);
expect(ranked[0].relevance).toBeGreaterThanOrEqual(ranked[ranked.length - 1].relevance);
});
});