feat(vibe): enforce session diversity constraints
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { mergeUniquePlan, SessionDirector } from './session-director.service.js';
|
||||
import { mergeUniquePlan, selectConstrainedSequence, SessionDirector } from './session-director.service.js';
|
||||
import { DbService } from './db.service.js';
|
||||
import { ALL_GENERATORS } from './generators.service.js';
|
||||
|
||||
function makeMockDb(overrides: Record<string, any> = {}): DbService {
|
||||
const mockQuery = vi.fn();
|
||||
@@ -60,6 +61,8 @@ describe('SessionDirector', () => {
|
||||
);
|
||||
const refillExclusions = (buildPlan.mock.calls[0][3] as any).excludedTrackIds as Set<string>;
|
||||
expect(refillExclusions).toEqual(new Set(['older-skip', 'skipped', 'already-queued']));
|
||||
expect((buildPlan.mock.calls[0][3] as any).retainedPlan.map((item: any) => item.trackId))
|
||||
.toEqual(['already-queued']);
|
||||
});
|
||||
|
||||
it('does not append anything when a refill contains only queued or excluded tracks', async () => {
|
||||
@@ -159,7 +162,7 @@ describe('SessionDirector', () => {
|
||||
{ trackId: 't1', generatorId: 'a', relevance: 0.9, explanation: [{ subjectType: 'artist', subjectId: 'a1', predicate: 'credited_main_on', objectType: 'track', objectId: 't1', fusedValue: 1 }] },
|
||||
{ trackId: 't2', generatorId: 'b', relevance: 0.3, explanation: [{ subjectType: 'artist', subjectId: 'a2', predicate: 'credited_main_on', objectType: 'track', objectId: 't2', fusedValue: 1 }] },
|
||||
];
|
||||
const fatigue = { artist: new Map(), genre: new Map(), track: new Map(), language: new Map(), vocal: 0 };
|
||||
const fatigue = { artist: new Map(), album: new Map(), genre: new Map(), track: new Map(), language: new Map(), vocal: 0 };
|
||||
const budgets = [{ dimension: 'artist', budgetShare: 0.2, horizonMin: 30, spent: 0 }];
|
||||
const state = { energy: 0.5, noveltyHunger: 0.3, sessionAgeMin: 10, lastArtistIds: [], lastGenreIds: [], context: null };
|
||||
|
||||
@@ -170,6 +173,339 @@ describe('SessionDirector', () => {
|
||||
|
||||
});
|
||||
|
||||
describe('sequence constraints', () => {
|
||||
const slots = Array.from({ length: 10 }, (_, position) => ({ position, role: 'known' }));
|
||||
const budgets = [
|
||||
{ dimension: 'artist', budgetShare: 0.2, horizonMin: 30, spent: 0 },
|
||||
{ dimension: 'genre', budgetShare: 0.4, horizonMin: 30, spent: 0 },
|
||||
{ dimension: 'language', budgetShare: 0.6, horizonMin: 30, spent: 0 },
|
||||
{ dimension: 'instrumental', budgetShare: 0.1, horizonMin: 30, spent: 0 },
|
||||
{ dimension: 'new_artist', budgetShare: 0.15, horizonMin: 60, spent: 0 },
|
||||
{ dimension: 'favorite', budgetShare: 0.25, horizonMin: 60, spent: 0 },
|
||||
];
|
||||
const roleToGeneratorIds = () => ['comfort'];
|
||||
|
||||
it('projects budgets while enforcing artist and album caps across the sequence', () => {
|
||||
const candidates = Array.from({ length: 15 }, (_, i) => ({ ...candidate(`t${i}`), generatorId: 'comfort' }));
|
||||
const metadata = new Map(candidates.map((item, i) => [item.trackId, {
|
||||
artistId: i < 5 ? 'overplayed-artist' : `artist-${i}`,
|
||||
albumId: i < 4 ? 'overplayed-album' : `album-${i}`,
|
||||
genreId: i < 6 ? 'genre-a' : 'genre-b',
|
||||
language: i < 7 ? 'ja' : 'en',
|
||||
instrumental: i === 7,
|
||||
newArtist: i === 8 || i === 9,
|
||||
favorite: i === 10 || i === 11 || i === 12,
|
||||
}]));
|
||||
|
||||
const result = selectConstrainedSequence({ candidates, slots, metadata, budgets, roleToGeneratorIds });
|
||||
expect(result.plan).toHaveLength(10);
|
||||
const ids = result.plan.map(item => item.trackId);
|
||||
expect(ids.filter(id => metadata.get(id)?.artistId === 'overplayed-artist')).toHaveLength(2);
|
||||
expect(ids.filter(id => metadata.get(id)?.albumId === 'overplayed-album').length).toBeLessThanOrEqual(3);
|
||||
expect(ids.filter(id => metadata.get(id)?.instrumental)).toHaveLength(1);
|
||||
expect(ids.filter(id => metadata.get(id)?.newArtist)).toHaveLength(2);
|
||||
expect(ids.filter(id => metadata.get(id)?.favorite)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('corrects the detected dimension directly before relaxing it', () => {
|
||||
const candidates = ['ja-1', 'ja-2', 'en-1', 'en-2'].map(trackId => ({ ...candidate(trackId), generatorId: 'comfort' }));
|
||||
const metadata = new Map([
|
||||
['ja-1', { artistId: 'a1', albumId: 'x1', language: 'ja' }],
|
||||
['ja-2', { artistId: 'a2', albumId: 'x2', language: 'ja' }],
|
||||
['en-1', { artistId: 'a3', albumId: 'x3', language: 'en' }],
|
||||
['en-2', { artistId: 'a4', albumId: 'x4', language: 'en' }],
|
||||
]);
|
||||
const result = selectConstrainedSequence({
|
||||
candidates,
|
||||
slots: slots.slice(0, 2),
|
||||
metadata,
|
||||
budgets: [],
|
||||
roleToGeneratorIds,
|
||||
loopDimension: 'language',
|
||||
loopedValue: 'ja',
|
||||
});
|
||||
expect(result.plan.map(item => item.trackId)).toEqual(['en-1', 'en-2']);
|
||||
expect(result.relaxations).toEqual([]);
|
||||
});
|
||||
|
||||
it('records a structured soft relaxation without violating hard album caps', () => {
|
||||
const candidates = Array.from({ length: 5 }, (_, i) => ({ ...candidate(`t${i}`), generatorId: 'comfort' }));
|
||||
const metadata = new Map(candidates.map((item, i) => [item.trackId, {
|
||||
artistId: `artist-${i}`,
|
||||
albumId: i < 4 ? 'single-album' : `album-${i}`,
|
||||
language: 'ja',
|
||||
}]));
|
||||
const result = selectConstrainedSequence({
|
||||
candidates, slots: slots.slice(0, 5), metadata, budgets: [], roleToGeneratorIds,
|
||||
loopDimension: 'language', loopedValue: 'ja',
|
||||
});
|
||||
expect(result.plan.filter(item => metadata.get(item.trackId)?.albumId === 'single-album')).toHaveLength(3);
|
||||
expect(result.relaxations).toContainEqual(expect.objectContaining({ stage: 'soft_budget' }));
|
||||
});
|
||||
|
||||
it('counts the retained queue tail against hard artist caps before selecting replacements', () => {
|
||||
const retained = [candidate('queued-a1'), candidate('queued-a2')];
|
||||
const candidates = [candidate('same-artist'), candidate('other-artist')].map(item => ({ ...item, generatorId: 'comfort' }));
|
||||
const metadata = new Map([
|
||||
['queued-a1', { artistId: 'artist-a', albumId: 'queued-album-1' }],
|
||||
['queued-a2', { artistId: 'artist-a', albumId: 'queued-album-2' }],
|
||||
['same-artist', { artistId: 'artist-a', albumId: 'replacement-album' }],
|
||||
['other-artist', { artistId: 'artist-b', albumId: 'replacement-album-2' }],
|
||||
]);
|
||||
|
||||
const result = selectConstrainedSequence({
|
||||
candidates, slots: slots.slice(0, 1), metadata, budgets: [], roleToGeneratorIds, retainedPlan: retained,
|
||||
});
|
||||
expect(result.plan.map(item => item.trackId)).toEqual(['other-artist']);
|
||||
});
|
||||
|
||||
it('enforces the three-track album limit across the rolling 40-play history', () => {
|
||||
const candidates = [candidate('same-album'), candidate('new-album')].map(item => ({ ...item, generatorId: 'comfort' }));
|
||||
const metadata = new Map([
|
||||
['same-album', { artistId: 'a4', albumId: 'album-a' }],
|
||||
['new-album', { artistId: 'a5', albumId: 'album-b' }],
|
||||
]);
|
||||
const albumHistory = Array.from({ length: 40 }, (_, index) => ({
|
||||
artistId: `history-${index}`,
|
||||
albumId: index < 3 ? 'album-a' : `history-album-${index}`,
|
||||
}));
|
||||
|
||||
const result = selectConstrainedSequence({
|
||||
candidates, slots: slots.slice(0, 1), metadata, budgets: [], roleToGeneratorIds, albumHistory,
|
||||
});
|
||||
expect(result.plan.map(item => item.trackId)).toEqual(['new-album']);
|
||||
});
|
||||
|
||||
it('projects budgets over historical counts and the planned horizon with track-consistent denominators', () => {
|
||||
const candidates = [candidate('ja'), candidate('en')].map(item => ({ ...item, generatorId: 'comfort' }));
|
||||
const metadata = new Map([
|
||||
['ja', { artistId: 'a1', albumId: 'x1', genreId: 'j-pop' }],
|
||||
['en', { artistId: 'a2', albumId: 'x2', genreId: 'rock' }],
|
||||
]);
|
||||
const historicalValues = new Map([['j-pop', 4], ['rock', 1]]);
|
||||
const result = selectConstrainedSequence({
|
||||
candidates,
|
||||
slots: slots.slice(0, 1),
|
||||
metadata,
|
||||
budgets: [{ dimension: 'genre', budgetShare: 0.6, horizonMin: 30, spent: 0.8, historicalTotal: 5, historicalValues }],
|
||||
roleToGeneratorIds,
|
||||
});
|
||||
// 4 / 5 becomes 4 / 6 if rock is selected; a fifth j-pop track would
|
||||
// exceed the 60% cap. The selector must use history + proposal, not only
|
||||
// the one-track replacement queue.
|
||||
expect(result.plan.map(item => item.trackId)).toEqual(['en']);
|
||||
});
|
||||
|
||||
it('does not treat unknown instrumentation as a vocal/instrumental budget credit', () => {
|
||||
const candidates = [candidate('unknown'), candidate('instrumental')].map(item => ({ ...item, generatorId: 'comfort' }));
|
||||
const metadata = new Map([
|
||||
['unknown', { artistId: 'a1', albumId: 'x1' }],
|
||||
['instrumental', { artistId: 'a2', albumId: 'x2', instrumental: true }],
|
||||
]);
|
||||
const result = selectConstrainedSequence({
|
||||
candidates,
|
||||
slots: slots.slice(0, 1),
|
||||
metadata,
|
||||
budgets: [{ dimension: 'instrumental', budgetShare: 1, horizonMin: 30, spent: 0, historicalTotal: 0, historicalValues: new Map() }],
|
||||
roleToGeneratorIds,
|
||||
});
|
||||
expect(result.plan.map(item => item.trackId)).toEqual(['instrumental']);
|
||||
});
|
||||
|
||||
it('excludes candidates matching any detected producer or label, not only their first claim', () => {
|
||||
const candidates = [candidate('producer-match'), candidate('label-match'), candidate('safe')].map(item => ({ ...item, generatorId: 'comfort' }));
|
||||
const metadata = new Map([
|
||||
['producer-match', { artistId: 'a1', albumId: 'x1', producerIds: ['other', 'producer-loop'] }],
|
||||
['label-match', { artistId: 'a2', albumId: 'x2', labelIds: ['other', 'label-loop'] }],
|
||||
['safe', { artistId: 'a3', albumId: 'x3', producerIds: ['safe-producer'], labelIds: ['safe-label'] }],
|
||||
]);
|
||||
const producerResult = selectConstrainedSequence({
|
||||
candidates, slots: slots.slice(0, 1), metadata, budgets: [], roleToGeneratorIds,
|
||||
loopDimension: 'producer', loopedValues: ['producer-loop'],
|
||||
});
|
||||
const labelResult = selectConstrainedSequence({
|
||||
candidates, slots: slots.slice(0, 1), metadata, budgets: [], roleToGeneratorIds,
|
||||
loopDimension: 'label', loopedValues: ['label-loop'],
|
||||
});
|
||||
expect(producerResult.plan.map(item => item.trackId)).not.toContain('producer-match');
|
||||
expect(labelResult.plan.map(item => item.trackId)).not.toContain('label-match');
|
||||
});
|
||||
|
||||
it('keeps stable candidate order while reusing a role preference pool', () => {
|
||||
const candidates = [
|
||||
{ ...candidate('comfort-first'), generatorId: 'comfort' },
|
||||
{ ...candidate('adjacent-first'), generatorId: 'adjacent' },
|
||||
{ ...candidate('comfort-second'), generatorId: 'comfort' },
|
||||
{ ...candidate('adjacent-second'), generatorId: 'adjacent' },
|
||||
];
|
||||
const metadata = new Map(candidates.map((item, index) => [item.trackId, {
|
||||
artistId: `artist-${index}`, albumId: `album-${index}`,
|
||||
}]));
|
||||
const result = selectConstrainedSequence({
|
||||
candidates,
|
||||
slots: Array.from({ length: 4 }, (_, position) => ({ position, role: position % 2 ? 'adjacent' : 'known' })),
|
||||
metadata,
|
||||
budgets: [],
|
||||
roleToGeneratorIds: role => role === 'adjacent' ? ['adjacent'] : ['comfort'],
|
||||
});
|
||||
expect(result.plan.map(item => item.trackId)).toEqual([
|
||||
'comfort-first', 'adjacent-first', 'comfort-second', 'adjacent-second',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('anti-loop signals', () => {
|
||||
const variedRecentPlays = Array.from({ length: 4 }, (_, index) => ({
|
||||
trackId: `00000000-0000-0000-0000-00000000000${index + 1}`,
|
||||
artistId: `artist-${index}`,
|
||||
genreId: `genre-${index}`,
|
||||
language: `lang-${index}`,
|
||||
bpm: 80 + index * 30,
|
||||
energy: index / 3,
|
||||
vocal: null,
|
||||
decade: 1980 + index * 10,
|
||||
valence: index % 2,
|
||||
albumId: `album-${index}`,
|
||||
producerIds: [],
|
||||
labelIds: [],
|
||||
}));
|
||||
|
||||
it('returns fused producer lineage from resolved main artists', async () => {
|
||||
const db = makeMockDb();
|
||||
(db.pgClient.query as any).mockResolvedValue({ rows: [{ lineage_id: 'producer-a' }, { lineage_id: 'producer-b' }] });
|
||||
const director = new SessionDirector(db);
|
||||
const signal = await director.detectAntiLoop({} as any, {} as any, [], variedRecentPlays);
|
||||
expect(signal).toEqual({ dimension: 'producer', values: ['producer-a', 'producer-b'] });
|
||||
const sql = (db.pgClient.query as any).mock.calls[0][0] as string;
|
||||
expect(sql).toContain('claim_fusion cf');
|
||||
expect(sql).toContain("cf.subject_type = 'artist'");
|
||||
expect(sql).toContain('cf.object_id = recent.artist_id');
|
||||
expect(sql).toContain('ORDER BY ta.confidence DESC, ta.artist_id');
|
||||
});
|
||||
|
||||
it('returns label identities after a producer check finds no loop', async () => {
|
||||
const db = makeMockDb();
|
||||
(db.pgClient.query as any)
|
||||
.mockResolvedValueOnce({ rows: [] })
|
||||
.mockResolvedValueOnce({ rows: [{ lineage_id: 'label-a' }] });
|
||||
const director = new SessionDirector(db);
|
||||
const signal = await director.detectAntiLoop({} as any, {} as any, [], variedRecentPlays);
|
||||
expect(signal).toEqual({ dimension: 'label', values: ['label-a'] });
|
||||
const sql = (db.pgClient.query as any).mock.calls[1][0] as string;
|
||||
expect(sql).toContain("cf.predicate = 'same_label_as'");
|
||||
expect(sql).toContain('cf.subject_id = recent.artist_id OR cf.object_id = recent.artist_id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('planner metadata and integration boundaries', () => {
|
||||
it('counts every completed play in a budget horizon while only classifying known values', async () => {
|
||||
const db = makeMockDb();
|
||||
(db.pgClient.query as any).mockResolvedValue({
|
||||
rows: [{ value: 'rock', cnt: 3 }, { value: null, cnt: 2 }],
|
||||
});
|
||||
const director = new SessionDirector(db);
|
||||
const usage = await (director as any).loadBudgetUsage('user-1', 'genre', 30);
|
||||
expect(usage).toMatchObject({ total: 5, spent: 0.6 });
|
||||
expect(usage.values).toEqual(new Map([['rock', 3]]));
|
||||
const sql = (db.pgClient.query as any).mock.calls[0][0] as string;
|
||||
expect(sql).toContain('WITH completed_plays AS');
|
||||
expect(sql).not.toContain('WHERE value IS NOT NULL');
|
||||
});
|
||||
|
||||
it('loads producer and label lineage from fused relationships of the resolved main artist', async () => {
|
||||
const db = makeMockDb();
|
||||
(db.pgClient.query as any).mockResolvedValue({
|
||||
rows: [{
|
||||
track_id: 'track-1', artist_id: 'artist-1', album_id: 'album-1', genre_id: null,
|
||||
language: null, instrumentalness: null, favorite: false, new_artist: false,
|
||||
energy: null, bpm: null, valence: null, release_date: null,
|
||||
producer_ids: ['producer-from-object', 'producer-from-subject'],
|
||||
label_ids: ['label-from-object', 'label-from-subject'],
|
||||
}],
|
||||
});
|
||||
const director = new SessionDirector(db);
|
||||
const metadata = await (director as any).loadConstraintMetadata('user-1', ['track-1']);
|
||||
expect(metadata.get('track-1')).toMatchObject({
|
||||
artistId: 'artist-1',
|
||||
producerIds: ['producer-from-object', 'producer-from-subject'],
|
||||
labelIds: ['label-from-object', 'label-from-subject'],
|
||||
});
|
||||
const sql = (db.pgClient.query as any).mock.calls[0][0] as string;
|
||||
expect(sql).toContain('FROM claim_fusion cf');
|
||||
expect(sql).toContain("cf.predicate = 'produced'");
|
||||
expect(sql).toContain("cf.predicate = 'same_label_as'");
|
||||
expect(sql).toContain('cf.subject_id = artist.artist_id OR cf.object_id = artist.artist_id');
|
||||
expect(sql).toContain('ORDER BY ta.confidence DESC, ta.artist_id');
|
||||
});
|
||||
|
||||
it('carries the retained tail and all 40 album-history plays through replan into constraint selection', async () => {
|
||||
const db = makeMockDb({
|
||||
getVibeSessionTrackIds: vi.fn().mockResolvedValue([]),
|
||||
getListenerBeliefs: vi.fn().mockResolvedValue([]),
|
||||
});
|
||||
const director = new SessionDirector(db);
|
||||
const history = Array.from({ length: 40 }, (_, index) => ({
|
||||
track_id: `history-${index}`, album_id: index < 3 ? 'history-album' : `old-album-${index}`,
|
||||
artist_id: `history-artist-${index}`, genre_id: null, bpm: null, energy: null,
|
||||
valence: null, instrumentalness: null, language: null, release_date: null,
|
||||
producer_ids: [], label_ids: [],
|
||||
}));
|
||||
(db.pgClient.query as any).mockImplementation((sql: string, params: unknown[] = []) => {
|
||||
if (sql.includes('FROM play_history ph') && sql.includes('LIMIT $2')) return Promise.resolve({ rows: history });
|
||||
if (sql.includes('WHERE t.id = ANY($2::uuid[])')) {
|
||||
const ids = params[1] as string[];
|
||||
return Promise.resolve({ rows: ids.map(trackId => ({
|
||||
track_id: trackId,
|
||||
artist_id: `artist-${trackId}`,
|
||||
album_id: trackId === 'history-album-candidate' ? 'history-album' : `album-${trackId}`,
|
||||
genre_id: null, language: null, instrumentalness: null, favorite: false,
|
||||
new_artist: false, energy: null, bpm: null, valence: null, release_date: null,
|
||||
producer_ids: trackId === 'producer-loop-candidate' ? ['producer-loop'] : [],
|
||||
label_ids: [],
|
||||
})) });
|
||||
}
|
||||
return Promise.resolve({ rows: [] });
|
||||
});
|
||||
vi.spyOn(director, 'buildState').mockResolvedValue({
|
||||
energy: 0.5, noveltyHunger: 0.3, sessionAgeMin: 0, lastArtistIds: [], lastGenreIds: [], context: null,
|
||||
});
|
||||
vi.spyOn(director, 'computeFatigue').mockResolvedValue({
|
||||
artist: new Map(), album: new Map(), genre: new Map(), language: new Map(), track: new Map(), vocal: 0.5,
|
||||
});
|
||||
vi.spyOn(director, 'getBudgets').mockResolvedValue([]);
|
||||
vi.spyOn(director, 'buildRepetitionState').mockResolvedValue({ recentTrackIds: new Set(), recentArtistIds: new Set() });
|
||||
vi.spyOn(director, 'rankCandidates').mockImplementation(async candidates => candidates);
|
||||
vi.spyOn(director, 'detectAntiLoop').mockResolvedValue({ dimension: 'producer', values: ['producer-loop'] });
|
||||
vi.spyOn(director as any, 'loadArtistMap').mockResolvedValue(new Map());
|
||||
|
||||
const originalGenerators = [...ALL_GENERATORS];
|
||||
ALL_GENERATORS.splice(0, ALL_GENERATORS.length, async () => [
|
||||
{ ...candidate('producer-loop-candidate'), generatorId: 'comfort' },
|
||||
{ ...candidate('history-album-candidate'), generatorId: 'comfort' },
|
||||
...Array.from({ length: 5 }, (_, index) => ({ ...candidate(`safe-candidate-comfort-${index}`), generatorId: 'comfort' })),
|
||||
...Array.from({ length: 4 }, (_, index) => ({ ...candidate(`safe-candidate-adjacent-${index}`), generatorId: 'adjacent' })),
|
||||
...Array.from({ length: 2 }, (_, index) => ({ ...candidate(`safe-candidate-favorite-${index}`), generatorId: 'deep-dive' })),
|
||||
]);
|
||||
let captured: any;
|
||||
vi.spyOn(director as any, 'constrainedSequence').mockImplementation((params: any) => {
|
||||
captured = params;
|
||||
return selectConstrainedSequence(params);
|
||||
});
|
||||
try {
|
||||
const retained = Array.from({ length: 9 }, (_, index) => ({ ...candidate(`queued-${index}`), generatorId: 'comfort' }));
|
||||
const plan = await director.replan('user-1', 'session-1', retained, []);
|
||||
expect(captured.retainedPlan.map((item: { trackId: string }) => item.trackId)).toEqual(retained.map(item => item.trackId));
|
||||
expect(captured.albumHistory).toHaveLength(40);
|
||||
expect(captured.loopDimension).toBe('producer');
|
||||
expect(captured.metadata.get('producer-loop-candidate').producerIds).toEqual(['producer-loop']);
|
||||
expect(plan.map(item => item.trackId)).not.toContain('history-album-candidate');
|
||||
expect(plan.map(item => item.trackId)).toContain('safe-candidate-comfort-0');
|
||||
} finally {
|
||||
ALL_GENERATORS.splice(0, ALL_GENERATORS.length, ...originalGenerators);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildState', () => {
|
||||
it('returns state with default values when no prior session', async () => {
|
||||
const db = makeMockDb();
|
||||
|
||||
Reference in New Issue
Block a user