fix: add more randomness to filler picker (#1746)

Rework the filler picker to add more randomness and better weighting
behavior. Specifically:

1. Shuffle incoming filler lists
2. Filler weights only added to sampling if they have a program that can
   fit in the requested time slot
3. Once a list is chosen, we shuffle the programs within that list
4. The rest of the algorithm remains the same with weighted sampling of
   programs with slight adjustments based on last-seen and duration.
This commit is contained in:
Christian Benincasa
2026-03-27 12:08:26 -04:00
committed by GitHub
parent 18dd66cb6e
commit 3af479008b
2 changed files with 212 additions and 123 deletions

View File

@@ -265,64 +265,102 @@ describe('FillerPickerV2', () => {
expect(result.filler).toBeNull();
});
it('accumulates weight across all fillers regardless of cooldown', async () => {
const now = Date.now();
// First filler in cooldown
const filler1 = createFiller({ weight: 50, cooldown: 60 }); // 1 minute in seconds
// Second filler not in cooldown
it('accumulates weight across eligible fillers for reservoir sampling', async () => {
// Both fillers are eligible (not on cooldown). The reservoir sampling
// loop should accumulate weights across all eligible lists.
const filler1 = createFiller({ weight: 50, cooldown: 0 });
const filler2 = createFiller({ weight: 50, cooldown: 0 });
// Return history that puts filler1 in cooldown
vi.mocked(mockPlayHistoryDB.getFillerHistory).mockResolvedValue([
createPlayHistory(v4(), new Date(now - 30000), filler1.fillerShowUuid), // In cooldown
]);
vi.mocked(mockPlayHistoryDB.getFillerHistory).mockResolvedValue([]);
// Track the weight values passed to random.bool
const boolCalls: Array<{ weight: number; totalWeight: number }> = [];
let programCalls = 0,
fillerCalls = 0;
vi.spyOn(picker, 'weightedPick').mockImplementation(
(reason, num, den) => {
if (reason === 'filler') {
fillerCalls++;
boolCalls.push({
weight: num,
totalWeight: den,
});
return true;
} else if (reason === 'program') {
programCalls++;
return programCalls > 1;
return true;
}
return false;
},
);
await picker.pickFiller(mockChannel, [filler1, filler2], 60000, now);
await picker.pickFiller(mockChannel, [filler1, filler2], 60000);
// The second filler should see accumulated weight from first filler
// filler1 adds 50 to listWeight, then filler2 adds 50 more = 100 total
// Both fillers are eligible, so both should get weighted picks.
// filler1: weight=50, totalWeight=50
// filler2: weight=50, totalWeight=100
expect(boolCalls).toHaveLength(2);
const listSelectionCall = boolCalls.find((c) => c.totalWeight === 100);
expect(listSelectionCall).toBeDefined();
});
it('breaks out of loop after selecting a filler list', async () => {
it('skips list with all programs on cooldown and picks from another list', async () => {
const now = Date.now();
// List A: single program on repeat cooldown
const programA = createProgram({ duration: 10000 });
const fillerA = createFiller({ weight: 100 });
fillerA.fillerContent = [programA];
// List B: single program NOT on cooldown (never played)
const programB = createProgram({ duration: 10000 });
const fillerB = createFiller({ weight: 100 });
fillerB.fillerContent = [programB];
// programA was played 10 minutes ago (within 30-min default cooldown)
vi.mocked(mockPlayHistoryDB.getFillerHistory).mockResolvedValue([
createPlayHistory(
programA.uuid,
new Date(now - 10 * 60 * 1000),
fillerA.fillerShowUuid,
),
]);
vi.mocked(random.bool).mockReturnValue(true);
const result = await picker.pickFiller(
mockChannel,
[fillerA, fillerB],
60000,
now,
);
// List A should be skipped (all programs on cooldown).
// List B should be picked instead — not null.
expect(result.filler).not.toBeNull();
expect(result.fillerListId).toBe(fillerB.fillerShowUuid);
expect(result.filler?.uuid).toBe(programB.uuid);
});
it('considers all eligible filler lists via reservoir sampling', async () => {
const filler1 = createFiller({ weight: 100 });
const filler2 = createFiller({ weight: 100 });
let boolCallCount = 0;
vi.mocked(random.bool).mockImplementation(() => {
boolCallCount++;
return true; // Always select
let fillerCalls = 0;
let programCalls = 0;
vi.spyOn(picker, 'weightedPick').mockImplementation((reason) => {
if (reason === 'filler') {
fillerCalls++;
return true;
} else if (reason === 'program') {
programCalls++;
return true;
}
return false;
});
await picker.pickFiller(mockChannel, [filler1, filler2], 60000);
// Should break after first filler is selected
// One call for list selection, one for program selection
expect(boolCallCount).toBeLessThanOrEqual(2);
// Both filler lists should be considered (reservoir sampling),
// then one program pick from the selected list.
expect(fillerCalls).toBe(2);
expect(programCalls).toBe(1);
});
});
@@ -505,13 +543,13 @@ describe('FillerPickerV2', () => {
const filler = createFiller();
filler.fillerContent = [program1, program2];
// With current implementation, list selection and program selection happen together.
// First random.bool selects the list and the first program in one pass.
// random.bool returning true selects the list (phase 1), then the
// first eligible program from the shuffled list (phase 2).
vi.mocked(random.bool).mockReturnValue(true);
const result = await picker.pickFiller(mockChannel, [filler], 60000);
// Should pick the first program when random.bool returns true
// Should pick the first program (shuffle is mocked as identity)
expect(result.filler).not.toBeNull();
expect(result.filler?.uuid).toBe(program1.uuid);
});
@@ -962,8 +1000,8 @@ describe('FillerPickerV2', () => {
const weightCalls: Array<{ weight: number; total: number }> = [];
// In current implementation, list selection and program selection happen
// in the same random.bool call sequence. First call picks list and first program.
// List selection (phase 1) and program selection (phase 2) are separate
// passes. Both use weightedPick which delegates to random.bool.
vi.mocked(random.bool).mockImplementation((weight, totalWeight) => {
weightCalls.push({
weight: weight as number,

View File

@@ -54,8 +54,6 @@ export class FillerPickerV2 implements IFillerPicker {
(history) => history.fillerListId,
);
let listWeight = 0;
let pickedFiller: Maybe<ChannelFillerShowWithContent>;
let minimumWait = Number.MAX_SAFE_INTEGER;
if (this.logger.isLevelEnabled('debug')) {
@@ -68,104 +66,157 @@ export class FillerPickerV2 implements IFillerPicker {
);
}
for (const filler of fillers) {
const { weight, cooldown, fillerShow, fillerContent } = filler;
// Phase 1: Select a filler list using weighted reservoir sampling.
// Shuffle first so that lists with equal weights don't always resolve
// in the same DB-insertion order.
const shuffledFillers = random.shuffle([...fillers]);
let listWeight = 0;
let pickedFiller: Maybe<ChannelFillerShowWithContent>;
for (const filler of shuffledFillers) {
const { weight, cooldown, fillerShow } = filler;
const fillerHistory = fillerPlayHistoryById[fillerShow.uuid];
filler.fillerContent = random.shuffle(filler.fillerContent);
const lastPlay = maxBy(fillerHistory, (history) =>
dayjs(history.playedAt).valueOf(),
);
const timeSincePlayedFiller = lastPlay
? now - dayjs(lastPlay.playedAt).valueOf()
: OneDayMillis;
const fillerCooldownMs = cooldown * 1000;
let programTotalWeight = 0;
for (const program of fillerContent) {
if (program.duration > maxDuration) {
this.logger.trace(
'Skipping program %s (%s) from filler list %s because it is too long (%d > %d)',
program.uuid,
program.title,
fillerShow.uuid,
program.duration,
maxDuration,
if (timeSincePlayedFiller >= fillerCooldownMs) {
// Check whether this list has at least one program that fits
// maxDuration and is past its repeat cooldown. Only lists with
// eligible programs participate in reservoir sampling — otherwise
// we'd risk picking a list and then failing to find a program.
let hasEligibleProgram = false;
for (const program of filler.fillerContent) {
if (program.duration > maxDuration) continue;
const programLastPlayed = fillerHistory?.find(
(h) => h.programUuid === program.uuid,
);
continue;
}
const programLastPlayed = fillerHistory?.find(
(h) => h.programUuid === program.uuid,
);
const timeSincePlayed = programLastPlayed
? now - dayjs(programLastPlayed.playedAt).valueOf()
: OneDayMillis;
// Channel level cooldown in effect for this program
if (timeSincePlayed < fillerRepeatCooldownMs) {
this.logger.trace(
'Skipping program %s (%s) from filler list %s because cooldown is in effect (%d < %d)',
program.uuid,
program.title,
fillerShow.uuid,
timeSincePlayed,
fillerRepeatCooldownMs,
);
const timeUntilProgramCanPlay =
fillerRepeatCooldownMs - timeSincePlayed;
if (program.duration + timeUntilProgramCanPlay <= maxDuration) {
minimumWait = Math.min(minimumWait, timeUntilProgramCanPlay);
this.logger.trace('New minimumWait: %d', minimumWait);
}
} else if (!pickedFiller) {
// Need to see if we can even use this list.
const fillerHistory = fillerPlayHistoryById[fillerShow.uuid];
const lastPlay = maxBy(fillerHistory, (history) =>
dayjs(history.playedAt).valueOf(),
);
const timeSincePlayedFiller = lastPlay
? now - dayjs(lastPlay.playedAt).valueOf()
const timeSincePlayed = programLastPlayed
? now - dayjs(programLastPlayed.playedAt).valueOf()
: OneDayMillis;
// Weights always count, despite cooldowns.
listWeight += weight;
const fillerCooldownMs = cooldown * 1000;
if (timeSincePlayedFiller >= fillerCooldownMs) {
if (this.weightedPick('filler', weight, listWeight)) {
pickedFiller = filler;
} else {
// Didn't pick this filler list based on weight
break;
}
if (timeSincePlayed >= fillerRepeatCooldownMs) {
hasEligibleProgram = true;
} else {
this.logger.trace(
'Cannot pick filler list %s (%s) because cooldown is in effect (%d < %d), last played at %s',
filler.fillerShowUuid,
filler.fillerShow.name,
timeSincePlayedFiller,
fillerCooldownMs,
lastPlay?.playedAt ? dayjs(lastPlay.playedAt).format() : 'never',
);
const timeUntilListIsCandidate =
fillerCooldownMs - timeSincePlayedFiller;
if (program.duration + timeUntilListIsCandidate <= maxDuration) {
minimumWait = Math.min(
minimumWait,
program.duration + timeUntilListIsCandidate,
);
const timeUntilProgramCanPlay =
fillerRepeatCooldownMs - timeSincePlayed;
if (program.duration + timeUntilProgramCanPlay <= maxDuration) {
minimumWait = Math.min(minimumWait, timeUntilProgramCanPlay);
this.logger.trace('New minimumWait: %d', minimumWait);
}
// Cannot use this list because cooldown is in effect
break;
}
const normalizedSince = normalizeSince(
timeSincePlayed >= FiveMinutesMillis
? FiveMinutesMillis
: timeSincePlayed,
);
const normalizedDuration = normalizeDuration(program.duration);
const programWeight = normalizedSince + normalizedDuration;
programTotalWeight += programWeight;
if (this.weightedPick('program', programWeight, programTotalWeight)) {
return {
filler: program,
fillerListId: pickedFiller.fillerShowUuid,
minimumWait: 0,
};
}
}
if (hasEligibleProgram) {
listWeight += weight;
if (this.weightedPick('filler', weight, listWeight)) {
pickedFiller = filler;
}
} else {
this.logger.trace(
'Skipping filler list %s (%s) — no eligible programs (all on cooldown or too long)',
filler.fillerShowUuid,
filler.fillerShow.name,
);
}
// Continue iterating — reservoir sampling requires seeing all candidates.
} else {
this.logger.trace(
'Cannot pick filler list %s (%s) because cooldown is in effect (%d < %d), last played at %s',
filler.fillerShowUuid,
filler.fillerShow.name,
timeSincePlayedFiller,
fillerCooldownMs,
lastPlay?.playedAt ? dayjs(lastPlay.playedAt).format() : 'never',
);
// Track minimumWait for the list cooldown.
const timeUntilListIsCandidate =
fillerCooldownMs - timeSincePlayedFiller;
const shortestProgram = filler.fillerContent.reduce(
(min, p) => Math.min(min, p.duration),
Number.MAX_SAFE_INTEGER,
);
if (shortestProgram + timeUntilListIsCandidate <= maxDuration) {
minimumWait = Math.min(
minimumWait,
shortestProgram + timeUntilListIsCandidate,
);
this.logger.trace('New minimumWait: %d', minimumWait);
}
}
}
if (!pickedFiller) {
return {
filler: null,
fillerListId: null,
minimumWait: minimumWait < 0 ? 15_000 : minimumWait,
};
}
// Phase 2: Select a program from the picked list using weighted
// reservoir sampling. Shuffle so that programs with equal weights
// don't always resolve in the same order.
const fillerHistory = fillerPlayHistoryById[pickedFiller.fillerShow.uuid];
const shuffledPrograms = random.shuffle([...pickedFiller.fillerContent]);
let programTotalWeight = 0;
for (const program of shuffledPrograms) {
if (program.duration > maxDuration) {
this.logger.trace(
'Skipping program %s (%s) from filler list %s because it is too long (%d > %d)',
program.uuid,
program.title,
pickedFiller.fillerShow.uuid,
program.duration,
maxDuration,
);
continue;
}
const programLastPlayed = fillerHistory?.find(
(h) => h.programUuid === program.uuid,
);
const timeSincePlayed = programLastPlayed
? now - dayjs(programLastPlayed.playedAt).valueOf()
: OneDayMillis;
// Channel level cooldown in effect for this program
if (timeSincePlayed < fillerRepeatCooldownMs) {
this.logger.trace(
'Skipping program %s (%s) from filler list %s because cooldown is in effect (%d < %d)',
program.uuid,
program.title,
pickedFiller.fillerShow.uuid,
timeSincePlayed,
fillerRepeatCooldownMs,
);
const timeUntilProgramCanPlay =
fillerRepeatCooldownMs - timeSincePlayed;
if (program.duration + timeUntilProgramCanPlay <= maxDuration) {
minimumWait = Math.min(minimumWait, timeUntilProgramCanPlay);
this.logger.trace('New minimumWait: %d', minimumWait);
}
continue;
}
const normalizedSince = normalizeSince(
timeSincePlayed >= FiveMinutesMillis
? FiveMinutesMillis
: timeSincePlayed,
);
const normalizedDuration = normalizeDuration(program.duration);
const programWeight = normalizedSince + normalizedDuration;
programTotalWeight += programWeight;
if (this.weightedPick('program', programWeight, programTotalWeight)) {
return {
filler: program,
fillerListId: pickedFiller.fillerShowUuid,
minimumWait: 0,
};
}
}