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
+252 -50

No files matched your search

+48 -5
View File
@@ -16,11 +16,14 @@ stage and incumbent are retained.
battery level. battery level.
- Use a cool phone, unplugged, with other applications closed. Record whether - Use a cool phone, unplugged, with other applications closed. Record whether
the device became warm or throttled during the run. the device became warm or throttled during the run.
- Run the benchmark three times after one warm-up run. Do not interact with - Tap **Run benchmark** once. It first runs every scenario as an unreported
the page while a run is active. warm-up, then records three measured repetitions per scenario. Do not interact
- Representative and loaded scenarios should normally complete below five with the page while a run is active. The first measured result is the
seconds; every scenario must return within ten seconds. These are release determinism baseline; later repetitions report whether they match it.
measurements, not CI timing assertions. - The original target was below five seconds for representative and loaded
scenarios, with a ten-second planning ceiling for every scenario. The owner
accepted six to seven seconds for the loaded scenario on 2026-09-25. These
are release measurements, not CI timing assertions.
- Keep the copied report unchanged, including any incomplete outcomes or - Keep the copied report unchanged, including any incomplete outcomes or
hardware limitations. No mobile-device measurement is claimed by the source hardware limitations. No mobile-device measurement is claimed by the source
tree until a human records it here. tree until a human records it here.
@@ -29,3 +32,43 @@ The browser page uses the same `createPlanningCalculation(request)` context as
the calendar and adds no solver or backend dependency. The ten-second release the calendar and adds no solver or backend dependency. The ten-second release
ceiling applies to planning latency; trace delivery is reported separately and ceiling applies to planning latency; trace delivery is reported separately and
cannot replace a valid planning outcome. cannot replace a valid planning outcome.
## Hardware status
No reference-phone run has been recorded. The project owner elected to close
the release tickets on 2026-09-25 using the emulated run below, accepting its
six-to-seven-second loaded planning time and the observed post-result trace UI
pause. This is an explicit release exception, not a reference-phone measurement
or proof of physical-device responsiveness.
## Emulated browser run — 2026-09-25
Chromium 149.0.7827.55 on an AMD Ryzen 7 7800X3D (x86_64), headless at
412×915 pixels, device scale 2.625, touch enabled, and 4× CPU throttling.
One full warm-up was excluded, followed by three measured repetitions. All
five scenarios returned within ten seconds of planning time and complete
results matched across repetitions. The loaded year took 5.74–6.17 seconds,
above the five-second target. A 100 ms main-thread timer observed a maximum
extra delay of 5,950.9 ms. Worker event timestamps place the four loaded-year
pauses of 4.0–6.0 seconds after their planning results, around delivery of
full trace artifacts. This desktop emulation does not establish reference-phone
performance or responsiveness.
```tsv
scenario repetition preparation_ms planning_ms trace_delivery_ms wall_ms outcome last_stage placements deterministic trace_status trace_candidates
representative year 1 0.0 443.7 624.7 1069.1 Plan dates 30 baseline ready 29451
representative year 2 0.1 490.0 609.3 1099.4 Plan dates 30 yes ready 29451
representative year 3 0.1 465.5 614.2 1079.8 Plan dates 30 yes ready 29451
typed obligations 1 0.0 157.0 261.2 418.2 Plan dates 10 baseline ready 13103
typed obligations 2 0.0 162.6 255.6 418.2 Plan dates 10 yes ready 13103
typed obligations 3 0.0 163.4 258.2 421.6 Plan dates 10 yes ready 13103
chained earned entitlements 1 0.0 45.3 1.0 47.0 Plan dates 3 baseline ready 27
chained earned entitlements 2 0.1 44.5 0.9 45.5 Plan dates 3 yes ready 27
chained earned entitlements 3 0.1 44.4 0.8 45.4 Plan dates 3 yes ready 27
deliberate conflict 1 0.0 92.8 0.9 93.8 PlanningConflict 0 baseline ready 366
deliberate conflict 2 0.0 44.7 0.2 44.9 PlanningConflict 0 yes ready 366
deliberate conflict 3 0.0 43.5 0.6 44.1 PlanningConflict 0 yes ready 366
loaded year 1 0.3 5901.5 5677.7 11581.0 Plan dates 144 baseline ready 865117
loaded year 2 0.8 6167.5 7582.4 13752.4 Plan dates 144 yes ready 865117
loaded year 3 0.6 5738.3 6219.5 11962.5 Plan dates 144 yes ready 865117
```
@@ -1,4 +1,5 @@
import { vi } from 'vitest'; import { vi } from 'vitest';
import { createPlanningCalculation } from '../services/leave-planner.service';
import { import {
benchmarkReport, benchmarkReport,
benchmarkScenarios, benchmarkScenarios,
@@ -9,6 +10,20 @@ describe('leave planner benchmark protocol', () => {
it('keeps five deterministic scenarios and exercises a later earned consumer', async () => { it('keeps five deterministic scenarios and exercises a later earned consumer', async () => {
const scenarios = benchmarkScenarios(); const scenarios = benchmarkScenarios();
expect(scenarios).toHaveLength(5); 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'); const chain = scenarios.find((scenario) => scenario.name === 'chained earned entitlements');
expect(chain?.request.rules?.map((rule) => rule.id)).toEqual([ expect(chain?.request.rules?.map((rule) => rule.id)).toEqual([
'wednesday-holidays', 'wednesday-holidays',
@@ -18,24 +33,44 @@ describe('leave planner benchmark protocol', () => {
const browserWindow = globalThis.window; const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined); vi.stubGlobal('window', undefined);
try { 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( const chained = measurements.find(
(measurement) => measurement.scenario === 'chained earned entitlements', (measurement) => measurement.scenario === 'chained earned entitlements',
); );
const loaded = measurements.find( const loaded = measurements.filter(
(measurement) => measurement.scenario === 'loaded year', (measurement) => measurement.scenario === 'loaded year',
); );
expect(chained?.outcome).toBe('Plan'); expect(chained?.outcome).toBe('Plan');
expect(chained?.placements).toBeGreaterThanOrEqual(3); expect(chained?.placements).toBeGreaterThanOrEqual(3);
expect(loaded?.outcome).toBe('Plan'); expect(loaded).toHaveLength(2);
expect(loaded?.deterministic).toBe(true); expect(loaded[0].outcome).toBe('Plan');
expect(loaded?.planningMs).toBeGreaterThanOrEqual(0); expect(loaded[0].deterministic).toBe('baseline');
expect(loaded?.traceDeliveryMs).toBeGreaterThanOrEqual(0); expect(loaded[1].deterministic).toBe('yes');
expect(loaded?.traceStatus).toBe('ready'); expect(loaded[0].planningMs).toBeGreaterThanOrEqual(0);
expect(loaded?.traceCandidates).toBeGreaterThan(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); 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('planning_ms\ttrace_delivery_ms');
expect(report).toContain('\tbaseline\t');
} finally { } finally {
vi.stubGlobal('window', browserWindow); vi.stubGlobal('window', browserWindow);
} }
+31 -20
View File
@@ -20,7 +20,7 @@ export interface BenchmarkMeasurement {
outcome: PlanningOutcome['kind']; outcome: PlanningOutcome['kind'];
lastCompletedStage?: string; lastCompletedStage?: string;
placements: number; placements: number;
deterministic: boolean; deterministic: 'baseline' | 'yes' | 'no';
traceStatus: PlanningTrace['status'] | 'not-produced'; traceStatus: PlanningTrace['status'] | 'not-produced';
traceCandidates: number; traceCandidates: number;
} }
@@ -59,7 +59,7 @@ export function benchmarkScenarios(): BenchmarkScenario[] {
id: 'cp-window', id: 'cp-window',
type: 'time_window', type: 'time_window',
force: 'obligation', force: 'obligation',
startDate: '2024-04-01', startDate: '2024-04-08',
endDate: '2024-04-12', endDate: '2024-04-12',
placeDays: 2, placeDays: 2,
placeType: 'cp', placeType: 'cp',
@@ -68,8 +68,8 @@ export function benchmarkScenarios(): BenchmarkScenario[] {
id: 'rtt-window', id: 'rtt-window',
type: 'time_window', type: 'time_window',
force: 'obligation', force: 'obligation',
startDate: '2024-05-01', startDate: '2024-04-08',
endDate: '2024-05-12', endDate: '2024-04-12',
placeDays: 2, placeDays: 2,
placeType: 'rtt', placeType: 'rtt',
}, },
@@ -166,10 +166,18 @@ function resultSummary(outcome: PlanningOutcome): {
} }
export async function runLeavePlannerBenchmark(repetitions = 1): Promise<BenchmarkMeasurement[]> { 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[] = []; const measurements: BenchmarkMeasurement[] = [];
for (const scenario of benchmarkScenarios()) { for (const scenario of scenarios) {
let previous: string | undefined; let baseline: string | undefined;
for (let repetition = 1; repetition <= repetitions; repetition++) { for (let repetition = 1; repetition <= repetitions; repetition++) {
const wallStart = performance.now();
const preparationStart = performance.now(); const preparationStart = performance.now();
const request = structuredClone(scenario.request); const request = structuredClone(scenario.request);
const preparationMs = performance.now() - preparationStart; const preparationMs = performance.now() - preparationStart;
@@ -182,29 +190,32 @@ export async function runLeavePlannerBenchmark(repetitions = 1): Promise<Benchma
const traceDeliveryMs = performance.now() - traceStart; const traceDeliveryMs = performance.now() - traceStart;
const summary = resultSummary(outcome); const summary = resultSummary(outcome);
const stable = JSON.stringify(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({ measurements.push({
scenario: scenario.name, scenario: scenario.name,
repetition, repetition,
preparationMs, preparationMs,
planningMs, planningMs,
traceDeliveryMs, traceDeliveryMs,
wallMs: preparationMs + planningMs + traceDeliveryMs, wallMs,
...summary, ...summary,
deterministic: previous === undefined || previous === stable, deterministic,
traceStatus: trace?.status ?? 'not-produced', traceStatus: trace?.status ?? 'not-produced',
traceCandidates: traceCandidates,
trace?.phases.reduce(
(total, phase) =>
total +
phase.decisions.reduce(
(decisionTotal, decision) =>
decisionTotal + decision.candidates.length,
0,
),
0,
) ?? 0,
}); });
previous = stable; baseline ??= stable;
} }
} }
return measurements; return measurements;
+82 -16
View File
@@ -582,15 +582,19 @@ class TraceRecorder {
return undefined; return undefined;
} }
recordCandidate(candidate: PlanningTraceCandidate): void { recordCandidate(candidate: PlanningTraceCandidate, copy = true): void {
this.latestDecision().candidates.push({ this.latestDecision().candidates.push(
copy
? {
...candidate, ...candidate,
dates: candidate.dates ? [...candidate.dates] : undefined, dates: candidate.dates ? [...candidate.dates] : undefined,
reason: candidate.reason ? { ...candidate.reason } : undefined, reason: candidate.reason ? { ...candidate.reason } : undefined,
placement: candidate.placement placement: candidate.placement
? { ...candidate.placement, ruleIds: [...candidate.placement.ruleIds] } ? { ...candidate.placement, ruleIds: [...candidate.placement.ruleIds] }
: undefined, : undefined,
}); }
: candidate,
);
} }
recordDecision( recordDecision(
@@ -891,7 +895,8 @@ function observeTrace(context: SearchContext, observe: (trace: TraceRecorder) =>
} }
function recordTraceCandidate(context: SearchContext, candidate: PlanningTraceCandidate): void { 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( function recordTraceIneligible(
@@ -1633,7 +1638,7 @@ function assignDate(
ruleId: string, ruleId: string,
context: SearchContext, context: SearchContext,
): State | null { ): State | null {
return assignDateVariants(state, date, selector, ruleId, context)[0] ?? null; return assignDateVariants(state, date, selector, ruleId, context, true)[0] ?? null;
} }
function assignDateVariants( function assignDateVariants(
@@ -1642,6 +1647,7 @@ function assignDateVariants(
selector: LeaveSelector, selector: LeaveSelector,
ruleId: string, ruleId: string,
context: SearchContext, context: SearchContext,
firstOnly = false,
): State[] { ): State[] {
context.check(); context.check();
const existing = state.placements.get(date); const existing = state.placements.get(date);
@@ -1724,6 +1730,40 @@ function assignDateVariants(
'No compatible leave balance can fund this candidate date.', '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) => { return balances.map((balance) => {
const result = cloneState(state); const result = cloneState(state);
const target = result.balances.find((item) => item.id === balance.id)!; const target = result.balances.find((item) => item.id === balance.id)!;
@@ -1806,23 +1846,40 @@ function assignDateToBalanceInPlace(
date: string, date: string,
balanceId: string, balanceId: string,
context: SearchContext, context: SearchContext,
ruleIds = new Set<string>(),
): boolean { ): boolean {
context.check(); 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); const balance = state.balances.find((item) => item.id === balanceId);
if ( if (
!balance || !balance ||
balance.remaining <= 0 || balance.remaining <= 0 ||
compareDate(balance.availableFrom, date) > 0 || compareDate(balance.availableFrom, date) > 0 ||
compareDate(date, balance.expiryDate) > 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; return false;
}
balance.remaining--; balance.remaining--;
state.placements.set(date, { state.placements.set(date, {
date, date,
balanceId, balanceId,
type: balance.type, type: balance.type,
ruleIds: new Set(), ruleIds: new Set(ruleIds),
supporting: false, supporting: false,
}); });
recordTraceCandidate(context, { recordTraceCandidate(context, {
@@ -1830,7 +1887,7 @@ function assignDateToBalanceInPlace(
date, date,
placement: { placement: {
...state.placements.get(date)!, ...state.placements.get(date)!,
ruleIds: [], ruleIds: [...state.placements.get(date)!.ruleIds],
}, },
}); });
return true; return true;
@@ -2637,15 +2694,18 @@ function optimizeMovablePlacements(
); );
continue; continue;
} }
const candidate = assignDateToBalance( if (
!assignDateToBalanceInPlace(
without, without,
date, date,
current.balanceId, current.balanceId,
context, context,
current.ruleIds, current.ruleIds,
); )
if (!candidate) continue; )
const feasibleCandidate = finalizeEarnedState(candidate, context); continue;
try {
const feasibleCandidate = finalizeEarnedState(without, context);
if (!feasibleCandidate) continue; if (!feasibleCandidate) continue;
feasibleCandidate.placements.get(date)!.supporting = current.supporting; feasibleCandidate.placements.get(date)!.supporting = current.supporting;
const metrics = qualityMetrics(feasibleCandidate, context, preferences); const metrics = qualityMetrics(feasibleCandidate, context, preferences);
@@ -2662,7 +2722,8 @@ function optimizeMovablePlacements(
if (metrics.unplacedAtRisk !== baseline.unplacedAtRisk) continue; if (metrics.unplacedAtRisk !== baseline.unplacedAtRisk) continue;
if (metrics.holidayWeekValue < baseline.holidayWeekValue) continue; if (metrics.holidayWeekValue < baseline.holidayWeekValue) continue;
if ( if (
metrics.preferenceSatisfaction.length !== baseline.preferenceSatisfaction.length || metrics.preferenceSatisfaction.length !==
baseline.preferenceSatisfaction.length ||
metrics.preferenceSatisfaction.some( metrics.preferenceSatisfaction.some(
(value, index) => value !== baseline.preferenceSatisfaction[index], (value, index) => value !== baseline.preferenceSatisfaction[index],
) )
@@ -2680,7 +2741,8 @@ function optimizeMovablePlacements(
? metrics.nonWorkingDayGain === bestMetrics.nonWorkingDayGain && ? metrics.nonWorkingDayGain === bestMetrics.nonWorkingDayGain &&
metrics.longestWorkSequence === metrics.longestWorkSequence ===
bestMetrics.longestWorkSequence && bestMetrics.longestWorkSequence &&
metrics.shortSequenceDeficit < bestMetrics.shortSequenceDeficit metrics.shortSequenceDeficit <
bestMetrics.shortSequenceDeficit
: metrics.nonWorkingDayGain === bestMetrics.nonWorkingDayGain && : metrics.nonWorkingDayGain === bestMetrics.nonWorkingDayGain &&
metrics.longestWorkSequence === metrics.longestWorkSequence ===
bestMetrics.longestWorkSequence && bestMetrics.longestWorkSequence &&
@@ -2688,10 +2750,14 @@ function optimizeMovablePlacements(
bestMetrics.shortSequenceDeficit && bestMetrics.shortSequenceDeficit &&
date < bestDate)); date < bestDate));
if (better) { if (better) {
best = feasibleCandidate; best = cloneState(feasibleCandidate);
bestMetrics = metrics; bestMetrics = metrics;
bestDate = date; bestDate = date;
} }
} finally {
without.placements.delete(date);
without.balances.find((item) => item.id === current.balanceId)!.remaining++;
}
} }
if (best !== result) recordTraceState(context, best); if (best !== result) recordTraceState(context, best);
result = best; result = 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 () => { it('offers no explanation trace for invalid input and explains conflicts', async () => {
const browserWindow = globalThis.window; const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined); vi.stubGlobal('window', undefined);