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:
1 parent
7890a03a74
commit
68a9e33de6
4 files changed
+208
-4
No files matched your search
@@ -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.
|
||||
@@ -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>;
|
||||
@@ -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[] = [];
|
||||
|
||||
Reference in new issue
Block a user