fix: rely on vibe fatigue instead of artist counters
This commit is contained in:
@@ -13,10 +13,6 @@ 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 already heard in this Vibe; they get a mild rank penalty. */
|
|
||||||
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;
|
||||||
@@ -43,13 +39,6 @@ function sessionExclusions(active: ActivePlan): string[] {
|
|||||||
])];
|
])];
|
||||||
}
|
}
|
||||||
|
|
||||||
function sessionArtistPenalties(active: ActivePlan): Map<string, number> {
|
|
||||||
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 }) {
|
||||||
const { dbService, sessionDirector: director } = options;
|
const { dbService, sessionDirector: director } = options;
|
||||||
|
|
||||||
@@ -154,15 +143,6 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe
|
|||||||
|
|
||||||
const next = active.plan.shift()!;
|
const next = active.plan.shift()!;
|
||||||
active.servedTrackIds = appendUniqueId(active.servedTrackIds, next.trackId);
|
active.servedTrackIds = appendUniqueId(active.servedTrackIds, next.trackId);
|
||||||
const artistResult = await dbService.pgClient.query<{ artist_id: string }>(
|
|
||||||
`SELECT artist_id FROM track_artists_v2
|
|
||||||
WHERE track_id = $1 AND role = 'main'
|
|
||||||
ORDER BY confidence DESC NULLS LAST LIMIT 1`,
|
|
||||||
[next.trackId]
|
|
||||||
);
|
|
||||||
if (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);
|
||||||
|
|
||||||
@@ -174,7 +154,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), artistPenalties: sessionArtistPenalties(active) }
|
{ excludedTrackIds: sessionExclusions(active) }
|
||||||
);
|
);
|
||||||
active.plan = refill;
|
active.plan = refill;
|
||||||
}
|
}
|
||||||
@@ -239,17 +219,6 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe
|
|||||||
// 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 = appendUniqueId(active.excludedTrackIds, trackId);
|
active.excludedTrackIds = appendUniqueId(active.excludedTrackIds, trackId);
|
||||||
if (action === 'skipped' || action === 'disliked') {
|
|
||||||
const artistResult = await dbService.pgClient.query<{ artist_id: string }>(
|
|
||||||
`SELECT artist_id FROM track_artists_v2
|
|
||||||
WHERE track_id = $1 AND role = 'main'
|
|
||||||
ORDER BY confidence DESC NULLS LAST LIMIT 1`,
|
|
||||||
[trackId]
|
|
||||||
);
|
|
||||||
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(
|
||||||
userId,
|
userId,
|
||||||
@@ -257,7 +226,7 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe
|
|||||||
active.plan,
|
active.plan,
|
||||||
[trackId],
|
[trackId],
|
||||||
active.seedTrackId ?? undefined,
|
active.seedTrackId ?? undefined,
|
||||||
{ excludedTrackIds: sessionTrackIds, artistPenalties: sessionArtistPenalties(active) }
|
{ excludedTrackIds: sessionTrackIds }
|
||||||
);
|
);
|
||||||
active.plan = refill;
|
active.plan = refill;
|
||||||
await setActivePlan(userId, active);
|
await setActivePlan(userId, active);
|
||||||
|
|||||||
@@ -37,12 +37,6 @@ 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>;
|
||||||
/**
|
|
||||||
* 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;
|
||||||
@@ -50,7 +44,6 @@ 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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -732,8 +725,7 @@ 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 [];
|
||||||
|
|
||||||
@@ -789,11 +781,6 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -912,7 +899,6 @@ export class SessionDirector {
|
|||||||
]);
|
]);
|
||||||
if (seedTrackId) recentExclusionSet.add(seedTrackId);
|
if (seedTrackId) recentExclusionSet.add(seedTrackId);
|
||||||
const recentExclusions = [...recentExclusionSet];
|
const recentExclusions = [...recentExclusionSet];
|
||||||
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) {
|
||||||
@@ -956,7 +942,7 @@ export class SessionDirector {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const ranked = await this.rankCandidates(
|
const ranked = await this.rankCandidates(
|
||||||
eligibleCandidates, fatigue, budgets, state, repetitionState, artistPenalties
|
eligibleCandidates, fatigue, budgets, state, repetitionState
|
||||||
);
|
);
|
||||||
|
|
||||||
const loopDim = await this.detectAntiLoop(state, fatigue, budgets, recentPlays);
|
const loopDim = await this.detectAntiLoop(state, fatigue, budgets, recentPlays);
|
||||||
@@ -1082,7 +1068,6 @@ 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)]),
|
||||||
artistPenalties: options.artistPenalties,
|
|
||||||
});
|
});
|
||||||
return mergeUniquePlan(remainingSlots, rebuilt, excludedTrackIds, PLAN_SIZE);
|
return mergeUniquePlan(remainingSlots, rebuilt, excludedTrackIds, PLAN_SIZE);
|
||||||
}
|
}
|
||||||
@@ -1092,7 +1077,6 @@ 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)]),
|
||||||
artistPenalties: options.artistPenalties,
|
|
||||||
});
|
});
|
||||||
return mergeUniquePlan(remainingSlots, rebuilt, excludedTrackIds, PLAN_SIZE);
|
return mergeUniquePlan(remainingSlots, rebuilt, excludedTrackIds, PLAN_SIZE);
|
||||||
}
|
}
|
||||||
@@ -1106,7 +1090,6 @@ 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)]),
|
||||||
artistPenalties: options.artistPenalties,
|
|
||||||
});
|
});
|
||||||
return mergeUniquePlan(remainingSlots, rebuilt, excludedTrackIds, PLAN_SIZE);
|
return mergeUniquePlan(remainingSlots, rebuilt, excludedTrackIds, PLAN_SIZE);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,27 +168,6 @@ describe('SessionDirector', () => {
|
|||||||
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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user