feat(vibe): plan musical arcs and callbacks
This commit is contained in:
@@ -68,6 +68,23 @@ describe('durable Vibe session routes', () => {
|
|||||||
await app.close();
|
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 () => {
|
it('returns a lifecycle conflict when an initial plan race ends or replaces the session', async () => {
|
||||||
const { app, coordinator } = await appWithCoordinator();
|
const { app, coordinator } = await appWithCoordinator();
|
||||||
coordinator.start.mockRejectedValueOnce(
|
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])) {
|
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' });
|
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)) {
|
if (body.eventId !== undefined && !validUuid(body.eventId)) {
|
||||||
return reply.code(400).send({ error: 'eventId must be a UUID' });
|
return reply.code(400).send({ error: 'eventId must be a UUID' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,20 @@ export interface Candidate {
|
|||||||
generatorId: string;
|
generatorId: string;
|
||||||
explanation: ClaimEdge[];
|
explanation: ClaimEdge[];
|
||||||
relevance: number;
|
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 {
|
export interface GeneratorContext {
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export interface RecentPlay {
|
|||||||
vocal: boolean | null;
|
vocal: boolean | null;
|
||||||
decade: number | null;
|
decade: number | null;
|
||||||
valence: number | null;
|
valence: number | null;
|
||||||
|
acousticness?: number | null;
|
||||||
|
instrumentalness?: number | null;
|
||||||
albumId?: string | null;
|
albumId?: string | null;
|
||||||
producerIds?: string[];
|
producerIds?: string[];
|
||||||
labelIds?: string[];
|
labelIds?: string[];
|
||||||
@@ -40,6 +42,8 @@ export interface CandidateConstraintMetadata {
|
|||||||
energy?: number;
|
energy?: number;
|
||||||
bpm?: number;
|
bpm?: number;
|
||||||
valence?: number;
|
valence?: number;
|
||||||
|
acousticness?: number;
|
||||||
|
instrumentalness?: number;
|
||||||
decade?: number;
|
decade?: number;
|
||||||
producerIds?: string[];
|
producerIds?: string[];
|
||||||
labelIds?: string[];
|
labelIds?: string[];
|
||||||
@@ -87,6 +91,46 @@ export interface PlanBuildOptions {
|
|||||||
retainedPlan?: Candidate[];
|
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_ENJOY = 1.0;
|
||||||
const W_FATIGUE = 0.4;
|
const W_FATIGUE = 0.4;
|
||||||
const W_DIVERSITY = 0.3;
|
const W_DIVERSITY = 0.3;
|
||||||
@@ -208,12 +252,106 @@ function metadataFromRecentPlay(play: RecentPlay): CandidateConstraintMetadata {
|
|||||||
energy: play.energy ?? undefined,
|
energy: play.energy ?? undefined,
|
||||||
bpm: play.bpm ?? undefined,
|
bpm: play.bpm ?? undefined,
|
||||||
valence: play.valence ?? undefined,
|
valence: play.valence ?? undefined,
|
||||||
|
acousticness: play.acousticness ?? undefined,
|
||||||
|
instrumentalness: play.instrumentalness ?? undefined,
|
||||||
decade: play.decade ?? undefined,
|
decade: play.decade ?? undefined,
|
||||||
producerIds: play.producerIds ?? [],
|
producerIds: play.producerIds ?? [],
|
||||||
labelIds: play.labelIds ?? [],
|
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
|
* The constraint layer is intentionally pure. Ranking supplies its candidate
|
||||||
* order; this layer chooses a feasible sequence and returns every soft rule it
|
* 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: {
|
export function selectConstrainedSequence(params: {
|
||||||
candidates: Candidate[];
|
candidates: Candidate[];
|
||||||
slots: { position: number; role: string }[];
|
slots: ArcSlot[];
|
||||||
metadata: Map<string, CandidateConstraintMetadata>;
|
metadata: Map<string, CandidateConstraintMetadata>;
|
||||||
budgets: DiversityBudget[];
|
budgets: DiversityBudget[];
|
||||||
roleToGeneratorIds: (role: string) => string[];
|
roleToGeneratorIds: (role: string) => string[];
|
||||||
@@ -249,6 +387,10 @@ export function selectConstrainedSequence(params: {
|
|||||||
const softDimensions = ['artist', 'genre', 'language'];
|
const softDimensions = ['artist', 'genre', 'language'];
|
||||||
const planLength = retainedPlan.length + slots.length;
|
const planLength = retainedPlan.length + slots.length;
|
||||||
const loopedValueSet = new Set([...loopedValues, ...(loopedValue ? [loopedValue] : [])]);
|
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
|
// Candidate ranking is already stable. Build each role's preferred pool
|
||||||
// once, preserving that order, rather than sorting the whole pool for every
|
// once, preserving that order, rather than sorting the whole pool for every
|
||||||
// slot in a long plan.
|
// slot in a long plan.
|
||||||
@@ -322,26 +464,105 @@ export function selectConstrainedSequence(params: {
|
|||||||
increment('album', valuesForDimension(metadata.get(retained.trackId), 'album'));
|
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++) {
|
for (let position = 0; position < slots.length; position++) {
|
||||||
const preferred = roleToGeneratorIds(slots[position].role);
|
let slot = slots[position];
|
||||||
const ordered = orderedForRole(slots[position].role);
|
|
||||||
let chosen: Candidate | undefined;
|
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 relaxedSoft = false;
|
||||||
let relaxedArc = 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++) {
|
for (let pass = 0; pass < 3 && !chosen; pass++) {
|
||||||
// pass 0: all soft constraints + arc role; pass 1: soft budgets/loop;
|
// pass 0: all soft constraints + arc role; pass 1: soft budgets/loop;
|
||||||
// pass 2: permit an arc-source fallback. Hard sequence caps remain.
|
// pass 2: permit an arc-source fallback. Hard sequence caps remain.
|
||||||
const relaxSoft = pass >= 1;
|
const relaxSoft = pass >= 1;
|
||||||
const relaxArc = pass >= 2;
|
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;
|
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 m = metadata.get(candidate.trackId);
|
||||||
const artist = valueForDimension(m, 'artist');
|
const artist = valueForDimension(m, 'artist');
|
||||||
const album = valueForDimension(m, 'album');
|
const album = valueForDimension(m, 'album');
|
||||||
if (artist && count('artist', artist) >= MAX_ARTIST_PER_PLAN) continue;
|
if (artist && count('artist', artist) >= MAX_ARTIST_PER_PLAN) continue;
|
||||||
if (album && count('album', album) >= MAX_ALBUM_PER_40_TRACKS) 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 (!relaxSoft && !explicitIntent) {
|
||||||
if (softDimensions.some(d => exceedsUpperLimit(d, valuesForDimension(m, d)))) continue;
|
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;
|
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;
|
relaxedSoft = relaxSoft;
|
||||||
relaxedArc = relaxArc;
|
relaxedArc = relaxArc;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!chosen) break;
|
if (!chosen) break;
|
||||||
if (relaxedSoft && !relaxations.some(r => r.stage === 'soft_budget')) {
|
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' });
|
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' });
|
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);
|
selectedIds.add(chosen.trackId);
|
||||||
const m = metadata.get(chosen.trackId);
|
const m = chosenMetadata;
|
||||||
for (const dimension of ['artist', 'album', 'genre', 'language', ...lowerDimensions]) {
|
for (const dimension of ['artist', 'album', 'genre', 'language', ...lowerDimensions]) {
|
||||||
increment(dimension, valuesForDimension(m, dimension));
|
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) {
|
if (selected.length < slots.length) {
|
||||||
relaxations.push({ constraint: 'freshness', stage: 'freshness', reason: 'hard exclusions and sequence caps left too few eligible candidates' });
|
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 {
|
export class SessionDirector {
|
||||||
@@ -723,27 +1048,77 @@ export class SessionDirector {
|
|||||||
return 'comfort';
|
return 'comfort';
|
||||||
}
|
}
|
||||||
|
|
||||||
getArcSlots(arcType: string, count: number): { position: number; role: string }[] {
|
getArcSlots(arcType: string, count: number): ArcSlot[] {
|
||||||
const pattern = this.getArcPattern(arcType);
|
const pattern = this.getArcPattern(arcType);
|
||||||
const slots: { position: number; role: string }[] = [];
|
const slots: ArcSlot[] = [];
|
||||||
for (let i = 0; i < count; i++) {
|
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;
|
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) {
|
switch (arcType) {
|
||||||
case 'comfort':
|
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':
|
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':
|
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':
|
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:
|
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'];
|
return ['discovery'];
|
||||||
case 'peak':
|
case 'peak':
|
||||||
return ['deep-dive', 'contextual'];
|
return ['deep-dive', 'contextual'];
|
||||||
|
case 'surprise':
|
||||||
|
return ['discovery', 'adjacent'];
|
||||||
case 'ambient':
|
case 'ambient':
|
||||||
return ['contextual', 'comfort'];
|
return ['contextual', 'comfort'];
|
||||||
default:
|
default:
|
||||||
@@ -1075,7 +1452,7 @@ export class SessionDirector {
|
|||||||
WHERE ph.user_id = $1 AND ph.completed = true
|
WHERE ph.user_id = $1 AND ph.completed = true
|
||||||
AND old_artist.artist_id = artist.artist_id
|
AND old_artist.artist_id = artist.artist_id
|
||||||
) AS new_artist,
|
) 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('produced', 'artist')} AS producer_ids,
|
||||||
${lineageIdsSql('same_label_as', 'artist')} AS label_ids
|
${lineageIdsSql('same_label_as', 'artist')} AS label_ids
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
@@ -1107,6 +1484,8 @@ export class SessionDirector {
|
|||||||
energy: (row.energy as number | null) ?? undefined,
|
energy: (row.energy as number | null) ?? undefined,
|
||||||
bpm: (row.bpm as number | null) ?? undefined,
|
bpm: (row.bpm as number | null) ?? undefined,
|
||||||
valence: (row.valence 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
|
decade: row.release_date
|
||||||
? Math.floor(new Date(row.release_date as string).getFullYear() / 10) * 10 : undefined,
|
? Math.floor(new Date(row.release_date as string).getFullYear() / 10) * 10 : undefined,
|
||||||
producerIds: Array.isArray(row.producer_ids) ? row.producer_ids as string[] : [],
|
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
|
// D.9 — Plan + replan loop
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
@@ -1251,7 +1672,7 @@ export class SessionDirector {
|
|||||||
// Fetch recent completed plays for anti-loop detection
|
// Fetch recent completed plays for anti-loop detection
|
||||||
const recentPlaysRes = await this.db.pgClient.query(
|
const recentPlaysRes = await this.db.pgClient.query(
|
||||||
`SELECT t.id AS track_id, t.album_id, artist.artist_id, tg.genre_id,
|
`SELECT t.id AS track_id, t.album_id, artist.artist_id, tg.genre_id,
|
||||||
af.bpm, af.energy, af.valence, af.instrumentalness,
|
af.bpm, af.energy, af.valence, af.acousticness, af.instrumentalness,
|
||||||
tl.language,
|
tl.language,
|
||||||
t.release_date,
|
t.release_date,
|
||||||
${lineageIdsSql('produced', 'artist')} AS producer_ids,
|
${lineageIdsSql('produced', 'artist')} AS producer_ids,
|
||||||
@@ -1283,6 +1704,8 @@ export class SessionDirector {
|
|||||||
vocal: (r.instrumentalness == null) ? null : r.instrumentalness < 0.5,
|
vocal: (r.instrumentalness == null) ? null : r.instrumentalness < 0.5,
|
||||||
decade: r.release_date ? Math.floor(new Date(r.release_date).getFullYear() / 10) * 10 : null,
|
decade: r.release_date ? Math.floor(new Date(r.release_date).getFullYear() / 10) * 10 : null,
|
||||||
valence: r.valence ?? null,
|
valence: r.valence ?? null,
|
||||||
|
acousticness: r.acousticness ?? null,
|
||||||
|
instrumentalness: r.instrumentalness ?? null,
|
||||||
albumId: r.album_id ?? null,
|
albumId: r.album_id ?? null,
|
||||||
producerIds: r.producer_ids ?? [],
|
producerIds: r.producer_ids ?? [],
|
||||||
labelIds: r.label_ids ?? [],
|
labelIds: r.label_ids ?? [],
|
||||||
@@ -1295,7 +1718,9 @@ export class SessionDirector {
|
|||||||
|
|
||||||
const arcType = this.pickArc(state);
|
const arcType = this.pickArc(state);
|
||||||
const planSize = PLAN_SIZE;
|
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;
|
let seedArtistId: string | null = null;
|
||||||
if (seedTrackId) {
|
if (seedTrackId) {
|
||||||
@@ -1337,6 +1762,21 @@ export class SessionDirector {
|
|||||||
if (allCandidates.length === 0) {
|
if (allCandidates.length === 0) {
|
||||||
return [];
|
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 repetitionState = await this.buildRepetitionState(userId);
|
||||||
const candidateArtistMap = await this.loadArtistMap(
|
const candidateArtistMap = await this.loadArtistMap(
|
||||||
@@ -1374,7 +1814,7 @@ export class SessionDirector {
|
|||||||
]);
|
]);
|
||||||
const constrained = this.constrainedSequence({
|
const constrained = this.constrainedSequence({
|
||||||
candidates: deduped,
|
candidates: deduped,
|
||||||
slots,
|
slots: effectiveSlots,
|
||||||
metadata,
|
metadata,
|
||||||
budgets,
|
budgets,
|
||||||
roleToGeneratorIds: role => this.roleToGeneratorIds(role),
|
roleToGeneratorIds: role => this.roleToGeneratorIds(role),
|
||||||
@@ -1394,6 +1834,37 @@ export class SessionDirector {
|
|||||||
return constrained.plan.slice(0, Math.max(0, planSize - retainedPlan.length));
|
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(
|
async replan(
|
||||||
userId: string,
|
userId: string,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
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 { DbService } from './db.service.js';
|
||||||
import { ALL_GENERATORS } from './generators.service.js';
|
import { ALL_GENERATORS } from './generators.service.js';
|
||||||
|
|
||||||
@@ -122,9 +122,261 @@ describe('SessionDirector', () => {
|
|||||||
|
|
||||||
it('has valid role names', () => {
|
it('has valid role names', () => {
|
||||||
const slots = director.getArcSlots('comfort', 20);
|
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));
|
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', () => {
|
describe('computeEntropy', () => {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ function setup() {
|
|||||||
const director = {
|
const director = {
|
||||||
buildPlan: vi.fn().mockResolvedValue([{ trackId: TRACK_ID, generatorId: 'comfort', relevance: 0.8, explanation: [] }]),
|
buildPlan: vi.fn().mockResolvedValue([{ trackId: TRACK_ID, generatorId: 'comfort', relevance: 0.8, explanation: [] }]),
|
||||||
buildState: vi.fn().mockResolvedValue({ energy: 0.5, noveltyHunger: 0.3 }),
|
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;
|
} as any;
|
||||||
return { db, director, coordinator: new VibeSessionCoordinator(db, director) };
|
return { db, director, coordinator: new VibeSessionCoordinator(db, director) };
|
||||||
}
|
}
|
||||||
@@ -80,6 +81,31 @@ describe('VibeSessionCoordinator', () => {
|
|||||||
.toEqual(['session_started']);
|
.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 () => {
|
it('returns the canonical replacement on an idempotent material-event retry without replanning', async () => {
|
||||||
const { db, coordinator } = setup();
|
const { db, coordinator } = setup();
|
||||||
(db.recordVibeEvent as any).mockResolvedValueOnce({
|
(db.recordVibeEvent as any).mockResolvedValueOnce({
|
||||||
@@ -118,13 +144,42 @@ describe('VibeSessionCoordinator', () => {
|
|||||||
const { db, director, coordinator } = setup();
|
const { db, director, coordinator } = setup();
|
||||||
const response = await coordinator.appendEvent('user-1', SESSION_ID, { type: 'completed', trackId: TRACK_ID });
|
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({
|
expect(db.publishVibePlan).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
sessionId: SESSION_ID, reason: 'feedback:completed', items: [expect.objectContaining({ committed: false })],
|
sessionId: SESSION_ID, reason: 'feedback:completed', items: [expect.objectContaining({ committed: false })],
|
||||||
}));
|
}));
|
||||||
expect(response).toMatchObject({ replanned: true, replanReason: 'feedback:completed', planVersion: 2 });
|
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 () => {
|
it('keeps the durable seed excluded when feedback supplies a different local replan anchor', async () => {
|
||||||
const { db, director, coordinator } = setup();
|
const { db, director, coordinator } = setup();
|
||||||
const seedTrackId = '44444444-4444-4444-8444-444444444444';
|
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 });
|
await coordinator.appendEvent('user-1', SESSION_ID, { type: 'completed', trackId: TRACK_ID });
|
||||||
|
|
||||||
expect(director.buildPlan).toHaveBeenCalledWith(
|
expect(director.replan).toHaveBeenCalledWith(
|
||||||
'user-1',
|
'user-1',
|
||||||
SESSION_ID,
|
SESSION_ID,
|
||||||
|
expect.any(Array),
|
||||||
|
[TRACK_ID],
|
||||||
TRACK_ID,
|
TRACK_ID,
|
||||||
{ excludedTrackIds: new Set([seedTrackId]) },
|
{ excludedTrackIds: new Set([seedTrackId]) },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { DbService, VibeEvent, VibePlan, VibeSession } from './db.service.js';
|
import { DbService, VibeEvent, VibePlan, VibeSession } from './db.service.js';
|
||||||
import { SessionDirector } from './session-director.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
|
* This is deliberately a narrow bridge between the durable Vibe ledger and
|
||||||
@@ -67,7 +68,7 @@ export interface VibeSessionResponse {
|
|||||||
export class VibeSessionCoordinator {
|
export class VibeSessionCoordinator {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly db: DbService,
|
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> {
|
async start(userId: string, input: StartVibeSessionInput): Promise<VibeSessionResponse> {
|
||||||
@@ -111,15 +112,18 @@ export class VibeSessionCoordinator {
|
|||||||
policyVersion,
|
policyVersion,
|
||||||
intent: input.intent ?? null,
|
intent: input.intent ?? null,
|
||||||
horizonTracks: candidates.length,
|
horizonTracks: candidates.length,
|
||||||
|
...(candidates[0]?.plan?.objective ?? {}),
|
||||||
},
|
},
|
||||||
items: candidates.map((candidate, ordinal) => ({
|
items: candidates.map((candidate, ordinal) => ({
|
||||||
ordinal,
|
ordinal,
|
||||||
track_id: candidate.trackId,
|
track_id: candidate.trackId,
|
||||||
slot_role: ordinal === 0 ? 'next' : null,
|
slot_role: candidate.plan?.slotRole ?? (ordinal === 0 ? 'next' : null),
|
||||||
candidate_source: candidate.generatorId,
|
candidate_source: candidate.generatorId,
|
||||||
score: candidate.relevance,
|
score: candidate.plan?.score ?? candidate.relevance,
|
||||||
score_breakdown: { relevance: candidate.relevance },
|
score_breakdown: candidate.plan?.scoreBreakdown ?? { relevance: candidate.relevance },
|
||||||
explanation: candidate.explanation,
|
explanation: candidate.plan
|
||||||
|
? { paths: candidate.explanation, planner: candidate.plan.explanation }
|
||||||
|
: candidate.explanation,
|
||||||
committed: false,
|
committed: false,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
@@ -216,11 +220,24 @@ export class VibeSessionCoordinator {
|
|||||||
// feedback tracks, the seed is not necessarily present in the event
|
// feedback tracks, the seed is not necessarily present in the event
|
||||||
// ledger, so carry it explicitly into every replacement request.
|
// ledger, so carry it explicitly into every replacement request.
|
||||||
const seedTrackId = session.seed_track_id ?? undefined;
|
const seedTrackId = session.seed_track_id ?? undefined;
|
||||||
const candidates = seedTrackId
|
// Revisions retain the durable, unserved queue tail rather than building
|
||||||
? await this.director.buildPlan(userId, sessionId, input.trackId ?? seedTrackId, {
|
// an unrelated plan after every signal. Besides reducing churn, this
|
||||||
excludedTrackIds: new Set([seedTrackId]),
|
// preserves a valid callback/recovery pair that has already been shown
|
||||||
})
|
// to the client while allowing the director to refill under the same
|
||||||
: await this.director.buildPlan(userId, sessionId, input.trackId);
|
// 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 reason = `feedback:${input.type}`;
|
||||||
const plan = await this.db.publishVibePlan({
|
const plan = await this.db.publishVibePlan({
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -232,15 +249,18 @@ export class VibeSessionCoordinator {
|
|||||||
feedbackEventId: result.event.id,
|
feedbackEventId: result.event.id,
|
||||||
feedbackType: input.type,
|
feedbackType: input.type,
|
||||||
horizonTracks: candidates.length,
|
horizonTracks: candidates.length,
|
||||||
|
...(candidates[0]?.plan?.objective ?? {}),
|
||||||
},
|
},
|
||||||
items: candidates.map((candidate, ordinal) => ({
|
items: candidates.map((candidate, ordinal) => ({
|
||||||
ordinal,
|
ordinal,
|
||||||
track_id: candidate.trackId,
|
track_id: candidate.trackId,
|
||||||
slot_role: ordinal === 0 ? 'next' : null,
|
slot_role: candidate.plan?.slotRole ?? (ordinal === 0 ? 'next' : null),
|
||||||
candidate_source: candidate.generatorId,
|
candidate_source: candidate.generatorId,
|
||||||
score: candidate.relevance,
|
score: candidate.plan?.score ?? candidate.relevance,
|
||||||
score_breakdown: { relevance: candidate.relevance },
|
score_breakdown: candidate.plan?.scoreBreakdown ?? { relevance: candidate.relevance },
|
||||||
explanation: candidate.explanation,
|
explanation: candidate.plan
|
||||||
|
? { paths: candidate.explanation, planner: candidate.plan.explanation }
|
||||||
|
: candidate.explanation,
|
||||||
committed: false,
|
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 {
|
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'))) {
|
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);
|
return new VibeSessionLifecycleError(error.message);
|
||||||
|
|||||||
Reference in New Issue
Block a user