fix: soften vibe artist exclusions
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled

This commit is contained in:
kami
2026-08-01 16:27:55 +04:00
parent 4c48d11e9d
commit e62b7e8d10
3 changed files with 68 additions and 29 deletions
+26 -19
View File
@@ -13,8 +13,10 @@ interface ActivePlan {
servedTrackIds?: string[]; servedTrackIds?: string[];
/** Explicit feedback targets (skip, dislike, completion, promotion). */ /** Explicit feedback targets (skip, dislike, completion, promotion). */
excludedTrackIds?: string[]; excludedTrackIds?: string[];
/** Main artists served or explicitly rejected in this session. */ /** Main artists already heard in this Vibe; they get a mild rank penalty. */
excludedArtistIds?: string[]; servedArtistIds?: string[];
/** Artists explicitly skipped/disliked; they receive the stronger penalty. */
downrankedArtistIds?: string[];
} }
const PLAN_TTL_SEC = 2 * 3600; const PLAN_TTL_SEC = 2 * 3600;
@@ -26,9 +28,9 @@ function planKey(userId: string): string {
return `v2:plan:${userId}`; return `v2:plan:${userId}`;
} }
function appendUniqueTrackId(ids: string[] | undefined, trackId: string): string[] { function appendUniqueId(ids: string[] | undefined, id: string): string[] {
const next = ids ? [...ids] : []; const next = ids ? [...ids] : [];
if (!next.includes(trackId)) next.push(trackId); if (!next.includes(id)) next.push(id);
return next.length > MAX_SESSION_EXCLUSIONS return next.length > MAX_SESSION_EXCLUSIONS
? next.slice(next.length - MAX_SESSION_EXCLUSIONS) ? next.slice(next.length - MAX_SESSION_EXCLUSIONS)
: next; : next;
@@ -41,8 +43,11 @@ function sessionExclusions(active: ActivePlan): string[] {
])]; ])];
} }
function sessionArtistExclusions(active: ActivePlan): string[] { function sessionArtistPenalties(active: ActivePlan): Map<string, number> {
return [...new Set(active.excludedArtistIds ?? [])]; const penalties = new Map<string, number>();
for (const artistId of active.servedArtistIds ?? []) penalties.set(artistId, 0.2);
for (const artistId of active.downrankedArtistIds ?? []) penalties.set(artistId, 0.75);
return penalties;
} }
export default async function v2Routes(fastify: FastifyInstance, options: { dbService: DbService; sessionDirector: SessionDirector }) { export default async function v2Routes(fastify: FastifyInstance, options: { dbService: DbService; sessionDirector: SessionDirector }) {
@@ -148,7 +153,7 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe
} }
const next = active.plan.shift()!; const next = active.plan.shift()!;
active.servedTrackIds = appendUniqueTrackId(active.servedTrackIds, next.trackId); active.servedTrackIds = appendUniqueId(active.servedTrackIds, next.trackId);
const artistResult = await dbService.pgClient.query<{ artist_id: string }>( const artistResult = await dbService.pgClient.query<{ artist_id: string }>(
`SELECT artist_id FROM track_artists_v2 `SELECT artist_id FROM track_artists_v2
WHERE track_id = $1 AND role = 'main' WHERE track_id = $1 AND role = 'main'
@@ -156,7 +161,7 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe
[next.trackId] [next.trackId]
); );
if (artistResult.rows[0]?.artist_id) { if (artistResult.rows[0]?.artist_id) {
active.excludedArtistIds = appendUniqueTrackId(active.excludedArtistIds, artistResult.rows[0].artist_id); active.servedArtistIds = appendUniqueId(active.servedArtistIds, artistResult.rows[0].artist_id);
} }
// Enrich with track details // Enrich with track details
const track = await dbService.getTrackById(next.trackId); const track = await dbService.getTrackById(next.trackId);
@@ -169,7 +174,7 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe
active.plan, active.plan,
[next.trackId], [next.trackId],
active.seedTrackId ?? undefined, active.seedTrackId ?? undefined,
{ excludedTrackIds: sessionExclusions(active), excludedArtistIds: sessionArtistExclusions(active) } { excludedTrackIds: sessionExclusions(active), artistPenalties: sessionArtistPenalties(active) }
); );
active.plan = refill; active.plan = refill;
} }
@@ -233,15 +238,17 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe
if (!sessionId || active.sessionId !== sessionId) return 0; if (!sessionId || active.sessionId !== sessionId) return 0;
// Feedback may race /next or arrive after a client-side prefetch. In all // Feedback may race /next or arrive after a client-side prefetch. In all
// cases its track becomes ineligible for the rest of this session. // cases its track becomes ineligible for the rest of this session.
active.excludedTrackIds = appendUniqueTrackId(active.excludedTrackIds, trackId); active.excludedTrackIds = appendUniqueId(active.excludedTrackIds, trackId);
const artistResult = await dbService.pgClient.query<{ artist_id: string }>( if (action === 'skipped' || action === 'disliked') {
`SELECT artist_id FROM track_artists_v2 const artistResult = await dbService.pgClient.query<{ artist_id: string }>(
WHERE track_id = $1 AND role = 'main' `SELECT artist_id FROM track_artists_v2
ORDER BY confidence DESC NULLS LAST LIMIT 1`, WHERE track_id = $1 AND role = 'main'
[trackId] ORDER BY confidence DESC NULLS LAST LIMIT 1`,
); [trackId]
if (artistResult.rows[0]?.artist_id) { );
active.excludedArtistIds = appendUniqueTrackId(active.excludedArtistIds, artistResult.rows[0].artist_id); if (artistResult.rows[0]?.artist_id) {
active.downrankedArtistIds = appendUniqueId(active.downrankedArtistIds, artistResult.rows[0].artist_id);
}
} }
const sessionTrackIds = sessionExclusions(active); const sessionTrackIds = sessionExclusions(active);
const refill = await director.replan( const refill = await director.replan(
@@ -250,7 +257,7 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe
active.plan, active.plan,
[trackId], [trackId],
active.seedTrackId ?? undefined, active.seedTrackId ?? undefined,
{ excludedTrackIds: sessionTrackIds, excludedArtistIds: sessionArtistExclusions(active) } { excludedTrackIds: sessionTrackIds, artistPenalties: sessionArtistPenalties(active) }
); );
active.plan = refill; active.plan = refill;
await setActivePlan(userId, active); await setActivePlan(userId, active);
@@ -37,8 +37,12 @@ export interface RepetitionState {
export interface PlanBuildOptions { export interface PlanBuildOptions {
/** Tracks already exposed during this Vibe session; they are ineligible. */ /** Tracks already exposed during this Vibe session; they are ineligible. */
excludedTrackIds?: Iterable<string>; excludedTrackIds?: Iterable<string>;
/** Main artists already served or rejected in this Vibe session. */ /**
excludedArtistIds?: Iterable<string>; * Session-local artist penalties. These deliberately affect ranking rather
* than eligibility: a Vibe may return to an artist after giving other
* artists a fair chance, but skips/dislikes push that artist much lower.
*/
artistPenalties?: Iterable<readonly [string, number]>;
} }
const W_ENJOY = 1.0; const W_ENJOY = 1.0;
@@ -46,6 +50,7 @@ const W_FATIGUE = 0.4;
const W_DIVERSITY = 0.3; const W_DIVERSITY = 0.3;
const W_ENTROPY = 0.2; const W_ENTROPY = 0.2;
const W_REPETITION = 0.5; const W_REPETITION = 0.5;
const W_SESSION_ARTIST_PENALTY = 0.5;
const PLAN_SIZE = 20; const PLAN_SIZE = 20;
/** /**
@@ -727,7 +732,8 @@ export class SessionDirector {
fatigue: FatigueState, fatigue: FatigueState,
budgets: DiversityBudget[], budgets: DiversityBudget[],
state: GeneratorContext['state'], state: GeneratorContext['state'],
repetitionState: RepetitionState repetitionState: RepetitionState,
artistPenalties: ReadonlyMap<string, number> = new Map()
): Promise<Candidate[]> { ): Promise<Candidate[]> {
if (candidates.length === 0) return []; if (candidates.length === 0) return [];
@@ -783,6 +789,11 @@ export class SessionDirector {
+ W_DIVERSITY * diversityBonus + W_DIVERSITY * diversityBonus
+ W_ENTROPY * entropyBonus; + W_ENTROPY * entropyBonus;
// Session history should diversify the queue, not make an artist
// permanently ineligible. A skipped/disliked artist receives the larger
// penalty supplied by the route; a merely served artist gets a small one.
score -= W_SESSION_ARTIST_PENALTY * (artistPenalties.get(artistId) ?? 0);
if (wouldRepeat) { if (wouldRepeat) {
score *= 0.1; score *= 0.1;
} }
@@ -901,7 +912,7 @@ export class SessionDirector {
]); ]);
if (seedTrackId) recentExclusionSet.add(seedTrackId); if (seedTrackId) recentExclusionSet.add(seedTrackId);
const recentExclusions = [...recentExclusionSet]; const recentExclusions = [...recentExclusionSet];
const excludedArtistIds = new Set(options.excludedArtistIds ?? []); const artistPenalties = new Map(options.artistPenalties ?? []);
const toleranceMap: Record<string, number> = {}; const toleranceMap: Record<string, number> = {};
const discoveryBeliefs = allBeliefs.filter(b => b.profile === 'discovery'); const discoveryBeliefs = allBeliefs.filter(b => b.profile === 'discovery');
for (const b of discoveryBeliefs) { for (const b of discoveryBeliefs) {
@@ -938,15 +949,14 @@ export class SessionDirector {
const eligibleCandidates = allCandidates.filter(candidate => const eligibleCandidates = allCandidates.filter(candidate =>
!recentExclusionSet.has(candidate.trackId) && !recentExclusionSet.has(candidate.trackId) &&
!repetitionState.recentTrackIds.has(candidate.trackId) && !repetitionState.recentTrackIds.has(candidate.trackId) &&
!repetitionState.recentArtistIds.has(candidateArtistMap.get(candidate.trackId) ?? '') && !repetitionState.recentArtistIds.has(candidateArtistMap.get(candidate.trackId) ?? '')
!excludedArtistIds.has(candidateArtistMap.get(candidate.trackId) ?? '')
); );
if (eligibleCandidates.length === 0) { if (eligibleCandidates.length === 0) {
return []; return [];
} }
const ranked = await this.rankCandidates( const ranked = await this.rankCandidates(
eligibleCandidates, fatigue, budgets, state, repetitionState eligibleCandidates, fatigue, budgets, state, repetitionState, artistPenalties
); );
const loopDim = await this.detectAntiLoop(state, fatigue, budgets, recentPlays); const loopDim = await this.detectAntiLoop(state, fatigue, budgets, recentPlays);
@@ -1072,7 +1082,7 @@ export class SessionDirector {
if (loopDim) { if (loopDim) {
const rebuilt = await this.buildPlan(userId, sessionId, seedTrackId, { const rebuilt = await this.buildPlan(userId, sessionId, seedTrackId, {
excludedTrackIds: new Set([...excludedTrackIds, ...remainingSlots.map(c => c.trackId)]), excludedTrackIds: new Set([...excludedTrackIds, ...remainingSlots.map(c => c.trackId)]),
excludedArtistIds: options.excludedArtistIds, artistPenalties: options.artistPenalties,
}); });
return mergeUniquePlan(remainingSlots, rebuilt, excludedTrackIds, PLAN_SIZE); return mergeUniquePlan(remainingSlots, rebuilt, excludedTrackIds, PLAN_SIZE);
} }
@@ -1082,7 +1092,7 @@ export class SessionDirector {
if (Math.abs(entropy - 0.55) > 0.2) { if (Math.abs(entropy - 0.55) > 0.2) {
const rebuilt = await this.buildPlan(userId, sessionId, seedTrackId, { const rebuilt = await this.buildPlan(userId, sessionId, seedTrackId, {
excludedTrackIds: new Set([...excludedTrackIds, ...remainingSlots.map(c => c.trackId)]), excludedTrackIds: new Set([...excludedTrackIds, ...remainingSlots.map(c => c.trackId)]),
excludedArtistIds: options.excludedArtistIds, artistPenalties: options.artistPenalties,
}); });
return mergeUniquePlan(remainingSlots, rebuilt, excludedTrackIds, PLAN_SIZE); return mergeUniquePlan(remainingSlots, rebuilt, excludedTrackIds, PLAN_SIZE);
} }
@@ -1096,7 +1106,7 @@ export class SessionDirector {
// queued. Keep the valid tail and append only fresh candidates. // queued. Keep the valid tail and append only fresh candidates.
const rebuilt = await this.buildPlan(userId, sessionId, seedTrackId, { const rebuilt = await this.buildPlan(userId, sessionId, seedTrackId, {
excludedTrackIds: new Set([...excludedTrackIds, ...remainingSlots.map(c => c.trackId)]), excludedTrackIds: new Set([...excludedTrackIds, ...remainingSlots.map(c => c.trackId)]),
excludedArtistIds: options.excludedArtistIds, artistPenalties: options.artistPenalties,
}); });
return mergeUniquePlan(remainingSlots, rebuilt, excludedTrackIds, PLAN_SIZE); return mergeUniquePlan(remainingSlots, rebuilt, excludedTrackIds, PLAN_SIZE);
} }
@@ -167,6 +167,28 @@ describe('SessionDirector', () => {
const ranked = await director.rankCandidates(candidates, fatigue, budgets, state, repetitionState); const ranked = await director.rankCandidates(candidates, fatigue, budgets, state, repetitionState);
expect(ranked[0].relevance).toBeGreaterThanOrEqual(ranked[ranked.length - 1].relevance); expect(ranked[0].relevance).toBeGreaterThanOrEqual(ranked[ranked.length - 1].relevance);
}); });
it('downranks a skipped artist without making it ineligible', async () => {
const db = makeMockDb();
(db.pgClient.query as any)
.mockResolvedValueOnce({ rows: [{ track_id: 't1', artist_id: 'a1' }, { track_id: 't2', artist_id: 'a2' }] })
.mockResolvedValueOnce({ rows: [] });
const director = new SessionDirector(db);
const candidates = [candidate('t1'), candidate('t2')];
const fatigue = { artist: new Map(), genre: new Map(), track: new Map(), language: new Map(), vocal: 0 };
const state = { energy: 0.5, noveltyHunger: 0.3, sessionAgeMin: 10, lastArtistIds: [], lastGenreIds: [], context: null };
const ranked = await director.rankCandidates(
candidates,
fatigue,
[],
state,
{ recentTrackIds: new Set<string>(), recentArtistIds: new Set<string>() },
new Map([['a1', 0.75]])
);
expect(ranked.map(item => item.trackId)).toEqual(['t2', 't1']);
});
}); });
describe('buildState', () => { describe('buildState', () => {