feat(vibe): enforce session diversity constraints

This commit is contained in:
kami
2026-08-02 00:14:39 +04:00
parent 57df1cfe9f
commit 89a23e3703
2 changed files with 943 additions and 212 deletions
File diff suppressed because it is too large Load Diff
+338 -2
View File
@@ -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();