perf(planning): plan from the best-ranked obligation solution

With several obligation solutions, the planner ran the whole preference
chain on candidate solutions as previews: every solution after a
complete enumeration, or the eight best-ranked after a truncated window
search. It now orders all solutions by the existing pre-preference
ranking (active objectives, then tie-breaks, of each obligation-only
plan; equal ranks keep enumeration order) and runs the preference
stages once, on the first. If that run ends with earned leave available
only after its final merged time-off period, the stages restart on the
next solution, so a request that planned before never becomes a
conflict.

The loaded year drops from 43 to 5.5 seconds and typed obligations from
3.3 to 0.1 seconds, with byte-identical benchmark plans. Over the 5,000
seeded requests, 23 plans become worse (9 on a real objective) and 1
better; over 50 loaded-year-sized requests, 4 become worse on a real
objective. ADR 0010 records the decision; docs/benchmark/issue-302.md
records the comparison and the opt-in runner that reproduces it.

The frozen-engine check fails until the frozen engine is rebuilt from
this commit in the next one.

Part of #302.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nessarandClaude Opus 5.5 committed 2026-10-18 21:18:06 +02:00
1 parent aa6a0c6f1b
commit 8168137cd5
13 files changed
+548 -247

No files matched your search

+3
View File
@@ -104,6 +104,9 @@ An optimization rule that minimizes unplaced at-risk leave.
**Obligation rule**:
A planning rule that every valid plan must satisfy. Obligation rules are resolved jointly; their order only breaks ties between valid plans.
**Obligation solution**:
One way to satisfy every obligation rule jointly, before any preference rule applies. When there are several, the planner ranks them on their obligation-only plans by the planning objective order and its final tie-breaks, and completes the planning objective order from the best-ranked one only.
**Preference rule**:
A planning rule that guides placement but may remain unsatisfied to preserve validity or satisfy higher-priority preferences.
@@ -2,6 +2,8 @@
The ten-second automatic deadline is superseded by [ADR 0006](0006-user-selected-horizons-and-cancellable-calculations.md), implemented by issue #59. Technical callers can still opt into an explicit planning-work budget; the user flow has no automatic cutoff.
The five-second target is revisited by [ADR 0010](0010-rank-obligation-solutions-before-preferences.md), which sets local benchmark targets of ten seconds for the loaded year, five seconds for multi-year horizons and three seconds otherwise.
The fixed ordering between user preferences and configurable optimization objectives, including the unconditional priority for at-risk loss prevention, is superseded by [ADR 0008](0008-user-controlled-planning-objectives.md); implementation of that decision is pending.
VacOptimizer resolves obligation rules exactly and jointly, then applies deterministic heuristic stages for at-risk loss, ordered preferences, complete public-holiday weeks, calendar gain, work-sequence quality, supporting placements, balance expiry, and dates in that order; complete holiday weeks therefore precede broader calendar-gain and work-sequence optimization. A completed plan does not claim mathematical global optimality. The dependency-free planner targets five seconds, returns `PlanningIncomplete` if those stages are interrupted, and stops after ten seconds. A HiGHS WebAssembly spike missed that hard limit on representative inputs even on the development machine, so no solver dependency is accepted. This supersedes ADR 0001.
@@ -0,0 +1,9 @@
# Rank Obligation Solutions Before Preferences
Accepted for [issue #302](https://gitea.nessar.fr/nessar/vac-optimizer/issues/302), after the analysis in [issue #288](https://gitea.nessar.fr/nessar/vac-optimizer/issues/288).
A search change may change planning outcomes when it is deliberate, isolated in one reviewed commit and recorded. The frozen engine of the [frozen-engine differential check](../benchmark/README.md#frozen-engine-differential-check) is rebuilt from that commit, in its own commit, and the benchmark documentation records a reproducible before/after comparison over the check's seeded requests, including every request that became worse on a real objective. Exact optimizations still keep every outcome, stable explanation and final-state snapshot identical to the frozen engine.
When obligation rules can be satisfied jointly in several ways, the planner orders the obligation solutions by its pre-preference ranking: the active objectives, then the tie-breaks, evaluated on the obligation-only plan. Equal ranks keep enumeration order. The planner runs the preference stages once, on the first solution, instead of previewing the preference stages on every candidate solution. This removes a cost that grew with the number of solutions, and trades away the cases where a lower-ranked solution ends better after the preferences: over the 5,000 seeded requests, 23 plans became worse, 9 of them on a real objective, and 1 became better; over 50 loaded-year-sized requests, 4 became worse on a real objective ([comparison record](../benchmark/issue-302.md#comparison-record)). If the run on the chosen solution ends in an earned-entitlement chronology conflict, the planner tries the next solution in that order, and reports a conflict only when every solution fails.
The Bun benchmark targets, measured with the benchmark protocol on the development machine, are 10 seconds for the loaded year, 5 seconds for multi-year horizons and 3 seconds for every other scenario. They are development targets, not reference-phone limits. This revisits the five-second target of [ADR 0003](0003-constraint-first-lexicographic-planning.md); [ADR 0006](0006-user-selected-horizons-and-cancellable-calculations.md) already removed its automatic cutoff and sets no validated limit for multi-year horizons.
+6
View File
@@ -138,6 +138,12 @@ if an outcome kind, rule type, year-crossing horizon or public-holiday horizon
stops appearing. Requests stay small because candidate search grows quickly
with horizon length and balances; the slowest case takes about one second.
The generator is `src/app/services/frozen-engine/random-planning-requests.ts`.
`compare-engines.ts` beside it is an opt-in runner, outside the functional suite
and CI, that classifies how the current engine's plans differ from the
calibration engine's over the same seeds; [the #302 record](issue-302.md#comparison-record)
shows its use.
The fixture is `src/app/services/frozen-engine/leave-planner.core.frozen.mjs`,
a generated bundle with `date-holidays` left external, and its hand-written
`leave-planner.core.frozen.d.mts`. Rebuild it only for an intended behavior
+109
View File
@@ -131,3 +131,112 @@ Across the five exact steps, the same harness measured three years at
7.45 seconds before the first step and 2.97 seconds after the last, two years at
2.27 and 0.92 seconds, typed obligations at 5.11 and 3.27 seconds, and the loaded
year at 58.26 and 44.05 seconds. These figures come from different sessions.
## Choosing the obligation solution
This is the one deliberate behavior change
([ADR 0010](../adr/0010-rank-obligation-solutions-before-preferences.md)).
When obligation rules can be satisfied jointly in several ways, the planner used
to run the whole preference chain on candidate solutions as previews: every
solution when their enumeration completed, or the eight best-ranked when a
window search stopped at its limit. It kept the solution whose preview scored
best and ran the preference stages again on it. The planner now orders the
solutions by the pre-preference ranking that chose those eight, the active
objectives then the tie-breaks of each obligation-only plan, with enumeration
order breaking equal ranks. It runs the preference stages once, on the first
solution. If that run ends with earned leave available only after its final
merged time-off period, the stages restart on the next solution, and a
conflict is reported only when every solution fails.
### Timing
Measured like the exact steps, against the last exact step, with two measured
calculations per engine. All seven scenarios keep byte-identical outcomes,
explanations and final states.
| Scenario | Before (s) | After (s) |
| --------------------------- | ---------: | --------: |
| representative year | 0.26 | 0.26 |
| typed obligations | 3.26 | 0.11 |
| chained earned entitlements | 0.00 | 0.01 |
| deliberate conflict | 0.00 | 0.00 |
| loaded year | 43.09 | 5.54 |
| two years | 0.94 | 0.97 |
| three years | 3.10 | 3.06 |
### Comparison record
The opt-in runner compares the current engine with the calibration engine
(`a871f8f`, which still previews) on the frozen-engine check's 5,000 seeded
requests. It runs outside the functional suite and CI:
```sh
bun src/app/services/frozen-engine/compare-engines.ts small 280000 5000
```
| Result | Requests |
| --------------------------------- | -------: |
| Identical | 4,970 |
| Same objective values, other plan | 6 |
| Better | 1 |
| Worse | 23 |
| Changed outcome kind | 0 |
Fourteen of the worse requests differ only in a final tie-break: eight on
placement dates and six on balance expiry. The other nine are worse on a real
objective:
| Seed | Worse objective | Previews → ranking |
| ------ | --------------------------- | ------------------ |
| 280112 | longest work sequence | 2 → 3 |
| 280204 | composed optimization | 6 → 3 |
| 280686 | longest work sequence | 2 → 3 |
| 283162 | supporting placements | 0 → 1 |
| 283484 | longest work sequence | 4 → 5 |
| 283930 | `tw0` preference | 2 → 1 |
| 284628 | longest work sequence | 8 → 16 |
| 284696 | non-working-day gain | 3 → 0 |
| 284824 | short work-sequence deficit | 0 → 2 |
Seed 282254 is better: its `tw1` preference goes from 1 to 2. The runner prints
every non-identical seed with its first differing objective; a first seed and
a count of 1, such as `small 284696 1`, reproduce a single request.
None of these 5,000 requests reaches the earned-entitlement fallback. A
separate search of seeds 200000 to 259999 with the same generator found 28
requests whose best-ranked solution earns its leave too late; all 28 plan from
a later solution, and 26 of them are identical to the calibration engine's
plan. Without the fallback, each would have become a conflict.
### Loaded-year-sized sample
The 5,000 requests last 7 to 70 days. A second generator,
`generateLoadedYearRequest`, builds one-year requests shaped like the `loaded
year` scenario: four to eight CP or RTT balances of 6 to 14 days, two to four
obligation windows of two or three days in distinct months, up to two
preference windows, and the six presets. Their window search always stops at its
solution limit, so the calibration engine previews eight solutions. Fifty of
them were compared, in five parallel runs of ten seeds:
```sh
bun src/app/services/frozen-engine/compare-engines.ts loaded-year 302000 50
```
| Result | Requests |
| ------------------------------- | -------: |
| Identical | 45 |
| Worse, placement-date tie-break | 1 |
| Worse on a real objective | 4 |
| Better or changed outcome kind | 0 |
| Seed | Worse objective | Previews → ranking |
| ------ | --------------------- | ------------------ |
| 302011 | longest work sequence | 14 → 19 |
| 302033 | public-holiday weeks | 14 → 13 |
| 302040 | `window-4` preference | 2 → 1 |
| 302049 | longest work sequence | 25 → 30 |
On these larger plans, ranking loses a real objective more often than on the
small requests: 4 in 50, against 9 in 5,000. The synchronous calibration engine
took 3,924 seconds for the 50 requests, about 78 seconds each, and the current
engine 289 seconds, about 5.8 seconds each.
+1 -1
View File
@@ -16,7 +16,7 @@ User cancellation is distinct from replacement by another calculation (`stale-ca
The asynchronous calculation retains a copied incumbent as soon as the obligation search finds a jointly valid solution, before that entire stage has completed. It also considers accepted placements and every feasible evaluated quality candidate, including candidates rejected by a stage's narrower comparator. Each candidate is reconciled for earned entitlement availability and checked against all obligation rules before retention. The existing full lexicographic comparator chooses the best available incumbent.
Retention reuses quality metrics already computed for an evaluated candidate. An intermediate state with more unplaced at-risk leave, or worse preferences at equal unplaced leave, is rejected using that objective prefix alone. This avoids rescoring dominated states during large obligation previews without skipping a candidate that could improve the retained plan.
Retention reuses quality metrics already computed for an evaluated candidate. An intermediate state with more unplaced at-risk leave, or worse preferences at equal unplaced leave, is rejected using that objective prefix alone. This avoids rescoring dominated states without skipping a candidate that could improve the retained plan.
Preference occurrence dates and patterns are calculated once for the run's immutable calendar and rules. Each comparison still checks the candidate's actual placements against those patterns; candidate scores are not cached.
+14 -13
View File
@@ -140,12 +140,15 @@ Moves that preserve every active objective can be pruned when their final tie-br
cannot improve. Per-iteration vectors are reused; preset statistics are materialized
only for selected incumbents and published plans.
For large non-earned window obligation searches above the existing exact-branch
threshold, the joint feasibility search retains its existing limit. Feasible
solutions are ranked by actual active objectives and final ties; the first eight
receive full optimization previews. Other feasible solutions are recorded as
`not-examined`. Small searches and earned-entitlement chains keep all previews.
This deterministic sampling has no elapsed-time cutoff or user-facing setting.
For large non-earned obligation searches above the existing exact-branch
threshold, the joint feasibility search retains its existing limit. When several
obligation solutions are found, they are ranked by the actual active objectives
and final ties of their obligation-only plans, with enumeration order breaking
equal ranks. The preference stages run once, on the best-ranked solution; no
solution receives a preference preview ([ADR 0010](adr/0010-rank-obligation-solutions-before-preferences.md)).
If that run ends with earned leave available only after its final merged
time-off period, the stages restart on the next solution in rank order. This
deterministic ranking has no elapsed-time cutoff or user-facing setting.
A deferred optimization addition is supporting only when it provides one of the
calendar benefits in the domain definition, rather than merely extending a period.
@@ -186,10 +189,10 @@ These are deterministic search limits, rather than claims of global optimality:
declared-adjacent, weekly and calendar-window destinations. Other destinations
retain `not-examined` trace records.
- Non-earned obligation searches whose estimated branch count exceeds 10,000 retain
at most 4,096 feasible solutions. Large window searches rank them by the actual
active vector and final ties, then fully preview the first eight; the other
feasible solutions are marked `not-examined`. Earned chains and small searches
retain all previews.
at most 4,096 feasible solutions. Every search ranks its solutions by the actual
active vector and final ties of the obligation-only plans, and the preference
stages run on the best-ranked solution only. A lower-ranked solution that would
end better after the preferences is not found.
- Calendar bundles rank original donors once by the actual active vector of the
source plan with each donor released, then retain that order during recursive
allocation. They keep the first prefix-valid allocation and collapse equivalent
@@ -209,9 +212,7 @@ These are deterministic search limits, rather than claims of global optimality:
plateau has no specialized bundle generator; the supplied sum/minimize loss
preset does consume both.
A completely enumerated single obligation solution has no alternative to rank, so
its discarded full preference preview is skipped; the real ordered execution still
runs in full. Earlier rules reuse their completed evaluation only when the complete
Earlier rules reuse their completed evaluation only when the complete
ordered incoming state matches their last output, including balance quantities and
validity, earned activations, placement insertion order, supporting status and rule
provenance. The trace records that unchanged state as `not-examined` with the earlier
+9 -136
View File
@@ -1,7 +1,5 @@
import { afterAll, describe, expect, it } from 'bun:test';
import { createOptimizationPresetRules } from './rule-service';
import { frenchPublicHolidaysForYear, isWorkingPlanningDate } from './declared-non-working-days';
import { OPTIMIZATION_CATALOG } from './planning-objectives';
import { frenchPublicHolidaysForYear } from './declared-non-working-days';
import {
calculatePlanningAsyncInternal,
calculatePlanningInternal,
@@ -12,145 +10,20 @@ import {
calculatePlanningAsyncInternal as frozenCalculateAsync,
calculatePlanningInternal as frozenCalculate,
} from './frozen-engine/leave-planner.core.frozen.mjs';
import {
DAY,
generateRequest,
iso,
mulberry32,
weekday,
type Req,
} from './frozen-engine/random-planning-requests';
/** Rebuild instructions for the frozen engine: docs/benchmark/README.md. */
const BASE_SEED = 280_000;
const CASES = 5000;
const CHUNK = 200;
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const DAY = 86_400_000;
const weekday = (ms: number) => ![0, 6].includes(new Date(ms).getUTCDay());
const iso = (ms: number) => new Date(ms).toISOString().slice(0, 10);
type Req = Record<string, unknown> & { rules: Record<string, unknown>[] };
function generateRequest(seed: number): Req {
const rnd = mulberry32(seed);
const int = (min: number, max: number) => min + Math.floor(rnd() * (max - min + 1));
const pick = <T>(items: readonly T[]): T => items[int(0, items.length - 1)];
const chance = (p: number) => rnd() < p;
const shuffle = <T>(items: T[]): T[] => {
for (let i = items.length - 1; i > 0; i--) {
const j = int(0, i);
[items[i], items[j]] = [items[j], items[i]];
}
return items;
};
const long = chance(0.1);
const mode = rnd();
let startMs = Date.UTC(2025, 0, 1) + int(0, 1094) * DAY;
if (mode < 0.15) startMs = Date.UTC(pick([2025, 2026]), 11, int(8, 31));
else if (mode < 0.3) {
const year = pick([2025, 2026, 2027]);
const holidays = [...frenchPublicHolidaysForYear(year)].filter((d) =>
weekday(Date.parse(d)),
);
startMs = Date.parse(pick(holidays)) - int(1, 10) * DAY;
}
const days = long ? int(29, 70) : int(7, 28);
const start = iso(startMs);
const date = (offset: number) => iso(startMs + offset * DAY);
const types = ['cp', 'rtt', 'other'] as const;
const balances = Array.from({ length: int(1, 3) }, (_, i) => ({
id: `b${i}`,
type: pick(types),
quantity: int(0, long ? 4 : 3),
...(chance(0.3) ? { availableFrom: date(int(1, days)) } : {}),
expiryDate: date(int(-3, days + 40)),
})).map((b) =>
b.availableFrom && b.availableFrom > b.expiryDate
? { ...b, expiryDate: b.availableFrom }
: b,
);
const declaredDays: { date: string; type: string }[] = [];
for (let n = int(0, 4); n > 0; n--) {
const ms = startMs + int(0, days) * DAY;
const d = iso(ms);
if (isWorkingPlanningDate(d) && !declaredDays.some((x) => x.date === d))
declaredDays.push({ date: d, type: pick(types) });
}
const selectors = ['cp', 'rtt', 'other', 'any'] as const;
const rules: Record<string, unknown>[] = [];
for (let n = long ? int(0, 1) : int(0, 2); n > 0; n--) {
const from = int(0, days - 1);
rules.push({
id: `tw${rules.length}`,
type: chance(0.5) ? 'time_window' : 'TIME_WINDOW',
force: pick(['obligation', 'preference']),
startDate: date(from),
endDate: date(from + int(0, 10)),
placeDays: int(1, 4),
placeType: pick(selectors),
});
}
for (let n = long && rules.length ? 0 : long ? int(0, 1) : int(0, 2); n > 0; n--) {
rules.push({
id: `it${rules.length}`,
type: chance(0.5) ? 'if_then' : 'IF_THEN',
force: pick(['obligation', 'preference']),
dayOfWeek: int(1, 5),
placeDays: int(1, 3),
placeType: pick(selectors),
placement: pick(['before', 'after', 'around', 'auto']),
rewardDays: int(0, 2),
rewardType: pick(types),
});
}
const presets: Record<string, unknown>[] = shuffle(
createOptimizationPresetRules() as unknown as Record<string, unknown>[],
).slice(0, long ? int(1, 4) : int(0, 3));
rules.push(...presets);
if (!long && chance(0.3)) {
const row = pick(OPTIMIZATION_CATALOG);
const conditions = (row.conditions as readonly string[]).filter(() => chance(0.5));
rules.push({
id: 'composed',
type: 'optimization',
force: 'preference',
configuration: {
elements: row.elements,
conditions,
measure: row.measure,
aggregation: pick(row.aggregations),
direction: pick(['minimize', 'maximize']),
...(row.measure === 'work-sequence-deficit' ? { thresholdDays: int(1, 8) } : {}),
},
});
}
for (const rule of shuffle(rules)) if (chance(0.08)) rule['enabled'] = false;
const request: Req = {
calculationDate: start,
horizon: { start, end: date(days) },
balances,
declaredDays,
rules,
};
if (chance(0.04)) {
const breakIt = int(0, 3);
if (breakIt === 0) balances[0].quantity = -1;
else if (breakIt === 1) request['horizon'] = { start: date(days), end: start };
else if (breakIt === 2) rules.push({ id: 'bad', type: 'unknown_type' });
else declaredDays.push({ date: start, type: 'nope' });
}
return request;
}
interface Run {
outcome: PlanningOutcome;
artifacts?: Pick<PlanningArtifacts, 'explanation' | 'finalState' | 'explanationError'>;
@@ -0,0 +1,80 @@
// Opt-in before/after record, outside the functional suite and CI: compares the
// current engine with the calibration engine (a871f8f) over seeded requests.
// bun src/app/services/frozen-engine/compare-engines.ts [small|loaded-year] [first seed] [count]
import { isDeepStrictEqual } from 'node:util';
import type { PlanningArtifacts, PlanningObjective, PlanningOutcome } from '../leave-planner.core';
import { compareObjectiveValues } from '../planning-objectives';
// The preset rules come from an Angular service file, which needs the JIT compiler outside tests.
await import('@angular/compiler');
const { generateLoadedYearRequest, generateRequest } = await import('./random-planning-requests');
const current = await import('../leave-planner.core');
const calibration = await import('./leave-planner.core.calibration.mjs');
const [kind = 'small', first = '280000', count = '5000'] = process.argv.slice(2);
const generate = kind === 'loaded-year' ? generateLoadedYearRequest : generateRequest;
type Engine = (
request: unknown,
checkpoint?: undefined,
artifacts?: (artifacts: PlanningArtifacts) => void,
) => PlanningOutcome;
function run(calculate: Engine, request: unknown) {
let artifacts: PlanningArtifacts | undefined;
const started = performance.now();
const outcome = calculate(structuredClone(request), undefined, (value) => (artifacts = value));
return {
ms: performance.now() - started,
result: {
outcome,
explanation: artifacts?.explanation,
finalState: artifacts?.finalState,
explanationError: artifacts?.explanationError,
},
};
}
/** The final plan's objective vector, which every placement explanation repeats. */
function objectives(result: ReturnType<typeof run>['result']): readonly PlanningObjective[] {
return result.explanation?.kind === 'PlanExplanation'
? (result.explanation.placements[0]?.objectiveVector.objectives ?? [])
: [];
}
const counts = new Map<string, number>();
const time = { before: 0, after: 0 };
for (let seed = Number(first); seed < Number(first) + Number(count); seed++) {
const request = generate(seed);
const before = run(calibration.calculatePlanningInternal, request);
const after = run(current.calculatePlanningInternal, request);
time.before += before.ms;
time.after += after.ms;
let category: string;
let detail = '';
if (isDeepStrictEqual(before.result, after.result)) category = 'identical';
else if (before.result.outcome.kind !== after.result.outcome.kind)
category = 'changed outcome kind';
else if (after.result.outcome.kind !== 'Plan') category = 'different non-plan outcome';
else {
const left = objectives(before.result);
const right = objectives(after.result);
const index = right.findIndex((objective, i) => objective.value !== left[i]?.value);
if (index < 0 && left.length === right.length) category = 'same objective values';
else if (index < 0 || left[index]?.id !== right[index].id) category = 'not comparable';
else {
const objective = right[index];
const better =
compareObjectiveValues(objective.direction, objective.value, left[index].value) < 0;
const tieBreak = ['balance-expiry', 'placement-date'].includes(objective.id);
category = `${better ? 'better' : 'worse'}${tieBreak ? ' (tie-break only)' : ''}`;
detail = `\t${objective.id}\t${left[index].value} -> ${objective.value}`;
}
}
if (category !== 'identical') console.log(`${seed}\t${category}${detail}`);
counts.set(category, (counts.get(category) ?? 0) + 1);
}
console.log(`${kind} requests, seeds ${first}..${Number(first) + Number(count) - 1}`);
for (const [category, total] of [...counts].sort()) console.log(`${category}\t${total}`);
console.log(
`calibration engine ${(time.before / 1000).toFixed(1)} s, current engine ${(time.after / 1000).toFixed(1)} s`,
);
@@ -0,0 +1,179 @@
// Seeded planning requests shared by the frozen-engine check and the engine comparison.
import { frenchPublicHolidaysForYear, isWorkingPlanningDate } from '../declared-non-working-days';
import { OPTIMIZATION_CATALOG } from '../planning-objectives';
import { createOptimizationPresetRules } from '../rule-service';
export function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export const DAY = 86_400_000;
export const weekday = (ms: number) => ![0, 6].includes(new Date(ms).getUTCDay());
export const iso = (ms: number) => new Date(ms).toISOString().slice(0, 10);
export type Req = Record<string, unknown> & { rules: Record<string, unknown>[] };
export function generateRequest(seed: number): Req {
const rnd = mulberry32(seed);
const int = (min: number, max: number) => min + Math.floor(rnd() * (max - min + 1));
const pick = <T>(items: readonly T[]): T => items[int(0, items.length - 1)];
const chance = (p: number) => rnd() < p;
const shuffle = <T>(items: T[]): T[] => {
for (let i = items.length - 1; i > 0; i--) {
const j = int(0, i);
[items[i], items[j]] = [items[j], items[i]];
}
return items;
};
const long = chance(0.1);
const mode = rnd();
let startMs = Date.UTC(2025, 0, 1) + int(0, 1094) * DAY;
if (mode < 0.15) startMs = Date.UTC(pick([2025, 2026]), 11, int(8, 31));
else if (mode < 0.3) {
const year = pick([2025, 2026, 2027]);
const holidays = [...frenchPublicHolidaysForYear(year)].filter((d) =>
weekday(Date.parse(d)),
);
startMs = Date.parse(pick(holidays)) - int(1, 10) * DAY;
}
const days = long ? int(29, 70) : int(7, 28);
const start = iso(startMs);
const date = (offset: number) => iso(startMs + offset * DAY);
const types = ['cp', 'rtt', 'other'] as const;
const balances = Array.from({ length: int(1, 3) }, (_, i) => ({
id: `b${i}`,
type: pick(types),
quantity: int(0, long ? 4 : 3),
...(chance(0.3) ? { availableFrom: date(int(1, days)) } : {}),
expiryDate: date(int(-3, days + 40)),
})).map((b) =>
b.availableFrom && b.availableFrom > b.expiryDate
? { ...b, expiryDate: b.availableFrom }
: b,
);
const declaredDays: { date: string; type: string }[] = [];
for (let n = int(0, 4); n > 0; n--) {
const ms = startMs + int(0, days) * DAY;
const d = iso(ms);
if (isWorkingPlanningDate(d) && !declaredDays.some((x) => x.date === d))
declaredDays.push({ date: d, type: pick(types) });
}
const selectors = ['cp', 'rtt', 'other', 'any'] as const;
const rules: Record<string, unknown>[] = [];
for (let n = long ? int(0, 1) : int(0, 2); n > 0; n--) {
const from = int(0, days - 1);
rules.push({
id: `tw${rules.length}`,
type: chance(0.5) ? 'time_window' : 'TIME_WINDOW',
force: pick(['obligation', 'preference']),
startDate: date(from),
endDate: date(from + int(0, 10)),
placeDays: int(1, 4),
placeType: pick(selectors),
});
}
for (let n = long && rules.length ? 0 : long ? int(0, 1) : int(0, 2); n > 0; n--) {
rules.push({
id: `it${rules.length}`,
type: chance(0.5) ? 'if_then' : 'IF_THEN',
force: pick(['obligation', 'preference']),
dayOfWeek: int(1, 5),
placeDays: int(1, 3),
placeType: pick(selectors),
placement: pick(['before', 'after', 'around', 'auto']),
rewardDays: int(0, 2),
rewardType: pick(types),
});
}
const presets: Record<string, unknown>[] = shuffle(
createOptimizationPresetRules() as unknown as Record<string, unknown>[],
).slice(0, long ? int(1, 4) : int(0, 3));
rules.push(...presets);
if (!long && chance(0.3)) {
const row = pick(OPTIMIZATION_CATALOG);
const conditions = (row.conditions as readonly string[]).filter(() => chance(0.5));
rules.push({
id: 'composed',
type: 'optimization',
force: 'preference',
configuration: {
elements: row.elements,
conditions,
measure: row.measure,
aggregation: pick(row.aggregations),
direction: pick(['minimize', 'maximize']),
...(row.measure === 'work-sequence-deficit' ? { thresholdDays: int(1, 8) } : {}),
},
});
}
for (const rule of shuffle(rules)) if (chance(0.08)) rule['enabled'] = false;
const request: Req = {
calculationDate: start,
horizon: { start, end: date(days) },
balances,
declaredDays,
rules,
};
if (chance(0.04)) {
const breakIt = int(0, 3);
if (breakIt === 0) balances[0].quantity = -1;
else if (breakIt === 1) request['horizon'] = { start: date(days), end: start };
else if (breakIt === 2) rules.push({ id: 'bad', type: 'unknown_type' });
else declaredDays.push({ date: start, type: 'nope' });
}
return request;
}
/**
* Loaded-year-sized requests: one year, four to eight balances and two to four
* obligation windows of two or three days. Their obligation search always stops
* at its solution limit, so the calibration engine previews at most eight solutions.
*/
export function generateLoadedYearRequest(seed: number): Req {
const rnd = mulberry32(seed);
const int = (min: number, max: number) => min + Math.floor(rnd() * (max - min + 1));
const pick = <T>(items: readonly T[]): T => items[int(0, items.length - 1)];
const year = int(2024, 2026);
const start = `${year}-01-01`;
const months = Array.from({ length: 12 }, (_, index) => index + 1);
for (let i = months.length - 1; i > 0; i--) {
const j = int(0, i);
[months[i], months[j]] = [months[j], months[i]];
}
const obligations = int(2, 4);
const windows = months.slice(0, obligations + int(0, 2)).map((month, index) => ({
id: `window-${index}`,
type: 'time_window',
force: index < obligations ? 'obligation' : 'preference',
startDate: `${year}-${String(month).padStart(2, '0')}-01`,
endDate: `${year}-${String(month).padStart(2, '0')}-28`,
placeDays: index < obligations ? int(2, 3) : int(1, 4),
placeType: pick(['any', 'cp', 'rtt']),
}));
const presets = createOptimizationPresetRules() as unknown as Record<string, unknown>[];
return {
calculationDate: start,
horizon: { start, end: `${year + 1}-01-01` },
balances: Array.from({ length: int(4, 8) }, (_, index) => ({
id: `b${index}`,
type: pick(['cp', 'rtt']),
quantity: int(6, 14),
availableFrom: start,
expiryDate: `${year}-12-31`,
})),
declaredDays: [],
rules: [presets[0], ...windows, ...presets.slice(1)],
};
}
@@ -0,0 +1,102 @@
import { describe, expect, it } from 'bun:test';
import { calculatePlanningInternal, type PlanningRequest } from './leave-planner.core';
import { createOptimizationPresetRules } from './rule-service';
const presets = createOptimizationPresetRules();
const preset = (id: string) => presets.find((rule) => rule.id === id)!;
describe('choosing among obligation solutions', () => {
it('plans from the solution ranked best before the preferences', () => {
// The obligation day can take either balance. Before the preferences, the
// at-risk RTT ranks first because it leaves no at-risk leave unplaced. Funding
// it with the CP instead would let the RTT gain three days around Easter
// later; ADR 0010 accepts that loss to run the preferences only once.
const request: PlanningRequest & { calculationDate: string } = {
calculationDate: '2026-04-03',
horizon: { start: '2026-04-03', end: '2026-05-24' },
balances: [
{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2026-06-30' },
{ id: 'rtt', type: 'rtt', quantity: 1, expiryDate: '2026-05-23' },
],
rules: [
preset('default-unplaced-at-risk'),
preset('default-supporting-placements'),
preset('default-non-working-day-gain'),
{
id: 'thursday',
type: 'time_window',
force: 'obligation',
startDate: '2026-04-09',
endDate: '2026-04-09',
placeDays: 1,
placeType: 'any',
},
],
};
const outcome = calculatePlanningInternal(request);
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan') return;
expect(outcome.placements).toEqual([
{
date: '2026-04-09',
balanceId: 'rtt',
type: 'rtt',
ruleIds: ['thursday'],
supporting: false,
},
]);
expect(outcome.metrics.unplacedAtRisk).toBe(0);
});
it('plans from the next solution when the best-ranked one earns its leave too late', () => {
const request: PlanningRequest & { calculationDate: string } = {
calculationDate: '2026-07-09',
horizon: { start: '2026-07-09', end: '2026-07-25' },
balances: [
{
id: 'cp',
type: 'cp',
quantity: 3,
availableFrom: '2026-07-13',
expiryDate: '2026-08-10',
},
],
rules: [
{
id: 'tuesday',
type: 'if_then',
force: 'preference',
dayOfWeek: 2,
placeDays: 1,
placeType: 'any',
placement: 'auto',
rewardDays: 2,
rewardType: 'rtt',
},
{
id: 'late-july',
type: 'time_window',
force: 'obligation',
startDate: '2026-07-23',
endDate: '2026-08-02',
placeDays: 2,
placeType: 'any',
},
],
};
const outcome = calculatePlanningInternal(request);
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan') return;
expect(
outcome.placements.map(({ date, balanceId, ruleIds }) => [date, balanceId, ruleIds]),
).toEqual([
['2026-07-13', 'cp', ['tuesday']],
['2026-07-23', 'cp', ['late-july']],
['2026-07-24', 'cp', ['late-july']],
]);
});
});
@@ -360,7 +360,9 @@ describe('supporting placement economy and provenance', () => {
}),
expect.objectContaining({
date: '2024-05-10',
ruleIds: ['gain'],
// The best-ranked obligation solution placed this date for the producer's
// after-pattern before gain ran, so the date keeps no gain credit (ADR 0010).
ruleIds: force === 'obligation' ? [] : ['gain'],
supporting: true,
}),
]);
+31 -96
View File
@@ -558,13 +558,6 @@ type PlanningCalendar = Pick<
| 'workingPrefix'
>;
interface ObligationPreview {
obligationState: State;
state: State;
preferences: PreferenceResult[];
metrics: PlanningMetrics;
}
function now(): number {
return typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();
}
@@ -584,12 +577,11 @@ class UserCancelled extends Error {}
// A true value asks the asynchronous driver to pause immediately at a phase boundary.
type PlanningSteps<T> = Generator<boolean | undefined, T, void>;
// ponytail: cap the number of feasible obligation branches previewed for a
// ponytail: cap the number of feasible obligation branches ranked for a
// loaded year; replace with bounded branch-and-bound if mobile evidence shows
// that this ceiling misses a better lexicographic assignment.
const OBLIGATION_SOLUTION_LIMIT = 4096;
const EXACT_OBLIGATION_BRANCH_LIMIT = 10_000;
const WINDOW_OBLIGATION_PREVIEW_LIMIT = 8;
const VALID_TYPES = new Set<LeaveType>(['cp', 'rtt', 'other']);
const VALID_SELECTORS = new Set<LeaveSelector>(['cp', 'rtt', 'other', 'any']);
const VALID_MODES = new Set<PlacementMode>(['before', 'after', 'around', 'auto']);
@@ -3293,26 +3285,6 @@ function preferenceReason(
return reason(code, `Preference "${rule.name}" could not be completely satisfied.`, [rule.id]);
}
function* applyPreferenceRules(
state: State,
preferenceRules: NormalizedRule[],
context: SearchContext,
): PlanningSteps<{ state: State; preferences: PreferenceResult[] }> {
let result = state;
const preferences: PreferenceResult[] = [];
for (let preferenceIndex = 0; preferenceIndex < preferenceRules.length; preferenceIndex++) {
yield;
result = yield* applyPreferenceStep(
result,
preferenceRules,
preferenceIndex,
preferences,
context,
);
}
return { state: result, preferences };
}
function* applyPreferenceStep(
state: State,
preferenceRules: NormalizedRule[],
@@ -4300,9 +4272,7 @@ function* calculatePlanningSteps(
let checkpoint: PlanOutcome | undefined;
let checkpointState: State | undefined;
let retained:
| (Pick<ObligationPreview, 'state' | 'preferences'> & {
vector: PlanningObjectiveVector;
})
| { state: State; preferences: PreferenceResult[]; vector: PlanningObjectiveVector }
| undefined;
if (retainIncumbents)
context.retain = (candidate, candidateVector) => {
@@ -4551,15 +4521,9 @@ function* calculatePlanningSteps(
const preferenceRules = normalized.rules.filter(
(item) => item.enabled && item.force === 'preference',
);
let previewSolutions = obligationSolutions;
if (
obligationSolutionLimit > 0 &&
obligationRequirements.every(
(requirement) => requirement.rule.type === 'time_window',
) &&
obligationSolutions.length > WINDOW_OBLIGATION_PREVIEW_LIMIT
) {
// shortcut: large window searches preview the eight best active vectors; upgrade to shared bounded previews when a measured case misses a better plan.
let rankedSolutions = obligationSolutions;
if (obligationSolutions.length > 1) {
// Rank obligation-only plans; the preference stages run once, on the best-ranked.
const ranked: { state: State; objectives: PlanningObjective[] }[] = [];
for (const candidate of obligationSolutions) {
yield;
@@ -4569,57 +4533,23 @@ function* calculatePlanningSteps(
objectives: obligationPreviewObjectives({ state: candidate }, context),
});
}
// The sort is stable, so equal ranks keep enumeration order.
ranked.sort((a, b) => compareObjectiveLists(a.objectives, b.objectives));
previewSolutions = ranked
.slice(0, WINDOW_OBLIGATION_PREVIEW_LIMIT)
.map(({ state }) => state);
rankedSolutions = ranked.map(({ state }) => state);
}
// A completed enumeration with one finalized solution has no alternative to rank.
const soleSolution =
obligationSolutions.length === 1 &&
(obligationSolutionLimit === 0 || obligationSolutions.length < obligationSolutionLimit)
? obligationSolutions[0]
: undefined;
const solePreferences = soleSolution
? preferenceResultsForState(soleSolution, context)
: [];
let bestPreview: ObligationPreview | null = soleSolution
? {
obligationState: soleSolution,
state: soleSolution,
preferences: solePreferences,
metrics: qualityMetrics(soleSolution, context, solePreferences),
}
: null;
for (const obligationSolution of soleSolution ? [] : previewSolutions) {
yield;
context.check();
const preview = yield* applyPreferenceRules(
cloneState(obligationSolution),
preferenceRules,
context,
);
const previewState = finalizeEarnedState(preview.state, context);
if (!previewState) continue;
const candidate = {
obligationState: obligationSolution,
state: previewState,
preferences: preview.preferences,
metrics: qualityMetrics(previewState, context, preview.preferences),
};
const compared: number | undefined = bestPreview
? compareObligationPreviews(candidate, bestPreview, context)
: undefined;
if (!bestPreview || compared! < 0) bestPreview = candidate;
}
if (!bestPreview) {
stages.push('obligations');
return finish(obligationConflict(obligationRequirements, context));
}
state = bestPreview.obligationState;
for (const obligationSolution of rankedSolutions) {
// A solution whose earned leave comes too late restarts the stages on the next one.
state = obligationSolution;
stages.length = 1;
preferences.length = 0;
checkpointState = undefined;
stages.push('obligations');
publishCheckpoint();
for (let preferenceIndex = 0; preferenceIndex < preferenceRules.length; preferenceIndex++) {
for (
let preferenceIndex = 0;
preferenceIndex < preferenceRules.length;
preferenceIndex++
) {
yield;
const rule = preferenceRules[preferenceIndex];
markProgress(context, ruleStage(rule));
@@ -4636,14 +4566,25 @@ function* calculatePlanningSteps(
}
markProgress(context, 'expiry');
yield true;
state = yield* optimizeRulePlacements(state, context, preferenceRules.length - 1, false);
state = yield* optimizeRulePlacements(
state,
context,
preferenceRules.length - 1,
false,
);
stages.push('expiry');
publishCheckpoint();
markProgress(context, 'dates');
yield true;
stages.push('dates');
const finalized = finalizeEarnedState(state, context);
if (!finalized) {
if (!finalized) continue;
state = finalized;
publishCheckpoint();
markProgress(context, 'complete');
yield true;
return finish(checkpoint!, state);
}
return finish({
kind: 'PlanningConflict',
conflictingRuleIds: state.earnedActivations.map((activation) => activation.ruleId),
@@ -4654,12 +4595,6 @@ function* calculatePlanningSteps(
),
],
});
}
state = finalized;
publishCheckpoint();
markProgress(context, 'complete');
yield true;
return finish(checkpoint!, state);
} catch (error) {
if (error instanceof UserCancelled && retained) {
// Freeze the chosen valid candidate, rather than exposing a temporary search state.