perf(planning): prune candidates that cannot beat the protected prefix

Finalizing a candidate never moves or retypes a placement, so preference
results and calendar optimizations already have their final values before
it, and leave-balance optimizations do too when nothing can be earned.
Reject a candidate before finalizing when the first differing such
objective of the protected prefix is worse than the baseline. Leave-placement
objectives and earning contexts stop the walk.

The frozen-engine check now also compares cancelled asynchronous runs,
including checkpoints and retained incumbents. The loaded year drops from
48.5 s to 44.3 s; outcomes, explanations and final states are unchanged.

Refs #284

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nessarandClaude Opus 5.5 committed 2026-10-17 00:45:28 +02:00
1 parent 7890a03a74
commit 68a9e33de6
4 files changed
+208 -4

No files matched your search

+51
View File
@@ -84,3 +84,54 @@ assigning dates and about 5 seconds each of garbage collection and timer idle
time.
The functional suite passes 669 tests across 35 files.
## #284: prune candidates that cannot beat the protected prefix
Before #284, `evaluateRuleCandidate` finalized every candidate before comparing
its objectives. In the loaded year, 650,369 of 1,233,535 evaluations (53%) then
ended at a worse protected prefix. For typed obligations it was 74,336 of
273,470, and for three years 38,048 of 193,621.
Finalizing never moves or retypes a placement. It edits only rule
provenance, supporting status, earned activations and earned balances. Before
finalizing, the evaluation therefore walks the candidate's protected prefix in
objective order, using only objectives whose pre-finalize value is already their
exact final value, so the bound is tight:
- time-window and if-then preferences read only placement dates and types,
plus declared days;
- public-holiday-week, time-off-period and work-sequence optimizations read
only the placement dates;
- leave-balance optimizations are exact only when no enabled if-then rule earns
days and the candidate has no earned activation;
- leave-placement optimizations have no safe bound, because finalizing rewrites
supporting status.
The walk prunes at the first differing objective when it is worse than the
baseline. It stops without pruning at a better value or at an objective without
a safe bound. A pruned candidate would have failed finalization or returned at
the same comparison, and incumbents are retained only after the full objective
vector is built. Pruning therefore never changes which candidate wins or which
incumbent is retained.
The frozen-engine check now also runs every tenth seed through both engines'
asynchronous adapter, cancelled after a seed-derived number of steps. It
compares outcomes, artifacts, every checkpoint, the explanation and outcome
callbacks, and the step count. Of those 500 cases, 422 end incomplete and 217
of those keep an incumbent. Pruning on any differing objective breaks both
variants. Wrongly treating leave-placement or earning leave-balance objectives
as exact went undetected, because the generated requests rarely put those
objectives in a protected prefix, so their exclusion rests on the argument
above. The check now takes about 60 seconds with coverage.
| Measurement | Before | After |
| ------------------- | --------: | --------: |
| Vitest duration | 193.680 s | 180.250 s |
| Loaded year | 48.489 s | 44.287 s |
| Typed obligations | 4.947 s | 5.023 s |
| Two years | 1.906 s | 1.837 s |
| Three years | 6.152 s | 5.935 s |
| Representative year | 0.441 s | 0.400 s |
All seven scenarios keep byte-identical outcomes, explanations and final states.
The functional suite passes 684 tests across 35 files.
+74 -3
View File
@@ -3,11 +3,15 @@ import { createOptimizationPresetRules } from './rule-service';
import { frenchPublicHolidaysForYear, isWorkingPlanningDate } from './declared-non-working-days';
import { OPTIMIZATION_CATALOG } from './planning-objectives';
import {
calculatePlanningAsyncInternal,
calculatePlanningInternal,
type PlanningArtifacts,
type PlanningOutcome,
} from './leave-planner.core';
import { calculatePlanningInternal as frozenCalculate } from './frozen-engine/leave-planner.core.frozen.mjs';
import {
calculatePlanningAsyncInternal as frozenCalculateAsync,
calculatePlanningInternal as frozenCalculate,
} from './frozen-engine/leave-planner.core.frozen.mjs';
/** Rebuild instructions for the frozen engine: docs/benchmark/README.md. */
const BASE_SEED = 280_000;
@@ -214,13 +218,67 @@ function assertSameAsFrozen(seed: number): void {
}
}
type AsyncEngine = typeof calculatePlanningAsyncInternal;
/** Cancels after a seed-derived step count: during preparation, mid-search, or never. */
function cancelAfter(seed: number, total: number): number {
const rnd = mulberry32(seed ^ 0x5bd1e995);
const roll = rnd();
if (roll < 0.1) return Math.floor(rnd() * total);
if (roll < 0.25) return Number.POSITIVE_INFINITY;
// Preparation is most of the steps; spend the rest of the budget on the search.
return Math.floor(total * (0.9 + rnd() * 0.1));
}
async function runCancelled(engine: AsyncEngine, request: Req, limit: number) {
const calls: unknown[] = [];
const artifacts: unknown[] = [];
const explanations: unknown[] = [];
const outcomes: unknown[] = [];
let polls = 0;
const outcome = await engine(
structuredClone(request),
(plan, state) => calls.push({ plan, state }),
({ timings: _timings, ...rest }) => artifacts.push(rest),
(o) => outcomes.push(o),
(...args) => explanations.push(args),
() => ++polls > limit,
);
return { outcome, calls, artifacts, explanations, outcomes, polls };
}
const cancelledKinds = new Map<string, number>();
let incumbents = 0;
async function assertCancelledSameAsFrozen(seed: number): Promise<void> {
const request = generateRequest(seed);
const total = (await runCancelled(calculatePlanningAsyncInternal, request, Infinity)).polls;
const limit = cancelAfter(seed, total);
const current = await runCancelled(calculatePlanningAsyncInternal, request, limit);
const frozen = await runCancelled(frozenCalculateAsync, request, limit);
const kind = current.outcome.kind;
cancelledKinds.set(kind, (cancelledKinds.get(kind) ?? 0) + 1);
if (kind === 'PlanningIncomplete' && current.calls.length) incumbents++;
try {
expect(current).toStrictEqual(frozen);
} catch (error) {
throw new Error(
`Cancelled run differs from frozen engine. seed=${seed} limit=${limit}\nrequest=${JSON.stringify(request)}\n${
(error as Error).message
}`,
{ cause: error },
);
}
}
const ONLY_SEED = process.env['FROZEN_ENGINE_SEED'];
describe('frozen engine differential check', () => {
if (ONLY_SEED) {
it(`matches the frozen engine for seed ${ONLY_SEED}`, () => {
it(`matches the frozen engine for seed ${ONLY_SEED}`, async () => {
assertSameAsFrozen(Number(ONLY_SEED));
});
await assertCancelledSameAsFrozen(Number(ONLY_SEED));
}, 120_000);
return;
}
for (let from = 0; from < CASES; from += CHUNK) {
@@ -229,6 +287,19 @@ describe('frozen engine differential check', () => {
}, 120_000);
}
for (let from = 0; from < CASES; from += CHUNK * 2) {
it(`matches the frozen engine when cancelled, seeds ${BASE_SEED + from}.. every 10th`, async () => {
for (let i = from; i < from + CHUNK * 2; i += 10)
await assertCancelledSameAsFrozen(BASE_SEED + i);
}, 300_000);
}
it('retains incumbents of cancelled calculations', () => {
console.info('cancelled coverage', Object.fromEntries(cancelledKinds), { incumbents });
expect(cancelledKinds.get('PlanningIncomplete') ?? 0).toBeGreaterThan(20);
expect(incumbents).toBeGreaterThan(10);
});
afterAll(() => {
console.info(
'frozen-engine coverage of generated cases',
@@ -1,7 +1,22 @@
import type { PlanningArtifactsCallback, PlanningOutcome } from '../leave-planner.core';
import type {
PlanningArtifactsCallback,
PlanningCheckpoint,
PlanningExplanationCallback,
PlanningOutcome,
PlanningOutcomeCallback,
} from '../leave-planner.core';
export function calculatePlanningInternal(
request: unknown,
checkpointCallback?: undefined,
artifactsCallback?: PlanningArtifactsCallback,
): PlanningOutcome;
export function calculatePlanningAsyncInternal(
request: unknown,
checkpointCallback?: PlanningCheckpoint,
artifactsCallback?: PlanningArtifactsCallback,
outcomeCallback?: PlanningOutcomeCallback,
explanationCallback?: PlanningExplanationCallback,
isCancelled?: () => boolean,
): Promise<PlanningOutcome>;
+67
View File
@@ -2350,6 +2350,71 @@ function* timeOffPeriodPlacementCandidates(
}
}
type FinalizeInvariance = 'exact' | 'without-earning' | 'unknown';
const finalizeInvarianceCache = new WeakMap<SearchContext, FinalizeInvariance[]>();
/**
* Finalizing never moves or retypes placements; it only edits ruleIds, supporting, and earned
* activations/balances. Preference results and the calendar optimizations (public-holiday-weeks,
* time-off-periods, work-sequences) read only placement dates and types, so the pre-finalize
* value is already the final one. leave-balances is exact only when nothing can be earned (no
* rewarding if_then rule and no earned activation on the candidate). leave-placements reads
* `supporting`, which finalizing rewrites. Entries follow activeObjectives order.
*/
function finalizeInvariance(context: SearchContext): FinalizeInvariance[] {
let entries = finalizeInvarianceCache.get(context);
if (!entries) {
const earning = context.rules.some(
(rule) => rule.enabled && rule.type === 'if_then' && rule.rewardDays,
);
entries = context.rules
.filter((rule) => rule.enabled && rule.force === 'preference')
.map((rule) => {
if (rule.type !== 'optimization') return 'exact';
switch (rule.configuration.elements) {
case 'public-holiday-weeks':
case 'time-off-periods':
case 'work-sequences':
return 'exact';
case 'leave-balances':
return earning ? 'unknown' : 'without-earning';
default:
// New element kinds stay unpruned until their bound is argued.
return 'unknown';
}
});
finalizeInvarianceCache.set(context, entries);
}
return entries;
}
/** True when the first differing finalize-invariant protected objective is already worse than the baseline. */
function provablyWorseThanProtectedPrefix(
candidate: State,
context: SearchContext,
throughIndex: number,
baseline: readonly PlanningObjective[],
): boolean {
const invariance = finalizeInvariance(context);
let index = 0;
for (const objective of activeObjectives(candidate, context, throughIndex)) {
const kind = invariance[index];
if (
kind === 'unknown' ||
(kind === 'without-earning' && candidate.earnedActivations.length)
)
return false;
const comparison = compareObjectiveValues(
objective.direction,
objective.value,
baseline[index].value,
);
if (comparison !== 0) return comparison > 0;
index++;
}
return false;
}
function evaluateRuleCandidate(
before: State,
candidate: State | null,
@@ -2360,6 +2425,8 @@ function evaluateRuleCandidate(
incumbentVector = objectiveVector(before, context),
): { state: State; vector?: PlanningObjectiveVector } | undefined {
if (!candidate) return undefined;
if (provablyWorseThanProtectedPrefix(candidate, context, throughIndex, baseline))
return undefined;
const state = finalizeRuleCandidate(candidate, context, before);
if (!state) return undefined;
const active: PlanningObjective[] = [];