diff --git a/docs/benchmark/README.md b/docs/benchmark/README.md index c68b6c9..c0d02ea 100644 --- a/docs/benchmark/README.md +++ b/docs/benchmark/README.md @@ -16,11 +16,14 @@ stage and incumbent are retained. battery level. - Use a cool phone, unplugged, with other applications closed. Record whether the device became warm or throttled during the run. -- Run the benchmark three times after one warm-up run. Do not interact with - the page while a run is active. -- Representative and loaded scenarios should normally complete below five - seconds; every scenario must return within ten seconds. These are release - measurements, not CI timing assertions. +- Tap **Run benchmark** once. It first runs every scenario as an unreported + warm-up, then records three measured repetitions per scenario. Do not interact + with the page while a run is active. The first measured result is the + determinism baseline; later repetitions report whether they match it. +- 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 hardware limitations. No mobile-device measurement is claimed by the source 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 ceiling applies to planning latency; trace delivery is reported separately and 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 +``` diff --git a/src/app/benchmark/leave-planner-benchmark.spec.ts b/src/app/benchmark/leave-planner-benchmark.spec.ts index f525709..3f8bcf8 100644 --- a/src/app/benchmark/leave-planner-benchmark.spec.ts +++ b/src/app/benchmark/leave-planner-benchmark.spec.ts @@ -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); } diff --git a/src/app/benchmark/leave-planner-benchmark.ts b/src/app/benchmark/leave-planner-benchmark.ts index b71b336..b1c60ab 100644 --- a/src/app/benchmark/leave-planner-benchmark.ts +++ b/src/app/benchmark/leave-planner-benchmark.ts @@ -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 { + 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 + 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; diff --git a/src/app/services/leave-planner.core.ts b/src/app/services/leave-planner.core.ts index 1bb70a3..8b5c9ff 100644 --- a/src/app/services/leave-planner.core.ts +++ b/src/app/services/leave-planner.core.ts @@ -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(), ): 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); diff --git a/src/app/services/leave-planner.service.spec.ts b/src/app/services/leave-planner.service.spec.ts index 5182802..073ec45 100644 --- a/src/app/services/leave-planner.service.spec.ts +++ b/src/app/services/leave-planner.service.spec.ts @@ -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);