feat(vibe): plan musical arcs and callbacks

This commit is contained in:
kami
2026-08-02 00:49:27 +04:00
parent 89a23e3703
commit fe13798c99
7 changed files with 910 additions and 42 deletions
@@ -68,6 +68,23 @@ describe('durable Vibe session routes', () => {
await app.close();
});
it('reserves track_served for the authoritative /next operation', async () => {
const { app, coordinator } = await appWithCoordinator();
const result = await app.inject({
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/events`,
payload: {
type: 'track_served',
trackId: TRACK_ID,
payload: { planVersionId: '33333333-3333-4333-8333-333333333333', ordinal: 0 },
},
});
expect(result.statusCode).toBe(400);
expect(result.json()).toEqual({ error: 'track_served is reserved for the server /next operation' });
expect(coordinator.appendEvent).not.toHaveBeenCalled();
await app.close();
});
it('returns a lifecycle conflict when an initial plan race ends or replaces the session', async () => {
const { app, coordinator } = await appWithCoordinator();
coordinator.start.mockRejectedValueOnce(
@@ -101,6 +101,12 @@ export default async function vibeSessionsRoutes(
if (!body || !VIBE_EVENT_TYPES.includes(body.type as typeof VIBE_EVENT_TYPES[number])) {
return reply.code(400).send({ error: 'type must be a supported Vibe event type' });
}
// Delivery is an authoritative state transition performed only by /next.
// Accepting this event from the public ledger endpoint would let a client
// fabricate exposure rows and consume the server-side surprise budget.
if (body.type === 'track_served') {
return reply.code(400).send({ error: 'track_served is reserved for the server /next operation' });
}
if (body.eventId !== undefined && !validUuid(body.eventId)) {
return reply.code(400).send({ error: 'eventId must be a UUID' });
}
@@ -20,6 +20,20 @@ export interface Candidate {
generatorId: string;
explanation: ClaimEdge[];
relevance: number;
/**
* Filled by the session director after sequence planning. Generators remain
* deliberately unaware of slots and objectives, while the durable plan can
* retain why this particular candidate won its position.
*/
plan?: {
slotRole: string;
score: number;
scoreBreakdown: Record<string, unknown>;
explanation: Record<string, unknown>;
/** Revision-level policy and constraint evidence, copied into the durable
* objective snapshot by the coordinator. */
objective?: Record<string, unknown>;
};
}
export interface GeneratorContext {
+495 -24
View File
@@ -22,6 +22,8 @@ export interface RecentPlay {
vocal: boolean | null;
decade: number | null;
valence: number | null;
acousticness?: number | null;
instrumentalness?: number | null;
albumId?: string | null;
producerIds?: string[];
labelIds?: string[];
@@ -40,6 +42,8 @@ export interface CandidateConstraintMetadata {
energy?: number;
bpm?: number;
valence?: number;
acousticness?: number;
instrumentalness?: number;
decade?: number;
producerIds?: string[];
labelIds?: string[];
@@ -87,6 +91,46 @@ export interface PlanBuildOptions {
retainedPlan?: Candidate[];
}
export interface ArcRange {
min?: number;
max?: number;
/** Preferred delta from the immediately preceding track. */
maxDelta?: number;
}
export interface CallbackToken {
id: string;
phase: 'anchor' | 'return';
/** The return may use the anchor artist, genre, or simply a familiar item. */
theme: 'artist' | 'genre' | 'favorite';
minSeparation: number;
maxSeparation: number;
}
export interface SurpriseDirective {
/** A surprise is bounded and must be followed by a recovery anchor. */
recoveryRole: string;
maxPerPlan: number;
maxPerHour: number;
}
export interface ArcSlot {
position: number;
role: string;
/** Optional only for legacy callers; getArcSlots always supplies targets. */
targets?: {
energy?: ArcRange;
tempo?: ArcRange;
valence?: ArcRange;
acousticness?: ArcRange;
instrumentality?: ArcRange;
/** 0 familiar, 1 exploratory. Derived from source and known metadata. */
novelty?: ArcRange;
};
callback?: CallbackToken;
surprise?: SurpriseDirective;
}
const W_ENJOY = 1.0;
const W_FATIGUE = 0.4;
const W_DIVERSITY = 0.3;
@@ -208,12 +252,106 @@ function metadataFromRecentPlay(play: RecentPlay): CandidateConstraintMetadata {
energy: play.energy ?? undefined,
bpm: play.bpm ?? undefined,
valence: play.valence ?? undefined,
acousticness: play.acousticness ?? undefined,
instrumentalness: play.instrumentalness ?? undefined,
decade: play.decade ?? undefined,
producerIds: play.producerIds ?? [],
labelIds: play.labelIds ?? [],
};
}
function candidateNovelty(candidate: Candidate, metadata: CandidateConstraintMetadata | undefined): number {
if (metadata?.favorite) return 0.05;
if (metadata?.newArtist) return 0.8;
if (candidate.generatorId === 'discovery') return 0.7;
if (candidate.generatorId === 'adjacent') return 0.45;
if (candidate.generatorId === 'deep-dive') return 0.2;
return 0.3;
}
function rangeScore(value: number | undefined, range: ArcRange | undefined): { score: number; known: boolean } {
if (!range) return { score: 1, known: true };
if (value === undefined || !Number.isFinite(value)) return { score: 0.5, known: false };
const min = range.min ?? -Infinity;
const max = range.max ?? Infinity;
if (value >= min && value <= max) return { score: 1, known: true };
const distance = value < min ? min - value : value - max;
// A target miss is a soft degradation. It is never an eligibility failure.
return { score: Math.max(0, 1 - distance / 0.5), known: true };
}
/**
* Unknown analysis must remain playable, but it cannot displace a candidate
* whose measured audio features satisfy this slot. This is intentionally
* limited to absolute targets: `maxDelta` is a transition preference, not an
* independent eligibility rule.
*/
function matchesMeasuredArcTargets(
metadata: CandidateConstraintMetadata | undefined,
slot: ArcSlot,
): boolean {
const targets = slot.targets ?? {};
const dimensions: Array<[number | undefined, ArcRange | undefined]> = [
[metadata?.energy, targets.energy],
[metadata?.bpm, targets.tempo],
[metadata?.valence, targets.valence],
[metadata?.acousticness, targets.acousticness],
[metadata?.instrumentalness, targets.instrumentality],
];
const absolute = dimensions.filter(([, range]) => range && (range.min !== undefined || range.max !== undefined));
if (absolute.length === 0) return false;
return absolute.every(([value, range]) => {
if (value === undefined || !Number.isFinite(value)) return false;
return value >= (range!.min ?? -Infinity) && value <= (range!.max ?? Infinity);
});
}
function hasAbsoluteArcTargets(slot: ArcSlot): boolean {
const targets = slot.targets ?? {};
return [targets.energy, targets.tempo, targets.valence, targets.acousticness, targets.instrumentality]
.some(range => range && (range.min !== undefined || range.max !== undefined));
}
/** A local DJ transition is useful even with partial analysis: unknown values
* lower confidence, whereas known values are evaluated smoothly. */
export function scoreArcTransition(
candidate: Candidate,
candidateMetadata: CandidateConstraintMetadata | undefined,
previousMetadata: CandidateConstraintMetadata | undefined,
slot: ArcSlot,
): { score: number; confidence: number; targetScore: number; transitionScore: number } {
const targetsForSlot = slot.targets ?? {};
const targets = [
rangeScore(candidateMetadata?.energy, targetsForSlot.energy),
rangeScore(candidateMetadata?.bpm, targetsForSlot.tempo),
rangeScore(candidateMetadata?.valence, targetsForSlot.valence),
rangeScore(candidateMetadata?.acousticness, targetsForSlot.acousticness),
rangeScore(candidateMetadata?.instrumentalness, targetsForSlot.instrumentality),
rangeScore(candidateNovelty(candidate, candidateMetadata), targetsForSlot.novelty),
];
const targetScore = targets.reduce((total, item) => total + item.score, 0) / targets.length;
const knownTargets = targets.filter(item => item.known).length;
const deltas: Array<[number | undefined, number | undefined, ArcRange | undefined, number]> = [
[candidateMetadata?.energy, previousMetadata?.energy, targetsForSlot.energy, 0.3],
[candidateMetadata?.bpm, previousMetadata?.bpm, targetsForSlot.tempo, 35],
[candidateMetadata?.valence, previousMetadata?.valence, targetsForSlot.valence, 0.3],
[candidateMetadata?.acousticness, previousMetadata?.acousticness, targetsForSlot.acousticness, 0.3],
[candidateMetadata?.instrumentalness, previousMetadata?.instrumentalness, targetsForSlot.instrumentality, 0.3],
];
const knownDeltas = deltas.filter(([current, previous]) => current !== undefined && previous !== undefined);
const transitionScore = knownDeltas.length === 0 ? 0.5 : knownDeltas.reduce((total, [current, previous, target, defaultMaxDelta]) => {
const maxDelta = target?.maxDelta ?? defaultMaxDelta;
return total + Math.max(0, 1 - Math.abs(current! - previous!) / maxDelta);
}, 0) / knownDeltas.length;
const confidence = (knownTargets + knownDeltas.length) / (targets.length + deltas.length);
return {
score: targetScore * 0.65 + transitionScore * 0.35,
confidence,
targetScore,
transitionScore,
};
}
/**
* The constraint layer is intentionally pure. Ranking supplies its candidate
* order; this layer chooses a feasible sequence and returns every soft rule it
@@ -222,7 +360,7 @@ function metadataFromRecentPlay(play: RecentPlay): CandidateConstraintMetadata {
*/
export function selectConstrainedSequence(params: {
candidates: Candidate[];
slots: { position: number; role: string }[];
slots: ArcSlot[];
metadata: Map<string, CandidateConstraintMetadata>;
budgets: DiversityBudget[];
roleToGeneratorIds: (role: string) => string[];
@@ -249,6 +387,10 @@ export function selectConstrainedSequence(params: {
const softDimensions = ['artist', 'genre', 'language'];
const planLength = retainedPlan.length + slots.length;
const loopedValueSet = new Set([...loopedValues, ...(loopedValue ? [loopedValue] : [])]);
let previousMetadata = retainedPlan.length > 0
? metadata.get(retainedPlan[retainedPlan.length - 1].trackId)
: undefined;
const callbackAnchors = new Map<string, { metadata: CandidateConstraintMetadata; position: number }>();
// 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.
@@ -322,26 +464,105 @@ export function selectConstrainedSequence(params: {
increment('album', valuesForDimension(metadata.get(retained.trackId), 'album'));
}
const hardConstraints = {
artistMaxPerPlan: MAX_ARTIST_PER_PLAN,
albumMaxPer40Tracks: MAX_ALBUM_PER_40_TRACKS,
uniqueTracks: true,
};
for (let position = 0; position < slots.length; position++) {
const preferred = roleToGeneratorIds(slots[position].role);
const ordered = orderedForRole(slots[position].role);
let slot = slots[position];
let chosen: Candidate | undefined;
let chosenTransition: ReturnType<typeof scoreArcTransition> | undefined;
let chosenCallbackScore = 0;
let chosenSurpriseScore = 0;
let chosenSelectionScore = 0;
let chosenRankScore = 0;
let relaxedSoft = false;
let relaxedArc = false;
let downgradedSurpriseGenerators: Set<string> | undefined;
/** A surprise is only admitted if its declared recovery role still has a
* hard-feasible anchor after the surprise itself has consumed its caps. */
const canReserveRecovery = (surpriseCandidate: Candidate, surpriseMetadata: CandidateConstraintMetadata | undefined) => {
if (!slot.surprise) return true;
const recoveryGenerators = new Set(roleToGeneratorIds(slot.surprise.recoveryRole));
const surpriseArtist = valueForDimension(surpriseMetadata, 'artist');
const surpriseAlbum = valueForDimension(surpriseMetadata, 'album');
return candidates.some(recovery => {
if (recovery.trackId === surpriseCandidate.trackId || selectedIds.has(recovery.trackId)) return false;
if (!recoveryGenerators.has(recovery.generatorId)) return false;
const recoveryMetadata = metadata.get(recovery.trackId);
const artist = valueForDimension(recoveryMetadata, 'artist');
const album = valueForDimension(recoveryMetadata, 'album');
const prospectiveArtistCount = artist && artist === surpriseArtist ? 1 : 0;
const prospectiveAlbumCount = album && album === surpriseAlbum ? 1 : 0;
return (!artist || count('artist', artist) + prospectiveArtistCount < MAX_ARTIST_PER_PLAN)
&& (!album || count('album', album) + prospectiveAlbumCount < MAX_ALBUM_PER_40_TRACKS);
});
};
// A surprise whose recovery cannot be reserved is downgraded before
// selection. This prevents an unrecoverable high-risk pick from being
// written to a revision merely because the later recovery slot is empty.
if (slot.surprise) {
const surpriseGenerators = new Set(roleToGeneratorIds(slot.role));
const reservable = candidates.some(candidate =>
!selectedIds.has(candidate.trackId)
&& surpriseGenerators.has(candidate.generatorId)
&& canReserveRecovery(candidate, metadata.get(candidate.trackId)),
);
if (!reservable) {
relaxations.push({
constraint: 'surprise_recovery',
stage: 'arc_precision',
reason: 'no hard-feasible recovery anchor remained for the requested surprise',
});
downgradedSurpriseGenerators = surpriseGenerators;
slot = {
...slot,
role: slot.surprise.recoveryRole,
surprise: undefined,
targets: { ...(slot.targets ?? {}), novelty: { max: 0.35 } },
};
}
}
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) {
const preferred = roleToGeneratorIds(slot.role);
const ordered = orderedForRole(slot.role);
let best: { candidate: Candidate; transition: ReturnType<typeof scoreArcTransition>; callbackScore: number; surpriseScore: number; score: number; rankScore: number } | undefined;
const fittingArcCandidateExists = hasAbsoluteArcTargets(slot) && ordered.some(candidate => {
if (selectedIds.has(candidate.trackId)) return false;
const m = metadata.get(candidate.trackId);
const artist = valueForDimension(m, 'artist');
const album = valueForDimension(m, 'album');
return (!artist || count('artist', artist) < MAX_ARTIST_PER_PLAN)
&& (!album || count('album', album) < MAX_ALBUM_PER_40_TRACKS)
&& matchesMeasuredArcTargets(m, slot);
});
for (const [candidateIndex, candidate] of ordered.entries()) {
if (selectedIds.has(candidate.trackId)) continue;
// Do not relabel the rejected high-risk item as a recovery merely
// because source relaxation is allowed for the downgraded slot.
if (downgradedSurpriseGenerators?.has(candidate.generatorId)) 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;
// A surprise is never silently satisfied by an arbitrary comfort
// fallback. If no explainable exploratory source is available the
// planner reports a shortened/degraded sequence instead.
if ((!relaxArc || slot.surprise) && !preferred.includes(candidate.generatorId)) continue;
// Arc measurements are an enforceable preference when the catalog
// gives us a hard-feasible measured fit. Sparse analysis falls back
// gracefully and is documented as an arc-precision relaxation below.
if (fittingArcCandidateExists && !matchesMeasuredArcTargets(m, slot)) continue;
if (!relaxSoft && !explicitIntent) {
if (softDimensions.some(d => exceedsUpperLimit(d, valuesForDimension(m, d)))) continue;
@@ -352,30 +573,134 @@ export function selectConstrainedSequence(params: {
if (!deficits.some(d => valuesForDimension(m, d).includes('true'))) continue;
}
}
chosen = candidate;
const transition = scoreArcTransition(candidate, m, previousMetadata, slot);
const anchor = slot.callback ? callbackAnchors.get(slot.callback.id) : undefined;
let callbackScore = 0;
if (slot.callback?.phase === 'return' && anchor) {
const separation = position - anchor.position;
const themeMatches = slot.callback.theme === 'favorite'
? m?.favorite === true
: slot.callback.theme === 'artist'
? !!m?.artistId && m.artistId === anchor.metadata.artistId
: !!m?.genreId && m.genreId === anchor.metadata.genreId;
// The token is preference-only: a hard diversity/repetition rule may
// still make a literal return impossible, in which case familiarity
// provides a graceful callback rather than a failed plan.
const inWindow = separation >= slot.callback.minSeparation && separation <= slot.callback.maxSeparation;
callbackScore = inWindow && themeMatches ? 1 : (m?.favorite ? 0.35 : 0);
}
const surpriseScore = slot.surprise
? (candidate.generatorId === 'discovery' || candidate.generatorId === 'adjacent' || m?.newArtist ? 1 : 0)
: 0;
if (slot.surprise && !canReserveRecovery(candidate, m)) continue;
// Keep ranking meaningful, but let the sequence controller prefer a
// fitting continuation over the next independent highest-ranked song.
const rankScore = 1 - candidateIndex / Math.max(1, ordered.length);
const score = rankScore * 0.45 + transition.score * 0.4 + callbackScore * 0.1 + surpriseScore * 0.05;
if (!best || score > best.score || (score === best.score && candidate.trackId.localeCompare(best.candidate.trackId) < 0)) {
best = { candidate, transition, callbackScore, surpriseScore, score, rankScore };
}
}
if (best) {
chosen = best.candidate;
chosenTransition = best.transition;
chosenCallbackScore = best.callbackScore;
chosenSurpriseScore = best.surpriseScore;
chosenSelectionScore = best.score;
chosenRankScore = best.rankScore;
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')) {
if ((hasAbsoluteArcTargets(slot) && !matchesMeasuredArcTargets(metadata.get(chosen.trackId), slot))
&& !relaxations.some(r => r.stage === 'arc_precision' && r.constraint === 'arc_precision')) {
relaxations.push({ constraint: 'arc_precision', stage: 'arc_precision', reason: 'no hard-feasible measured candidate satisfied the requested arc targets' });
}
if (relaxedArc && !relaxations.some(r => r.constraint === 'arc_source')) {
relaxations.push({ constraint: 'arc_source', stage: 'arc_precision', reason: 'no hard-feasible candidate matched the requested arc slot' });
}
selected.push(chosen);
const chosenMetadata = metadata.get(chosen.trackId);
const decorated: Candidate = {
...chosen,
plan: {
slotRole: slot.role,
score: chosenSelectionScore || (chosenTransition ? chosenTransition.score : chosen.relevance),
scoreBreakdown: {
relevance: chosen.relevance,
rankingPositionScore: chosenRankScore,
transition: chosenTransition?.transitionScore ?? 0.5,
arcTarget: chosenTransition?.targetScore ?? 0.5,
transitionConfidence: chosenTransition?.confidence ?? 0,
callback: chosenCallbackScore,
surprise: chosenSurpriseScore,
selection: chosenSelectionScore,
weights: { rank: 0.45, transition: 0.4, callback: 0.1, surprise: 0.05 },
},
explanation: {
arcRole: slot.role,
targets: slot.targets,
callback: slot.callback ? {
id: slot.callback.id,
phase: slot.callback.phase,
theme: slot.callback.theme,
minSeparation: slot.callback.minSeparation,
maxSeparation: slot.callback.maxSeparation,
matchScore: chosenCallbackScore,
} : null,
surprise: slot.surprise ? {
recoveryRole: slot.surprise.recoveryRole,
maxPerPlan: slot.surprise.maxPerPlan,
maxPerHour: slot.surprise.maxPerHour,
selectionScore: chosenSurpriseScore,
} : null,
arcPrecision: {
measuredFit: matchesMeasuredArcTargets(chosenMetadata, slot),
enforcedBecauseFitExists: hasAbsoluteArcTargets(slot),
},
},
},
};
selected.push(decorated);
selectedIds.add(chosen.trackId);
const m = metadata.get(chosen.trackId);
const m = chosenMetadata;
for (const dimension of ['artist', 'album', 'genre', 'language', ...lowerDimensions]) {
increment(dimension, valuesForDimension(m, dimension));
}
previousMetadata = chosenMetadata;
if (slot.callback?.phase === 'anchor' && chosenMetadata) {
callbackAnchors.set(slot.callback.id, { metadata: chosenMetadata, position });
}
}
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 };
const policy = {
selection: 'rank_transition_callback_surprise_v1',
weights: { rank: 0.45, transition: 0.4, callback: 0.1, surprise: 0.05 },
arcTargets: 'enforced_when_a_hard_feasible_measured_fit_exists',
surprise: 'requires_reserved_recovery_anchor',
};
const constraints = {
hard: hardConstraints,
softBudgetDimensions: [...softDimensions, ...lowerDimensions],
loopDimension: loopDimension ?? null,
};
const objective = { policy, constraints, relaxations };
return {
plan: selected.map(candidate => ({
...candidate,
plan: candidate.plan ? {
...candidate.plan,
explanation: { ...candidate.plan.explanation, policy, constraints, relaxations },
objective,
} : candidate.plan,
})),
relaxations,
};
}
export class SessionDirector {
@@ -723,27 +1048,77 @@ export class SessionDirector {
return 'comfort';
}
getArcSlots(arcType: string, count: number): { position: number; role: string }[] {
getArcSlots(arcType: string, count: number): ArcSlot[] {
const pattern = this.getArcPattern(arcType);
const slots: { position: number; role: string }[] = [];
const slots: ArcSlot[] = [];
for (let i = 0; i < count; i++) {
slots.push({ position: i, role: pattern[i % pattern.length] });
const template = pattern[i % pattern.length];
// A repeated template must not replay an old callback token. The token
// identity is scoped to the cycle so each return has one clear anchor.
const cycle = Math.floor(i / pattern.length);
slots.push({
...template,
position: i,
callback: template.callback ? { ...template.callback, id: `${template.callback.id}:${cycle}` } : undefined,
surprise: template.surprise && cycle === 0 ? template.surprise : undefined,
});
}
return slots;
}
private getArcPattern(arcType: string): string[] {
private getArcPattern(arcType: string): Omit<ArcSlot, 'position'>[] {
const familiar = { novelty: { max: 0.35 } };
const adjacent = { novelty: { min: 0.2, max: 0.65 } };
const newDiscovery = { novelty: { min: 0.55, max: 1 } };
const callbackAnchor: CallbackToken = {
id: 'comfort-theme', phase: 'anchor', theme: 'artist', minSeparation: 2, maxSeparation: 6,
};
const callbackReturn: CallbackToken = {
...callbackAnchor, phase: 'return',
};
const surprise: SurpriseDirective = { recoveryRole: 'favorite', maxPerPlan: 1, maxPerHour: 1 };
const energeticSurprise: SurpriseDirective = { recoveryRole: 'cooldown', maxPerPlan: 1, maxPerHour: 1 };
switch (arcType) {
case 'comfort':
return ['known', 'known', 'known', 'known', 'adjacent', 'adjacent', 'adjacent', 'adjacent', 'favorite', 'favorite'];
return [
{ role: 'known', targets: { energy: { min: 0.3, max: 0.65, maxDelta: 0.2 }, tempo: { maxDelta: 25 }, ...familiar } },
{ role: 'known', targets: { energy: { min: 0.3, max: 0.65, maxDelta: 0.18 }, tempo: { maxDelta: 22 }, ...familiar } },
{ role: 'adjacent', targets: { energy: { min: 0.35, max: 0.7, maxDelta: 0.22 }, tempo: { maxDelta: 28 }, ...adjacent } },
{ role: 'favorite', targets: { energy: { min: 0.3, max: 0.7, maxDelta: 0.25 }, valence: { maxDelta: 0.3 }, ...familiar }, callback: callbackAnchor },
{ role: 'adjacent', targets: { energy: { min: 0.35, max: 0.75, maxDelta: 0.25 }, ...adjacent } },
{ role: 'favorite', targets: { energy: { min: 0.3, max: 0.7, maxDelta: 0.25 }, ...familiar }, callback: callbackReturn },
{ role: 'surprise', targets: { energy: { min: 0.25, max: 0.75, maxDelta: 0.3 }, ...newDiscovery }, surprise },
{ role: 'favorite', targets: { energy: { min: 0.25, max: 0.65, maxDelta: 0.25 }, acousticness: { min: 0.1 }, ...familiar } },
];
case 'discovery':
return ['favorite', 'similar', 'new', 'favorite'];
return [
{ role: 'favorite', targets: { energy: { maxDelta: 0.25 }, ...familiar }, callback: callbackAnchor },
{ role: 'similar', targets: { energy: { maxDelta: 0.25 }, tempo: { maxDelta: 30 }, ...adjacent } },
{ role: 'surprise', targets: { energy: { maxDelta: 0.3 }, ...newDiscovery }, surprise },
{ role: 'favorite', targets: { energy: { maxDelta: 0.25 }, ...familiar }, callback: callbackReturn },
];
case 'energetic':
return ['medium', 'medium', 'high', 'high', 'high', 'peak', 'cooldown', 'cooldown'];
return [
{ role: 'medium', targets: { energy: { min: 0.45, max: 0.7, maxDelta: 0.18 }, tempo: { min: 95, max: 145, maxDelta: 25 }, ...familiar } },
{ role: 'medium', targets: { energy: { min: 0.5, max: 0.75, maxDelta: 0.15 }, tempo: { min: 100, max: 155, maxDelta: 20 }, ...adjacent } },
{ role: 'high', targets: { energy: { min: 0.65, max: 0.9, maxDelta: 0.22 }, tempo: { min: 110, max: 175, maxDelta: 28 }, ...adjacent } },
{ role: 'high', targets: { energy: { min: 0.7, max: 0.95, maxDelta: 0.18 }, tempo: { min: 115, max: 180, maxDelta: 25 }, ...newDiscovery } },
{ role: 'peak', targets: { energy: { min: 0.8, max: 1, maxDelta: 0.2 }, tempo: { min: 120, max: 190, maxDelta: 30 }, valence: { min: 0.45, maxDelta: 0.3 }, ...newDiscovery } },
{ role: 'surprise', targets: { energy: { min: 0.7, max: 1, maxDelta: 0.25 }, tempo: { maxDelta: 35 }, ...newDiscovery }, surprise: energeticSurprise },
{ role: 'cooldown', targets: { energy: { min: 0.45, max: 0.75, maxDelta: 0.3 }, tempo: { maxDelta: 35 }, ...familiar } },
{ role: 'cooldown', targets: { energy: { min: 0.35, max: 0.65, maxDelta: 0.2 }, acousticness: { min: 0.1 }, ...familiar } },
];
case 'late-night':
return ['soft', 'soft', 'ambient', 'ambient', 'acoustic', 'slow'];
return [
{ role: 'soft', targets: { energy: { max: 0.4, maxDelta: 0.15 }, tempo: { max: 115, maxDelta: 20 }, acousticness: { min: 0.2 }, ...familiar } },
{ role: 'soft', targets: { energy: { max: 0.38, maxDelta: 0.12 }, tempo: { max: 110, maxDelta: 18 }, ...familiar } },
{ role: 'ambient', targets: { energy: { max: 0.3, maxDelta: 0.15 }, tempo: { max: 100, maxDelta: 18 }, instrumentality: { min: 0.35 }, ...adjacent } },
{ role: 'ambient', targets: { energy: { max: 0.28, maxDelta: 0.1 }, acousticness: { min: 0.25 }, instrumentality: { min: 0.35 }, ...adjacent } },
{ role: 'acoustic', targets: { energy: { max: 0.45, maxDelta: 0.2 }, acousticness: { min: 0.35 }, ...familiar }, callback: callbackAnchor },
{ role: 'slow', targets: { energy: { max: 0.4, maxDelta: 0.15 }, tempo: { max: 105, maxDelta: 18 }, ...familiar }, callback: callbackReturn },
];
default:
return ['known', 'known', 'known', 'known', 'adjacent', 'adjacent', 'adjacent', 'adjacent', 'favorite', 'favorite'];
return this.getArcPattern('comfort');
}
}
@@ -766,6 +1141,8 @@ export class SessionDirector {
return ['discovery'];
case 'peak':
return ['deep-dive', 'contextual'];
case 'surprise':
return ['discovery', 'adjacent'];
case 'ambient':
return ['contextual', 'comfort'];
default:
@@ -1075,7 +1452,7 @@ export class SessionDirector {
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,
taf.energy, taf.bpm, taf.valence, taf.acousticness, taf.instrumentalness,
${lineageIdsSql('produced', 'artist')} AS producer_ids,
${lineageIdsSql('same_label_as', 'artist')} AS label_ids
FROM tracks t
@@ -1107,6 +1484,8 @@ export class SessionDirector {
energy: (row.energy as number | null) ?? undefined,
bpm: (row.bpm as number | null) ?? undefined,
valence: (row.valence as number | null) ?? undefined,
acousticness: (row.acousticness as number | null) ?? undefined,
instrumentalness: (row.instrumentalness 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[] : [],
@@ -1223,6 +1602,48 @@ export class SessionDirector {
);
}
/**
* The surprise budget is consumed when a surprise is actually delivered to
* playback, not when it is merely present in a preview. Replans copy the
* unserved tail into a new immutable revision, so counting `slot_role`
* rows would charge the same unserved surprise once per revision. The
* `track_served` event is the durable, exactly-once delivery boundary; its
* plan-version payload lets us identify the precise immutable item that
* was exposed.
*/
private async getSessionSurpriseUsage(sessionId: string, userId: string): Promise<{ session: number; hour: number }> {
const res = await this.db.pgClient.query(
`WITH surprise_exposures AS (
SELECT DISTINCT e.id, e.occurred_at
FROM vibe_events e
JOIN vibe_plan_items item
ON item.plan_version_id = CASE
WHEN e.payload->>'planVersionId' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
THEN (e.payload->>'planVersionId')::uuid
END
AND item.ordinal = CASE
WHEN (e.payload->>'ordinal') ~ '^(0|[1-9][0-9]{0,8})$'
OR ((e.payload->>'ordinal') ~ '^1[0-9]{9}$' AND (e.payload->>'ordinal') <= '2147483647')
THEN (e.payload->>'ordinal')::integer
END
AND item.track_id = e.track_id
JOIN vibe_plan_versions version
ON version.id = item.plan_version_id
AND version.session_id = e.session_id
WHERE e.session_id = $1
AND e.user_id = $2
AND e.type = 'track_served'
AND item.slot_role = 'surprise'
)
SELECT COUNT(*)::int AS session_count,
COUNT(*) FILTER (WHERE occurred_at > NOW() - INTERVAL '1 hour')::int AS hour_count
FROM surprise_exposures`,
[sessionId, userId],
);
const row = res.rows[0] as { session_count?: number | string; hour_count?: number | string } | undefined;
return { session: Number(row?.session_count ?? 0), hour: Number(row?.hour_count ?? 0) };
}
// ---------------------------------------------------------------
// D.9 — Plan + replan loop
// ---------------------------------------------------------------
@@ -1251,7 +1672,7 @@ export class SessionDirector {
// Fetch recent completed plays for anti-loop detection
const recentPlaysRes = await this.db.pgClient.query(
`SELECT t.id AS track_id, t.album_id, artist.artist_id, tg.genre_id,
af.bpm, af.energy, af.valence, af.instrumentalness,
af.bpm, af.energy, af.valence, af.acousticness, af.instrumentalness,
tl.language,
t.release_date,
${lineageIdsSql('produced', 'artist')} AS producer_ids,
@@ -1283,6 +1704,8 @@ export class SessionDirector {
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,
acousticness: r.acousticness ?? null,
instrumentalness: r.instrumentalness ?? null,
albumId: r.album_id ?? null,
producerIds: r.producer_ids ?? [],
labelIds: r.label_ids ?? [],
@@ -1295,7 +1718,9 @@ export class SessionDirector {
const arcType = this.pickArc(state);
const planSize = PLAN_SIZE;
const slots = this.getArcSlots(arcType, Math.max(0, planSize - retainedPlan.length));
const rawSlots = this.getArcSlots(arcType, Math.max(0, planSize - retainedPlan.length));
const surpriseUsage = await this.getSessionSurpriseUsage(sessionId, userId);
const slots = this.applySurpriseBudget(rawSlots, surpriseUsage, state.sessionAgeMin);
let seedArtistId: string | null = null;
if (seedTrackId) {
@@ -1337,6 +1762,21 @@ export class SessionDirector {
if (allCandidates.length === 0) {
return [];
}
const availableGeneratorIds = new Set(allCandidates.map(candidate => candidate.generatorId));
// A budgeted surprise without an explainable discovery candidate becomes
// its declared recovery anchor before ranking. This is a deterministic
// degradation, not a random or mislabeled fallback.
const effectiveSlots = slots.map(slot => {
const hasSurpriseSource = !slot.surprise
|| this.roleToGeneratorIds(slot.role).some(id => availableGeneratorIds.has(id));
if (hasSurpriseSource || !slot.surprise) return slot;
return {
...slot,
role: slot.surprise.recoveryRole,
surprise: undefined,
targets: { ...(slot.targets ?? {}), novelty: { max: 0.35 } },
};
});
const repetitionState = await this.buildRepetitionState(userId);
const candidateArtistMap = await this.loadArtistMap(
@@ -1374,7 +1814,7 @@ export class SessionDirector {
]);
const constrained = this.constrainedSequence({
candidates: deduped,
slots,
slots: effectiveSlots,
metadata,
budgets,
roleToGeneratorIds: role => this.roleToGeneratorIds(role),
@@ -1394,6 +1834,37 @@ export class SessionDirector {
return constrained.plan.slice(0, Math.max(0, planSize - retainedPlan.length));
}
/** Apply the durable delivery budget to fresh arc slots. Retained entries
* are intentionally absent here: they have not consumed anything until a
* `track_served` event exists for their immutable revision. */
private applySurpriseBudget(
rawSlots: ArcSlot[],
surpriseUsage: { session: number; hour: number },
sessionAgeMin: number,
): ArcSlot[] {
// One surprise is allowed in a rolling hour. The session capacity grows
// slowly (one per listening hour), preserving a recovery anchor after
// every admitted surprise instead of filling each replan with novelty.
const sessionSurpriseLimit = Math.max(1, Math.ceil(Math.max(0, sessionAgeMin) / 60));
let plannedSurprises = 0;
return rawSlots.map(slot => {
if (!slot.surprise) return slot;
const allowed = plannedSurprises < slot.surprise.maxPerPlan
&& surpriseUsage.hour + plannedSurprises < slot.surprise.maxPerHour
&& surpriseUsage.session + plannedSurprises < sessionSurpriseLimit;
if (allowed) {
plannedSurprises++;
return slot;
}
return {
...slot,
role: slot.surprise.recoveryRole,
surprise: undefined,
targets: { ...(slot.targets ?? {}), novelty: { max: 0.35 } },
};
});
}
async replan(
userId: string,
sessionId: string,
+254 -2
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest';
import { mergeUniquePlan, selectConstrainedSequence, SessionDirector } from './session-director.service.js';
import { mergeUniquePlan, scoreArcTransition, selectConstrainedSequence, SessionDirector } from './session-director.service.js';
import { DbService } from './db.service.js';
import { ALL_GENERATORS } from './generators.service.js';
@@ -122,9 +122,261 @@ describe('SessionDirector', () => {
it('has valid role names', () => {
const slots = director.getArcSlots('comfort', 20);
const validRoles = ['known', 'adjacent', 'favorite', 'similar', 'new', 'medium', 'high', 'peak', 'cooldown', 'soft', 'ambient', 'acoustic', 'slow'];
const validRoles = ['known', 'adjacent', 'favorite', 'similar', 'new', 'medium', 'high', 'peak', 'cooldown', 'soft', 'ambient', 'acoustic', 'slow', 'surprise'];
slots.forEach(s => expect(validRoles).toContain(s.role));
});
it('creates measurable targets, callbacks, and one bounded surprise in the first arc cycle', () => {
const slots = director.getArcSlots('comfort', 20);
expect(slots.every(slot => slot.targets && Object.keys(slot.targets).length > 0)).toBe(true);
expect(slots.filter(slot => slot.surprise)).toHaveLength(1);
const anchor = slots.find(slot => slot.callback?.phase === 'anchor');
const callback = slots.find(slot => slot.callback?.phase === 'return');
expect(anchor?.callback?.id).toBe(callback?.callback?.id);
expect(anchor?.callback?.minSeparation).toBeGreaterThan(0);
});
});
describe('durable surprise delivery accounting', () => {
it('counts only exact, served surprise plan-item exposures', async () => {
const sessionId = '00000000-0000-4000-8000-000000000001';
const userId = '00000000-0000-4000-8000-000000000002';
const otherSessionId = '00000000-0000-4000-8000-000000000003';
const revisionOneId = '00000000-0000-4000-8000-000000000011';
const revisionTwoId = '00000000-0000-4000-8000-000000000012';
const otherRevisionId = '00000000-0000-4000-8000-000000000013';
const now = new Date();
const recentAt = new Date(now.getTime() - 5 * 60 * 1000);
const expiredAt = new Date(now.getTime() - 61 * 60 * 1000);
const retainedTrackId = '00000000-0000-4000-8000-000000000021';
const servedTrackId = '00000000-0000-4000-8000-000000000022';
const oldTrackId = '00000000-0000-4000-8000-000000000023';
// This mirrors the three tables involved in the query. The unserved
// retained row exists in both immutable revisions, but has no ledger
// event and therefore must not consume a surprise budget.
const versions = [
{ id: revisionOneId, sessionId },
{ id: revisionTwoId, sessionId },
{ id: otherRevisionId, sessionId: otherSessionId },
];
const items = [
{ planVersionId: revisionOneId, ordinal: 6, trackId: retainedTrackId, slotRole: 'surprise' },
{ planVersionId: revisionTwoId, ordinal: 6, trackId: retainedTrackId, slotRole: 'surprise' },
{ planVersionId: revisionTwoId, ordinal: 7, trackId: servedTrackId, slotRole: 'surprise' },
{ planVersionId: revisionTwoId, ordinal: 8, trackId: oldTrackId, slotRole: 'surprise' },
{ planVersionId: revisionTwoId, ordinal: 9, trackId: '00000000-0000-4000-8000-000000000024', slotRole: 'favorite' },
{ planVersionId: otherRevisionId, ordinal: 7, trackId: servedTrackId, slotRole: 'surprise' },
];
const events = [
// The exact revision-two association counts once.
{ id: '00000000-0000-4000-8000-000000000031', sessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: revisionTwoId, ordinal: 7 }, occurredAt: recentAt },
// A valid historical exposure remains in the session total but falls
// out of the rolling 60-minute counter.
{ id: '00000000-0000-4000-8000-000000000032', sessionId, userId, type: 'track_served', trackId: oldTrackId, payload: { planVersionId: revisionTwoId, ordinal: 8 }, occurredAt: expiredAt },
{ id: '00000000-0000-4000-8000-000000000033', sessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: revisionOneId, ordinal: 7 }, occurredAt: recentAt }, // wrong ordinal
{ id: '00000000-0000-4000-8000-000000000034', sessionId, userId, type: 'track_served', trackId: retainedTrackId, payload: { planVersionId: revisionTwoId, ordinal: 7 }, occurredAt: recentAt }, // wrong track
{ id: '00000000-0000-4000-8000-000000000035', sessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: '00000000-0000-4000-8000-000000000014', ordinal: 7 }, occurredAt: recentAt }, // wrong version
{ id: '00000000-0000-4000-8000-000000000036', sessionId: otherSessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: otherRevisionId, ordinal: 7 }, occurredAt: recentAt }, // wrong session
{ id: '00000000-0000-4000-8000-000000000037', sessionId, userId, type: 'track_finished', trackId: servedTrackId, payload: { planVersionId: revisionTwoId, ordinal: 7 }, occurredAt: recentAt },
// Legacy/corrupt payloads must neither cast-fail nor claim an actual
// surprise exposure when somebody writes directly to the event ledger.
{ id: '00000000-0000-4000-8000-000000000038', sessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: 'not-a-uuid', ordinal: 7 }, occurredAt: recentAt },
{ id: '00000000-0000-4000-8000-000000000039', sessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: revisionTwoId, ordinal: 'not-an-integer' }, occurredAt: recentAt },
{ id: '00000000-0000-4000-8000-000000000040', sessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: revisionTwoId, ordinal: '999999999999999999999999999999999999' }, occurredAt: recentAt },
];
const exposureIds: string[] = [];
const db = makeMockDb();
(db.pgClient.query as any).mockImplementation((sql: string, params: unknown[]) => {
// Faithfully evaluate the query's joins against the in-memory rows;
// do not treat merely planned items as delivered exposure.
expect(params).toEqual([sessionId, userId]);
const matching = events.filter(event => {
const item = items.find(candidate => candidate.planVersionId === event.payload.planVersionId
&& candidate.ordinal === event.payload.ordinal
&& candidate.trackId === event.trackId);
const version = item && versions.find(candidate => candidate.id === item.planVersionId);
return event.sessionId === sessionId
&& event.userId === userId
&& event.type === 'track_served'
&& item?.slotRole === 'surprise'
&& version?.sessionId === event.sessionId;
});
exposureIds.push(...new Set(matching.map(event => event.id)));
return Promise.resolve({
rows: [{
session_count: new Set(matching.map(event => event.id)).size,
hour_count: new Set(matching.filter(event => event.occurredAt > new Date(Date.now() - 60 * 60 * 1000)).map(event => event.id)).size,
}],
});
});
const usage = await (new SessionDirector(db) as any).getSessionSurpriseUsage(sessionId, userId);
expect(usage).toEqual({ session: 2, hour: 1 });
expect(exposureIds).toEqual([
'00000000-0000-4000-8000-000000000031',
'00000000-0000-4000-8000-000000000032',
]);
const [sql, params] = (db.pgClient.query as any).mock.calls[0] as [string, unknown[]];
expect(params).toEqual([sessionId, userId]);
expect(sql).toContain('SELECT DISTINCT e.id, e.occurred_at');
expect(sql).toContain("e.payload->>'planVersionId' ~*");
expect(sql).toContain("THEN (e.payload->>'planVersionId')::uuid");
expect(sql).toContain("e.payload->>'ordinal') ~ '^(0|[1-9][0-9]{0,8})$'");
expect(sql).toContain("THEN (e.payload->>'ordinal')::integer");
expect(sql).toContain('AND item.track_id = e.track_id');
expect(sql).toContain('AND version.session_id = e.session_id');
expect(sql).toContain("e.type = 'track_served'");
expect(sql).toContain("item.slot_role = 'surprise'");
expect(sql).toContain('WHERE e.session_id = $1');
expect(sql).toContain('AND e.user_id = $2');
expect(sql).toContain("occurred_at > NOW() - INTERVAL '1 hour'");
});
});
describe('transition-aware sequence scoring', () => {
it('prefers a smooth, on-arc candidate and treats missing analysis as lower confidence', () => {
const slot = {
position: 0,
role: 'high',
targets: { energy: { min: 0.7, max: 0.9, maxDelta: 0.2 }, tempo: { min: 120, max: 160, maxDelta: 25 } },
};
const previous = { energy: 0.72, bpm: 132 };
const smooth = scoreArcTransition(candidate('smooth'), { energy: 0.78, bpm: 140 }, previous, slot);
const abrupt = scoreArcTransition(candidate('abrupt'), { energy: 0.15, bpm: 72 }, previous, slot);
const unknown = scoreArcTransition(candidate('unknown'), {}, previous, slot);
expect(smooth.score).toBeGreaterThan(abrupt.score);
expect(unknown.confidence).toBeLessThan(smooth.confidence);
expect(unknown.score).toBeGreaterThan(0);
});
it('selects a callback inside its separation window while preserving hard caps', () => {
const candidates = ['anchor', 'bridge-a', 'bridge-b', 'return', 'other'].map(id => ({ ...candidate(id), generatorId: 'comfort' }));
const metadata = new Map([
['anchor', { artistId: 'theme', albumId: 'a1', favorite: true, energy: 0.5 }],
['bridge-a', { artistId: 'a2', albumId: 'a2', energy: 0.5 }],
['bridge-b', { artistId: 'a3', albumId: 'a3', energy: 0.5 }],
['return', { artistId: 'theme', albumId: 'a4', favorite: true, energy: 0.5 }],
['other', { artistId: 'a4', albumId: 'a5', favorite: false, energy: 0.5 }],
]);
const token = { id: 'theme', theme: 'artist' as const, minSeparation: 2, maxSeparation: 4 };
const result = selectConstrainedSequence({
candidates,
metadata,
budgets: [],
roleToGeneratorIds: () => ['comfort'],
slots: [
{ position: 0, role: 'known', targets: {}, callback: { ...token, phase: 'anchor' as const } },
{ position: 1, role: 'known', targets: {} },
{ position: 2, role: 'known', targets: {} },
{ position: 3, role: 'favorite', targets: {}, callback: { ...token, phase: 'return' as const } },
],
});
expect(result.plan.map(item => item.trackId)).toEqual(['anchor', 'bridge-a', 'bridge-b', 'return']);
expect(result.plan[3].plan?.scoreBreakdown.callback).toBe(1);
});
it('plans an energetic rise through peak and cooldown when measured candidates exist', () => {
const db = makeMockDb();
const director = new SessionDirector(db);
const slots = director.getArcSlots('energetic', 8);
const entries = [
['medium-1', 'comfort', 0.55, 110], ['medium-2', 'comfort', 0.62, 125],
['high-1', 'discovery', 0.72, 135], ['high-2', 'discovery', 0.8, 150],
['peak', 'deep-dive', 0.9, 165], ['surprise', 'discovery', 0.85, 155],
['cooldown-1', 'comfort', 0.62, 130], ['cooldown-2', 'comfort', 0.5, 110],
] as const;
const candidates = entries.map(([trackId, generatorId]) => ({ ...candidate(trackId), generatorId }));
const metadata: Map<string, any> = new Map(entries.map(([trackId, generatorId, energy, bpm], index) => [trackId, {
artistId: `artist-${index}`, albumId: `album-${index}`, energy, bpm,
valence: 0.6, acousticness: trackId.startsWith('cooldown') ? 0.3 : 0.1,
favorite: String(trackId).startsWith('medium') || String(trackId).startsWith('cooldown'),
newArtist: generatorId === 'discovery',
}]));
const result = selectConstrainedSequence({
candidates, slots, metadata, budgets: [],
roleToGeneratorIds: role => (director as any).roleToGeneratorIds(role),
});
expect(result.plan.map(item => item.trackId)).toEqual(entries.map(([trackId]) => trackId));
expect(result.plan.map(item => metadata.get(item.trackId)?.energy))
.toEqual([0.55, 0.62, 0.72, 0.8, 0.9, 0.85, 0.62, 0.5]);
expect(result.relaxations).toEqual([]);
});
it('keeps the familiar-new-familiar discovery callback intact', () => {
const db = makeMockDb();
const director = new SessionDirector(db);
const candidates = [
{ ...candidate('favorite-anchor'), generatorId: 'deep-dive' },
{ ...candidate('adjacent'), generatorId: 'adjacent' },
{ ...candidate('new'), generatorId: 'discovery' },
{ ...candidate('favorite-return'), generatorId: 'deep-dive' },
];
const metadata = new Map([
['favorite-anchor', { artistId: 'theme', albumId: 'a1', favorite: true, energy: 0.5 }],
['adjacent', { artistId: 'bridge', albumId: 'a2', energy: 0.55 }],
['new', { artistId: 'new', albumId: 'a3', newArtist: true, energy: 0.6 }],
['favorite-return', { artistId: 'theme', albumId: 'a4', favorite: true, energy: 0.55 }],
]);
const result = selectConstrainedSequence({
candidates, slots: director.getArcSlots('discovery', 4), metadata, budgets: [],
roleToGeneratorIds: role => (director as any).roleToGeneratorIds(role),
});
expect(result.plan.map(item => item.trackId))
.toEqual(['favorite-anchor', 'adjacent', 'new', 'favorite-return']);
expect(result.plan[3].plan?.explanation.callback).toMatchObject({ phase: 'return', matchScore: 1 });
});
it('downgrades a surprise deterministically when no preferred recovery anchor is feasible', () => {
const db = makeMockDb();
const director = new SessionDirector(db);
const candidates = [
{ ...candidate('favorite-anchor'), generatorId: 'deep-dive' },
{ ...candidate('adjacent'), generatorId: 'adjacent' },
{ ...candidate('unrecoverable-surprise'), generatorId: 'discovery' },
// This may fill the downgraded favourite slot only through the
// explicitly persisted arc-source relaxation; it cannot reserve a
// recovery for the surprise because it is not a favourite source.
{ ...candidate('fallback'), generatorId: 'contextual' },
];
const metadata = new Map(candidates.map((item, index) => [item.trackId, {
artistId: `artist-${index}`, albumId: `album-${index}`,
favorite: item.trackId === 'favorite-anchor', newArtist: item.trackId === 'unrecoverable-surprise', energy: 0.5,
}]));
const result = selectConstrainedSequence({
candidates, slots: director.getArcSlots('discovery', 3), metadata, budgets: [],
roleToGeneratorIds: role => (director as any).roleToGeneratorIds(role),
});
expect(result.plan.map(item => item.trackId)).toEqual(['favorite-anchor', 'adjacent', 'fallback']);
expect(result.plan[2].plan?.slotRole).toBe('favorite');
expect(result.relaxations).toContainEqual(expect.objectContaining({ constraint: 'surprise_recovery' }));
});
it('falls back safely with sparse audio analysis and records arc precision', () => {
const db = makeMockDb();
const director = new SessionDirector(db);
const result = selectConstrainedSequence({
candidates: [
{ ...candidate('unknown-analysis'), generatorId: 'comfort' },
{ ...candidate('wrong-energy'), generatorId: 'comfort' },
],
slots: director.getArcSlots('energetic', 1),
metadata: new Map([
['unknown-analysis', { artistId: 'a1', albumId: 'x1' }],
['wrong-energy', { artistId: 'a2', albumId: 'x2', energy: 0.1, bpm: 70 }],
]),
budgets: [], roleToGeneratorIds: role => (director as any).roleToGeneratorIds(role),
});
expect(result.plan.map(item => item.trackId)).toEqual(['unknown-analysis']);
expect(result.relaxations).toContainEqual(expect.objectContaining({ constraint: 'arc_precision' }));
expect(result.plan[0].plan?.explanation.arcPrecision).toMatchObject({ measuredFit: false });
});
});
describe('computeEntropy', () => {
@@ -54,6 +54,7 @@ function setup() {
const director = {
buildPlan: vi.fn().mockResolvedValue([{ trackId: TRACK_ID, generatorId: 'comfort', relevance: 0.8, explanation: [] }]),
buildState: vi.fn().mockResolvedValue({ energy: 0.5, noveltyHunger: 0.3 }),
replan: vi.fn().mockResolvedValue([{ trackId: TRACK_ID, generatorId: 'comfort', relevance: 0.8, explanation: [] }]),
} as any;
return { db, director, coordinator: new VibeSessionCoordinator(db, director) };
}
@@ -80,6 +81,31 @@ describe('VibeSessionCoordinator', () => {
.toEqual(['session_started']);
});
it('persists a director-selected arc role and explainable sequence score', async () => {
const { db, director, coordinator } = setup();
(director.buildPlan as any).mockResolvedValueOnce([{
trackId: TRACK_ID, generatorId: 'discovery', relevance: 0.7, explanation: [{ predicate: 'near' }],
plan: {
slotRole: 'surprise', score: 0.82,
scoreBreakdown: { relevance: 0.7, transition: 0.9, arcTarget: 0.85 },
explanation: { arcRole: 'surprise', surprise: { recoveryRole: 'favorite' } },
},
}]);
await coordinator.start('user-1', {});
expect(db.publishVibePlan).toHaveBeenCalledWith(expect.objectContaining({
items: [expect.objectContaining({
slot_role: 'surprise', score: 0.82,
score_breakdown: expect.objectContaining({ transition: 0.9 }),
explanation: expect.objectContaining({
paths: [{ predicate: 'near' }],
planner: expect.objectContaining({ arcRole: 'surprise' }),
}),
})],
}));
});
it('returns the canonical replacement on an idempotent material-event retry without replanning', async () => {
const { db, coordinator } = setup();
(db.recordVibeEvent as any).mockResolvedValueOnce({
@@ -118,13 +144,42 @@ describe('VibeSessionCoordinator', () => {
const { db, director, coordinator } = setup();
const response = await coordinator.appendEvent('user-1', SESSION_ID, { type: 'completed', trackId: TRACK_ID });
expect(director.buildPlan).toHaveBeenCalledWith('user-1', SESSION_ID, TRACK_ID);
expect(director.replan).toHaveBeenCalledWith(
'user-1', SESSION_ID, expect.any(Array), [TRACK_ID], TRACK_ID,
{ excludedTrackIds: new Set() },
);
expect(db.publishVibePlan).toHaveBeenCalledWith(expect.objectContaining({
sessionId: SESSION_ID, reason: 'feedback:completed', items: [expect.objectContaining({ committed: false })],
}));
expect(response).toMatchObject({ replanned: true, replanReason: 'feedback:completed', planVersion: 2 });
});
it('passes the durable unserved callback tail into the retention-aware replan', async () => {
const { db, director, coordinator } = setup();
(db.getVibePlan as any).mockResolvedValue({
...plan(),
items: [{
...plan().items[0],
slot_role: 'favorite',
explanation: {
paths: [],
planner: { arcRole: 'favorite', callback: { id: 'theme:0', phase: 'return' } },
},
}],
});
await coordinator.appendEvent('user-1', SESSION_ID, { type: 'completed', trackId: 'other-track' });
expect(director.replan).toHaveBeenCalledWith(
'user-1', SESSION_ID,
[expect.objectContaining({
trackId: TRACK_ID,
plan: expect.objectContaining({ slotRole: 'favorite', explanation: expect.objectContaining({ arcRole: 'favorite' }) }),
})],
['other-track'], 'other-track', { excludedTrackIds: new Set() },
);
});
it('keeps the durable seed excluded when feedback supplies a different local replan anchor', async () => {
const { db, director, coordinator } = setup();
const seedTrackId = '44444444-4444-4444-8444-444444444444';
@@ -132,9 +187,11 @@ describe('VibeSessionCoordinator', () => {
await coordinator.appendEvent('user-1', SESSION_ID, { type: 'completed', trackId: TRACK_ID });
expect(director.buildPlan).toHaveBeenCalledWith(
expect(director.replan).toHaveBeenCalledWith(
'user-1',
SESSION_ID,
expect.any(Array),
[TRACK_ID],
TRACK_ID,
{ excludedTrackIds: new Set([seedTrackId]) },
);
@@ -1,5 +1,6 @@
import { DbService, VibeEvent, VibePlan, VibeSession } from './db.service.js';
import { SessionDirector } from './session-director.service.js';
import { Candidate } from './generators.service.js';
/**
* This is deliberately a narrow bridge between the durable Vibe ledger and
@@ -67,7 +68,7 @@ export interface VibeSessionResponse {
export class VibeSessionCoordinator {
constructor(
private readonly db: DbService,
private readonly director: Pick<SessionDirector, 'buildPlan' | 'buildState'>,
private readonly director: Pick<SessionDirector, 'buildPlan' | 'buildState' | 'replan'>,
) {}
async start(userId: string, input: StartVibeSessionInput): Promise<VibeSessionResponse> {
@@ -111,15 +112,18 @@ export class VibeSessionCoordinator {
policyVersion,
intent: input.intent ?? null,
horizonTracks: candidates.length,
...(candidates[0]?.plan?.objective ?? {}),
},
items: candidates.map((candidate, ordinal) => ({
ordinal,
track_id: candidate.trackId,
slot_role: ordinal === 0 ? 'next' : null,
slot_role: candidate.plan?.slotRole ?? (ordinal === 0 ? 'next' : null),
candidate_source: candidate.generatorId,
score: candidate.relevance,
score_breakdown: { relevance: candidate.relevance },
explanation: candidate.explanation,
score: candidate.plan?.score ?? candidate.relevance,
score_breakdown: candidate.plan?.scoreBreakdown ?? { relevance: candidate.relevance },
explanation: candidate.plan
? { paths: candidate.explanation, planner: candidate.plan.explanation }
: candidate.explanation,
committed: false,
})),
});
@@ -216,11 +220,24 @@ export class VibeSessionCoordinator {
// feedback tracks, the seed is not necessarily present in the event
// ledger, so carry it explicitly into every replacement request.
const seedTrackId = session.seed_track_id ?? undefined;
const candidates = seedTrackId
? await this.director.buildPlan(userId, sessionId, input.trackId ?? seedTrackId, {
excludedTrackIds: new Set([seedTrackId]),
})
: await this.director.buildPlan(userId, sessionId, input.trackId);
// Revisions retain the durable, unserved queue tail rather than building
// an unrelated plan after every signal. Besides reducing churn, this
// preserves a valid callback/recovery pair that has already been shown
// to the client while allowing the director to refill under the same
// hard caps and current feedback state.
const current = await this.db.getVibePlan(sessionId, userId);
const retained = current ? this.unservedCandidates(current) : [];
const excludedTrackIds = new Set<string>([
...(seedTrackId ? [seedTrackId] : []),
]);
const candidates = await this.director.replan(
userId,
sessionId,
retained,
input.trackId ? [input.trackId] : [],
input.trackId ?? seedTrackId,
{ excludedTrackIds },
);
const reason = `feedback:${input.type}`;
const plan = await this.db.publishVibePlan({
sessionId,
@@ -232,15 +249,18 @@ export class VibeSessionCoordinator {
feedbackEventId: result.event.id,
feedbackType: input.type,
horizonTracks: candidates.length,
...(candidates[0]?.plan?.objective ?? {}),
},
items: candidates.map((candidate, ordinal) => ({
ordinal,
track_id: candidate.trackId,
slot_role: ordinal === 0 ? 'next' : null,
slot_role: candidate.plan?.slotRole ?? (ordinal === 0 ? 'next' : null),
candidate_source: candidate.generatorId,
score: candidate.relevance,
score_breakdown: { relevance: candidate.relevance },
explanation: candidate.explanation,
score: candidate.plan?.score ?? candidate.relevance,
score_breakdown: candidate.plan?.scoreBreakdown ?? { relevance: candidate.relevance },
explanation: candidate.plan
? { paths: candidate.explanation, planner: candidate.plan.explanation }
: candidate.explanation,
committed: false,
})),
});
@@ -303,6 +323,37 @@ export class VibeSessionCoordinator {
};
}
/** Reconstruct the planner envelope from the durable revision. Older
* revisions stored only paths, so they remain valid retention inputs. */
private unservedCandidates(plan: VibePlan): Candidate[] {
return plan.items
.filter(item => !item.committed)
.map(item => {
const stored = item.explanation;
const hasPlanner = !!stored && !Array.isArray(stored) && typeof stored === 'object'
&& 'planner' in stored;
const object = hasPlanner ? stored as { paths?: Candidate['explanation']; planner?: Record<string, unknown> } : undefined;
const planner = object?.planner;
return {
trackId: item.track_id,
generatorId: item.candidate_source,
relevance: item.score,
explanation: object?.paths ?? (Array.isArray(stored) ? stored : []),
plan: planner ? {
slotRole: item.slot_role ?? 'retained',
score: item.score,
scoreBreakdown: item.score_breakdown,
explanation: planner,
objective: {
policy: planner.policy,
constraints: planner.constraints,
relaxations: planner.relaxations,
},
} : undefined,
};
});
}
private mapLifecycleError(error: unknown): Error {
if (error instanceof Error && (error.message.includes('Cannot record a new event for') || error.message.includes('Cannot resume ') || error.message.includes('Cannot publish a plan for'))) {
return new VibeSessionLifecycleError(error.message);