feat(benchmark): enhance benchmark documentation and add emulated run details

fix(benchmark): update deterministic checks and improve measurement accuracy

fix(service): refine candidate recording and enhance balance assignment logic

test(service): add test for balance recording with preference assignment
This commit is contained in:
nessar committed 2026-09-25 14:07:00 +02:00
1 parent ff86cf48b4
commit f1f08356df
5 files changed
+300 -98

No files matched your search

@@ -1,4 +1,5 @@
import { vi } from 'vitest';
import { createPlanningCalculation } from '../services/leave-planner.service';
import {
benchmarkReport,
benchmarkScenarios,
@@ -9,6 +10,20 @@ describe('leave planner benchmark protocol', () => {
it('keeps five deterministic scenarios and exercises a later earned consumer', async () => {
const scenarios = benchmarkScenarios();
expect(scenarios).toHaveLength(5);
const typedObligations = scenarios.find(
(scenario) => scenario.name === 'typed obligations',
);
const cpRule = typedObligations?.request.rules?.find((rule) => rule.id === 'cp-window');
const rttRule = typedObligations?.request.rules?.find((rule) => rule.id === 'rtt-window');
expect(cpRule).toMatchObject({ placeType: 'cp', placeDays: 2 });
expect(rttRule).toMatchObject({ placeType: 'rtt', placeDays: 2 });
expect(cpRule?.type).toBe('time_window');
expect(rttRule?.type).toBe('time_window');
if (cpRule && 'startDate' in cpRule && rttRule && 'startDate' in rttRule)
expect(
String(cpRule.startDate) <= String(rttRule.endDate) &&
String(rttRule.startDate) <= String(cpRule.endDate),
).toBe(true);
const chain = scenarios.find((scenario) => scenario.name === 'chained earned entitlements');
expect(chain?.request.rules?.map((rule) => rule.id)).toEqual([
'wednesday-holidays',
@@ -18,24 +33,44 @@ describe('leave planner benchmark protocol', () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
const measurements = await runLeavePlannerBenchmark(1);
const typedOutcome = await createPlanningCalculation(typedObligations!.request).outcome;
expect(typedOutcome.kind).toBe('Plan');
if (typedOutcome.kind === 'Plan') {
expect(
typedOutcome.placements.filter((placement) =>
placement.ruleIds.includes('cp-window'),
),
).toHaveLength(2);
expect(
typedOutcome.placements.filter((placement) =>
placement.ruleIds.includes('rtt-window'),
),
).toHaveLength(2);
}
const measurements = await runLeavePlannerBenchmark(2);
const chained = measurements.find(
(measurement) => measurement.scenario === 'chained earned entitlements',
);
const loaded = measurements.find(
const loaded = measurements.filter(
(measurement) => measurement.scenario === 'loaded year',
);
expect(chained?.outcome).toBe('Plan');
expect(chained?.placements).toBeGreaterThanOrEqual(3);
expect(loaded?.outcome).toBe('Plan');
expect(loaded?.deterministic).toBe(true);
expect(loaded?.planningMs).toBeGreaterThanOrEqual(0);
expect(loaded?.traceDeliveryMs).toBeGreaterThanOrEqual(0);
expect(loaded?.traceStatus).toBe('ready');
expect(loaded?.traceCandidates).toBeGreaterThan(0);
expect(loaded).toHaveLength(2);
expect(loaded[0].outcome).toBe('Plan');
expect(loaded[0].deterministic).toBe('baseline');
expect(loaded[1].deterministic).toBe('yes');
expect(loaded[0].planningMs).toBeGreaterThanOrEqual(0);
expect(loaded[0].traceDeliveryMs).toBeGreaterThanOrEqual(0);
expect(loaded[0].wallMs).toBeGreaterThanOrEqual(
loaded[0].preparationMs + loaded[0].planningMs + loaded[0].traceDeliveryMs,
);
expect(loaded[0].traceStatus).toBe('ready');
expect(loaded[0].traceCandidates).toBeGreaterThan(0);
const report = benchmarkReport(measurements);
expect(report.split('\n')).toHaveLength(6);
expect(report.split('\n')).toHaveLength(11);
expect(report).toContain('planning_ms\ttrace_delivery_ms');
expect(report).toContain('\tbaseline\t');
} finally {
vi.stubGlobal('window', browserWindow);
}
+31 -20
View File
@@ -20,7 +20,7 @@ export interface BenchmarkMeasurement {
outcome: PlanningOutcome['kind'];
lastCompletedStage?: string;
placements: number;
deterministic: boolean;
deterministic: 'baseline' | 'yes' | 'no';
traceStatus: PlanningTrace['status'] | 'not-produced';
traceCandidates: number;
}
@@ -59,7 +59,7 @@ export function benchmarkScenarios(): BenchmarkScenario[] {
id: 'cp-window',
type: 'time_window',
force: 'obligation',
startDate: '2024-04-01',
startDate: '2024-04-08',
endDate: '2024-04-12',
placeDays: 2,
placeType: 'cp',
@@ -68,8 +68,8 @@ export function benchmarkScenarios(): BenchmarkScenario[] {
id: 'rtt-window',
type: 'time_window',
force: 'obligation',
startDate: '2024-05-01',
endDate: '2024-05-12',
startDate: '2024-04-08',
endDate: '2024-04-12',
placeDays: 2,
placeType: 'rtt',
},
@@ -166,10 +166,18 @@ function resultSummary(outcome: PlanningOutcome): {
}
export async function runLeavePlannerBenchmark(repetitions = 1): Promise<BenchmarkMeasurement[]> {
const scenarios = benchmarkScenarios();
for (const scenario of scenarios) {
const calculation = createPlanningCalculation(structuredClone(scenario.request));
await calculation.outcome;
await calculation.trace;
}
const measurements: BenchmarkMeasurement[] = [];
for (const scenario of benchmarkScenarios()) {
let previous: string | undefined;
for (const scenario of scenarios) {
let baseline: string | undefined;
for (let repetition = 1; repetition <= repetitions; repetition++) {
const wallStart = performance.now();
const preparationStart = performance.now();
const request = structuredClone(scenario.request);
const preparationMs = performance.now() - preparationStart;
@@ -182,29 +190,32 @@ export async function runLeavePlannerBenchmark(repetitions = 1): Promise<Benchma
const traceDeliveryMs = performance.now() - traceStart;
const summary = resultSummary(outcome);
const stable = JSON.stringify(outcome);
const deterministic =
baseline === undefined ? 'baseline' : stable === baseline ? 'yes' : 'no';
const traceCandidates =
trace?.phases.reduce(
(total, phase) =>
total +
phase.decisions.reduce(
(decisionTotal, decision) => decisionTotal + decision.candidates.length,
0,
),
0,
) ?? 0;
const wallMs = performance.now() - wallStart;
measurements.push({
scenario: scenario.name,
repetition,
preparationMs,
planningMs,
traceDeliveryMs,
wallMs: preparationMs + planningMs + traceDeliveryMs,
wallMs,
...summary,
deterministic: previous === undefined || previous === stable,
deterministic,
traceStatus: trace?.status ?? 'not-produced',
traceCandidates:
trace?.phases.reduce(
(total, phase) =>
total +
phase.decisions.reduce(
(decisionTotal, decision) =>
decisionTotal + decision.candidates.length,
0,
),
0,
) ?? 0,
traceCandidates,
});
previous = stable;
baseline ??= stable;
}
}
return measurements;
+130 -64
View File
@@ -582,15 +582,19 @@ class TraceRecorder {
return undefined;
}
recordCandidate(candidate: PlanningTraceCandidate): void {
this.latestDecision().candidates.push({
...candidate,
dates: candidate.dates ? [...candidate.dates] : undefined,
reason: candidate.reason ? { ...candidate.reason } : undefined,
placement: candidate.placement
? { ...candidate.placement, ruleIds: [...candidate.placement.ruleIds] }
: undefined,
});
recordCandidate(candidate: PlanningTraceCandidate, copy = true): void {
this.latestDecision().candidates.push(
copy
? {
...candidate,
dates: candidate.dates ? [...candidate.dates] : undefined,
reason: candidate.reason ? { ...candidate.reason } : undefined,
placement: candidate.placement
? { ...candidate.placement, ruleIds: [...candidate.placement.ruleIds] }
: undefined,
}
: candidate,
);
}
recordDecision(
@@ -891,7 +895,8 @@ function observeTrace(context: SearchContext, observe: (trace: TraceRecorder) =>
}
function recordTraceCandidate(context: SearchContext, candidate: PlanningTraceCandidate): void {
observeTrace(context, (trace) => trace.recordCandidate(candidate));
// These internal callsites pass fresh literals; retain the full snapshot copy for plan data.
observeTrace(context, (trace) => trace.recordCandidate(candidate, false));
}
function recordTraceIneligible(
@@ -1633,7 +1638,7 @@ function assignDate(
ruleId: string,
context: SearchContext,
): State | null {
return assignDateVariants(state, date, selector, ruleId, context)[0] ?? null;
return assignDateVariants(state, date, selector, ruleId, context, true)[0] ?? null;
}
function assignDateVariants(
@@ -1642,6 +1647,7 @@ function assignDateVariants(
selector: LeaveSelector,
ruleId: string,
context: SearchContext,
firstOnly = false,
): State[] {
context.check();
const existing = state.placements.get(date);
@@ -1724,6 +1730,40 @@ function assignDateVariants(
'No compatible leave balance can fund this candidate date.',
),
});
if (firstOnly && balances.length) {
const first = balances[0];
const result = cloneState(state);
result.balances.find((item) => item.id === first.id)!.remaining--;
result.placements.set(date, {
date,
balanceId: first.id,
type: first.type,
ruleIds: ruleId ? new Set([ruleId]) : new Set(),
supporting: false,
});
for (const [index, balance] of balances.entries()) {
recordTraceCandidate(context, {
status: index === 0 ? 'evaluated-rejected' : 'not-examined',
date,
...(index === 0
? {}
: {
reason: reason(
'first-compatible-balance',
'This balance was not examined after the first compatible balance was selected.',
),
}),
placement: {
date,
balanceId: balance.id,
type: balance.type,
ruleIds: ruleId ? [ruleId] : [],
supporting: false,
},
});
}
return [result];
}
return balances.map((balance) => {
const result = cloneState(state);
const target = result.balances.find((item) => item.id === balance.id)!;
@@ -1806,23 +1846,40 @@ function assignDateToBalanceInPlace(
date: string,
balanceId: string,
context: SearchContext,
ruleIds = new Set<string>(),
): boolean {
context.check();
if (state.placements.has(date)) return false;
if (state.placements.has(date)) {
recordTraceCandidate(context, {
status: 'ineligible',
date,
reason: reason('occupied', 'Another placement already uses this date.'),
});
return false;
}
const balance = state.balances.find((item) => item.id === balanceId);
if (
!balance ||
balance.remaining <= 0 ||
compareDate(balance.availableFrom, date) > 0 ||
compareDate(date, balance.expiryDate) > 0
)
) {
recordTraceCandidate(context, {
status: 'evaluated-rejected',
date,
reason: reason(
'balance-unavailable',
'The selected leave balance cannot fund this date.',
),
});
return false;
}
balance.remaining--;
state.placements.set(date, {
date,
balanceId,
type: balance.type,
ruleIds: new Set(),
ruleIds: new Set(ruleIds),
supporting: false,
});
recordTraceCandidate(context, {
@@ -1830,7 +1887,7 @@ function assignDateToBalanceInPlace(
date,
placement: {
...state.placements.get(date)!,
ruleIds: [],
ruleIds: [...state.placements.get(date)!.ruleIds],
},
});
return true;
@@ -2637,60 +2694,69 @@ function optimizeMovablePlacements(
);
continue;
}
const candidate = assignDateToBalance(
without,
date,
current.balanceId,
context,
current.ruleIds,
);
if (!candidate) continue;
const feasibleCandidate = finalizeEarnedState(candidate, context);
if (!feasibleCandidate) continue;
feasibleCandidate.placements.get(date)!.supporting = current.supporting;
const metrics = qualityMetrics(feasibleCandidate, context, preferences);
recordTraceEvaluation(
context,
result,
feasibleCandidate,
preferences,
baseline,
metrics,
[date],
current.date,
);
if (metrics.unplacedAtRisk !== baseline.unplacedAtRisk) continue;
if (metrics.holidayWeekValue < baseline.holidayWeekValue) continue;
if (
metrics.preferenceSatisfaction.length !== baseline.preferenceSatisfaction.length ||
metrics.preferenceSatisfaction.some(
(value, index) => value !== baseline.preferenceSatisfaction[index],
!assignDateToBalanceInPlace(
without,
date,
current.balanceId,
context,
current.ruleIds,
)
)
continue;
const better =
metrics.holidayWeekValue > bestMetrics.holidayWeekValue ||
(metrics.holidayWeekValue === bestMetrics.holidayWeekValue &&
(stage === 'non-working-day-gain'
? metrics.nonWorkingDayGain > bestMetrics.nonWorkingDayGain
: stage === 'longest-work-sequence'
? metrics.nonWorkingDayGain === bestMetrics.nonWorkingDayGain &&
metrics.longestWorkSequence < bestMetrics.longestWorkSequence
: stage === 'short-sequence-deficit'
try {
const feasibleCandidate = finalizeEarnedState(without, context);
if (!feasibleCandidate) continue;
feasibleCandidate.placements.get(date)!.supporting = current.supporting;
const metrics = qualityMetrics(feasibleCandidate, context, preferences);
recordTraceEvaluation(
context,
result,
feasibleCandidate,
preferences,
baseline,
metrics,
[date],
current.date,
);
if (metrics.unplacedAtRisk !== baseline.unplacedAtRisk) continue;
if (metrics.holidayWeekValue < baseline.holidayWeekValue) continue;
if (
metrics.preferenceSatisfaction.length !==
baseline.preferenceSatisfaction.length ||
metrics.preferenceSatisfaction.some(
(value, index) => value !== baseline.preferenceSatisfaction[index],
)
)
continue;
const better =
metrics.holidayWeekValue > bestMetrics.holidayWeekValue ||
(metrics.holidayWeekValue === bestMetrics.holidayWeekValue &&
(stage === 'non-working-day-gain'
? metrics.nonWorkingDayGain > bestMetrics.nonWorkingDayGain
: stage === 'longest-work-sequence'
? metrics.nonWorkingDayGain === bestMetrics.nonWorkingDayGain &&
metrics.longestWorkSequence ===
bestMetrics.longestWorkSequence &&
metrics.shortSequenceDeficit < bestMetrics.shortSequenceDeficit
: metrics.nonWorkingDayGain === bestMetrics.nonWorkingDayGain &&
metrics.longestWorkSequence ===
bestMetrics.longestWorkSequence &&
metrics.shortSequenceDeficit ===
bestMetrics.shortSequenceDeficit &&
date < bestDate));
if (better) {
best = feasibleCandidate;
bestMetrics = metrics;
bestDate = date;
metrics.longestWorkSequence < bestMetrics.longestWorkSequence
: stage === 'short-sequence-deficit'
? metrics.nonWorkingDayGain === bestMetrics.nonWorkingDayGain &&
metrics.longestWorkSequence ===
bestMetrics.longestWorkSequence &&
metrics.shortSequenceDeficit <
bestMetrics.shortSequenceDeficit
: metrics.nonWorkingDayGain === bestMetrics.nonWorkingDayGain &&
metrics.longestWorkSequence ===
bestMetrics.longestWorkSequence &&
metrics.shortSequenceDeficit ===
bestMetrics.shortSequenceDeficit &&
date < bestDate));
if (better) {
best = cloneState(feasibleCandidate);
bestMetrics = metrics;
bestDate = date;
}
} finally {
without.placements.delete(date);
without.balances.find((item) => item.id === current.balanceId)!.remaining++;
}
}
if (best !== result) recordTraceState(context, best);
@@ -1660,6 +1660,53 @@ describe('planning calculation context', () => {
}
});
it('records every eligible balance when a preference keeps the first assignment', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-01-10' },
balances: [
balance('later', 'cp', 1, '2026-01-01'),
balance('earlier', 'cp', 1, '2025-01-01'),
],
rules: [
{
id: 'window',
type: 'time_window',
force: 'preference',
startDate: '2024-01-02',
endDate: '2024-01-02',
placeDays: 1,
placeType: 'cp',
},
],
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan') return;
expect(outcome.placements[0].balanceId).toBe('earlier');
const trace = await calculation.trace;
const candidates =
trace?.phases
.find((phase) => phase.name === 'preference:window')
?.decisions.flatMap((decision) => decision.candidates)
.filter((candidate) => candidate.date === '2024-01-02' && candidate.placement)
.map((candidate) => ({
balanceId: candidate.placement!.balanceId,
status: candidate.status,
reason: candidate.reason?.code,
})) ?? [];
expect(candidates.slice(0, 2)).toEqual([
{ balanceId: 'earlier', status: 'evaluated-rejected', reason: undefined },
{ balanceId: 'later', status: 'not-examined', reason: 'first-compatible-balance' },
]);
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('offers no explanation trace for invalid input and explains conflicts', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);