perf(planning): reuse immutable non-working date counts

This commit is contained in:
nessar committed 2026-10-16 21:19:58 +02:00
1 parent c7e7abcd24
commit 433a8b0ade
3 files changed
+95 -3

No files matched your search

@@ -0,0 +1,61 @@
# PR #278: reuse counts for immutable calendar dates
Measured on 2026-10-16 against `c7e7abc`, which already includes the
[direct date-key optimization](pr-278-date-keys.md).
A loaded-year CPU profile attributed 4.06 of 69.37 seconds to repeatedly counting
distinct non-working dates for time-off-period objectives. The existing calendar
cache already shares frozen date arrays between candidate plans, but each score
rebuilt a `Set` to count the same dates again.
The objective evaluator now keeps those counts in a `WeakMap` keyed by the frozen
array. Mutable arrays are recounted on every evaluation. The weak keys allow
unused arrays to be collected, and configuration validation and duplicate-date
handling remain intact. This change touches only that scoring operation.
## Before and after
Both protocol runs used Node 24.18.0, Bun 1.3.14 and Vitest 5.0.3 on the same local
machine. The baseline is the completed run recorded for `c7e7abc`; its saved core
bundle is byte-for-byte identical to a fresh bundle of that commit. No other
test, build or profiling workload ran alongside either protocol measurement.
```sh
NODE_OPTIONS=--max-old-space-size=2048 timeout 300s bunx vitest run src/app/benchmark/leave-planner-benchmark.spec.ts --maxWorkers=1
```
All seven warm-ups, fourteen measured calculations, the typed-obligation precheck,
fixtures, assertions and resource limits remain unchanged.
| Measurement | Before | After |
| ---------------------------------------------- | --------: | --------: |
| Entire command, external wall time | 268.573 s | 256.034 s |
| Benchmark test | 263.265 s | 250.652 s |
| Loaded year, mean of two measured calculations | 67.747 s | 64.360 s |
| Headroom before timeout | 31.427 s | 43.966 s |
The full command was 4.7% faster in this pair. Both runs completed successfully,
and every repeated scenario remained deterministic. Live CI performance still
needs confirmation.
An isolated check using the loaded-year plan's frozen date arrays took
551–554 ms before and 46–57 ms after for 100,000 scoring calls, following 5,000
warm-ups per run in before/after/after/before order. That result isolates the repeated
counting cost; the protocol above measures its effect on complete calculations.
## Correctness checks
Complete outcomes, stable explanations and final-state snapshots match `c7e7abc`
for all seven scenarios. These separate comparisons ran alongside functional
checks; their diagnostic timings are not used in the protocol comparison.
The functional coverage suite passes all 638 tests across 33 files. Three new
regression cases cover duplicate dates and changes to mutable arrays alongside
frozen arrays for sum, count and maximum scores. Application and spec type checks,
lint, formatting and `git diff --check` also pass.
No browser timing run or live CI rerun was performed for this change.
Diagnostic sources, profiles, bundles and full logs are retained in
`/tmp/vac-opt278-step2`. The original core diagnostic runner is documented in
[issue-78.md](issue-78.md#reproduction).
@@ -75,6 +75,29 @@ describe('composable planning objectives', () => {
).toEqual([3, 7, 4, 6, 5, 2]);
});
it.each(['sum', 'count', 'maximum'] as const)(
'keeps %s distinct-date scores current with frozen and mutable period dates',
(aggregation) => {
const mutableDates = ['2024-05-08'];
const observations: OptimizationObservations = {
...empty,
timeOffPeriods: Object.freeze([
Object.freeze({
nonWorkingDates: Object.freeze(['2024-01-06', '2024-01-07', '2024-01-07']),
}),
Object.freeze({ nonWorkingDates: mutableDates }),
]),
};
const configuration = { ...OPTIMIZATION_PRESETS.nonWorkingDayGain, aggregation };
const initial = { sum: 3, count: 2, maximum: 2 };
expect(evaluateOptimization(configuration, observations)).toBe(initial[aggregation]);
expect(evaluateOptimization(configuration, observations)).toBe(initial[aggregation]);
mutableDates.push('2024-05-09', '2024-05-09', '2024-05-11');
const updated = { sum: 5, count: 2, maximum: 3 };
expect(evaluateOptimization(configuration, observations)).toBe(updated[aggregation]);
},
);
it.each(['sum', 'count', 'maximum'] as const)(
'defines empty %s as zero for every measure',
(aggregation) => {
+11 -3
View File
@@ -203,6 +203,9 @@ export interface OptimizationObservations {
leavePlacements: readonly { supporting: boolean }[];
}
// Calendar geometry reuses frozen date arrays across candidate plans.
const nonWorkingDateCounts = new WeakMap<readonly string[], number>();
export function evaluateOptimization(
configuration: OptimizationConfiguration,
observations: OptimizationObservations,
@@ -224,9 +227,14 @@ export function evaluateOptimization(
.map((week) => week.value);
break;
case 'time-off-periods':
values = observations.timeOffPeriods.map(
(period) => new Set(period.nonWorkingDates).size,
);
values = observations.timeOffPeriods.map(({ nonWorkingDates }) => {
const cached = nonWorkingDateCounts.get(nonWorkingDates);
if (cached !== undefined) return cached;
const count = new Set(nonWorkingDates).size;
if (Object.isFrozen(nonWorkingDates))
nonWorkingDateCounts.set(nonWorkingDates, count);
return count;
});
break;
case 'work-sequences':
values = observations.workSequences