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:
1 parent
aa6a0c6f1b
commit
8168137cd5
13 files changed
+548
-247
No files matched your search
@@ -104,6 +104,9 @@ An optimization rule that minimizes unplaced at-risk leave.
|
|||||||
**Obligation rule**:
|
**Obligation rule**:
|
||||||
A planning rule that every valid plan must satisfy. Obligation rules are resolved jointly; their order only breaks ties between valid plans.
|
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**:
|
**Preference rule**:
|
||||||
A planning rule that guides placement but may remain unsatisfied to preserve validity or satisfy higher-priority preferences.
|
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 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.
|
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.
|
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.
|
||||||
@@ -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
|
stops appearing. Requests stay small because candidate search grows quickly
|
||||||
with horizon length and balances; the slowest case takes about one second.
|
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`,
|
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
|
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
|
`leave-planner.core.frozen.d.mts`. Rebuild it only for an intended behavior
|
||||||
|
|||||||
@@ -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
|
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
|
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.
|
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.
|
||||||
@@ -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.
|
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.
|
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
@@ -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
|
cannot improve. Per-iteration vectors are reused; preset statistics are materialized
|
||||||
only for selected incumbents and published plans.
|
only for selected incumbents and published plans.
|
||||||
|
|
||||||
For large non-earned window obligation searches above the existing exact-branch
|
For large non-earned obligation searches above the existing exact-branch
|
||||||
threshold, the joint feasibility search retains its existing limit. Feasible
|
threshold, the joint feasibility search retains its existing limit. When several
|
||||||
solutions are ranked by actual active objectives and final ties; the first eight
|
obligation solutions are found, they are ranked by the actual active objectives
|
||||||
receive full optimization previews. Other feasible solutions are recorded as
|
and final ties of their obligation-only plans, with enumeration order breaking
|
||||||
`not-examined`. Small searches and earned-entitlement chains keep all previews.
|
equal ranks. The preference stages run once, on the best-ranked solution; no
|
||||||
This deterministic sampling has no elapsed-time cutoff or user-facing setting.
|
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
|
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.
|
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
|
declared-adjacent, weekly and calendar-window destinations. Other destinations
|
||||||
retain `not-examined` trace records.
|
retain `not-examined` trace records.
|
||||||
- Non-earned obligation searches whose estimated branch count exceeds 10,000 retain
|
- 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
|
at most 4,096 feasible solutions. Every search ranks its solutions by the actual
|
||||||
active vector and final ties, then fully preview the first eight; the other
|
active vector and final ties of the obligation-only plans, and the preference
|
||||||
feasible solutions are marked `not-examined`. Earned chains and small searches
|
stages run on the best-ranked solution only. A lower-ranked solution that would
|
||||||
retain all previews.
|
end better after the preferences is not found.
|
||||||
- Calendar bundles rank original donors once by the actual active vector of the
|
- 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
|
source plan with each donor released, then retain that order during recursive
|
||||||
allocation. They keep the first prefix-valid allocation and collapse equivalent
|
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
|
plateau has no specialized bundle generator; the supplied sum/minimize loss
|
||||||
preset does consume both.
|
preset does consume both.
|
||||||
|
|
||||||
A completely enumerated single obligation solution has no alternative to rank, so
|
Earlier rules reuse their completed evaluation only when the complete
|
||||||
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
|
|
||||||
ordered incoming state matches their last output, including balance quantities and
|
ordered incoming state matches their last output, including balance quantities and
|
||||||
validity, earned activations, placement insertion order, supporting status and rule
|
validity, earned activations, placement insertion order, supporting status and rule
|
||||||
provenance. The trace records that unchanged state as `not-examined` with the earlier
|
provenance. The trace records that unchanged state as `not-examined` with the earlier
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { afterAll, describe, expect, it } from 'bun:test';
|
import { afterAll, describe, expect, it } from 'bun:test';
|
||||||
import { createOptimizationPresetRules } from './rule-service';
|
import { frenchPublicHolidaysForYear } from './declared-non-working-days';
|
||||||
import { frenchPublicHolidaysForYear, isWorkingPlanningDate } from './declared-non-working-days';
|
|
||||||
import { OPTIMIZATION_CATALOG } from './planning-objectives';
|
|
||||||
import {
|
import {
|
||||||
calculatePlanningAsyncInternal,
|
calculatePlanningAsyncInternal,
|
||||||
calculatePlanningInternal,
|
calculatePlanningInternal,
|
||||||
@@ -12,145 +10,20 @@ import {
|
|||||||
calculatePlanningAsyncInternal as frozenCalculateAsync,
|
calculatePlanningAsyncInternal as frozenCalculateAsync,
|
||||||
calculatePlanningInternal as frozenCalculate,
|
calculatePlanningInternal as frozenCalculate,
|
||||||
} from './frozen-engine/leave-planner.core.frozen.mjs';
|
} 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. */
|
/** Rebuild instructions for the frozen engine: docs/benchmark/README.md. */
|
||||||
const BASE_SEED = 280_000;
|
const BASE_SEED = 280_000;
|
||||||
const CASES = 5000;
|
const CASES = 5000;
|
||||||
const CHUNK = 200;
|
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 {
|
interface Run {
|
||||||
outcome: PlanningOutcome;
|
outcome: PlanningOutcome;
|
||||||
artifacts?: Pick<PlanningArtifacts, 'explanation' | 'finalState' | 'explanationError'>;
|
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({
|
expect.objectContaining({
|
||||||
date: '2024-05-10',
|
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,
|
supporting: true,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -558,13 +558,6 @@ type PlanningCalendar = Pick<
|
|||||||
| 'workingPrefix'
|
| 'workingPrefix'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
interface ObligationPreview {
|
|
||||||
obligationState: State;
|
|
||||||
state: State;
|
|
||||||
preferences: PreferenceResult[];
|
|
||||||
metrics: PlanningMetrics;
|
|
||||||
}
|
|
||||||
|
|
||||||
function now(): number {
|
function now(): number {
|
||||||
return typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();
|
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.
|
// A true value asks the asynchronous driver to pause immediately at a phase boundary.
|
||||||
type PlanningSteps<T> = Generator<boolean | undefined, T, void>;
|
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
|
// loaded year; replace with bounded branch-and-bound if mobile evidence shows
|
||||||
// that this ceiling misses a better lexicographic assignment.
|
// that this ceiling misses a better lexicographic assignment.
|
||||||
const OBLIGATION_SOLUTION_LIMIT = 4096;
|
const OBLIGATION_SOLUTION_LIMIT = 4096;
|
||||||
const EXACT_OBLIGATION_BRANCH_LIMIT = 10_000;
|
const EXACT_OBLIGATION_BRANCH_LIMIT = 10_000;
|
||||||
const WINDOW_OBLIGATION_PREVIEW_LIMIT = 8;
|
|
||||||
const VALID_TYPES = new Set<LeaveType>(['cp', 'rtt', 'other']);
|
const VALID_TYPES = new Set<LeaveType>(['cp', 'rtt', 'other']);
|
||||||
const VALID_SELECTORS = new Set<LeaveSelector>(['cp', 'rtt', 'other', 'any']);
|
const VALID_SELECTORS = new Set<LeaveSelector>(['cp', 'rtt', 'other', 'any']);
|
||||||
const VALID_MODES = new Set<PlacementMode>(['before', 'after', 'around', 'auto']);
|
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]);
|
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(
|
function* applyPreferenceStep(
|
||||||
state: State,
|
state: State,
|
||||||
preferenceRules: NormalizedRule[],
|
preferenceRules: NormalizedRule[],
|
||||||
@@ -4300,9 +4272,7 @@ function* calculatePlanningSteps(
|
|||||||
let checkpoint: PlanOutcome | undefined;
|
let checkpoint: PlanOutcome | undefined;
|
||||||
let checkpointState: State | undefined;
|
let checkpointState: State | undefined;
|
||||||
let retained:
|
let retained:
|
||||||
| (Pick<ObligationPreview, 'state' | 'preferences'> & {
|
| { state: State; preferences: PreferenceResult[]; vector: PlanningObjectiveVector }
|
||||||
vector: PlanningObjectiveVector;
|
|
||||||
})
|
|
||||||
| undefined;
|
| undefined;
|
||||||
if (retainIncumbents)
|
if (retainIncumbents)
|
||||||
context.retain = (candidate, candidateVector) => {
|
context.retain = (candidate, candidateVector) => {
|
||||||
@@ -4551,15 +4521,9 @@ function* calculatePlanningSteps(
|
|||||||
const preferenceRules = normalized.rules.filter(
|
const preferenceRules = normalized.rules.filter(
|
||||||
(item) => item.enabled && item.force === 'preference',
|
(item) => item.enabled && item.force === 'preference',
|
||||||
);
|
);
|
||||||
let previewSolutions = obligationSolutions;
|
let rankedSolutions = obligationSolutions;
|
||||||
if (
|
if (obligationSolutions.length > 1) {
|
||||||
obligationSolutionLimit > 0 &&
|
// Rank obligation-only plans; the preference stages run once, on the best-ranked.
|
||||||
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.
|
|
||||||
const ranked: { state: State; objectives: PlanningObjective[] }[] = [];
|
const ranked: { state: State; objectives: PlanningObjective[] }[] = [];
|
||||||
for (const candidate of obligationSolutions) {
|
for (const candidate of obligationSolutions) {
|
||||||
yield;
|
yield;
|
||||||
@@ -4569,57 +4533,23 @@ function* calculatePlanningSteps(
|
|||||||
objectives: obligationPreviewObjectives({ state: candidate }, context),
|
objectives: obligationPreviewObjectives({ state: candidate }, context),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// The sort is stable, so equal ranks keep enumeration order.
|
||||||
ranked.sort((a, b) => compareObjectiveLists(a.objectives, b.objectives));
|
ranked.sort((a, b) => compareObjectiveLists(a.objectives, b.objectives));
|
||||||
previewSolutions = ranked
|
rankedSolutions = ranked.map(({ state }) => state);
|
||||||
.slice(0, WINDOW_OBLIGATION_PREVIEW_LIMIT)
|
|
||||||
.map(({ state }) => state);
|
|
||||||
}
|
}
|
||||||
// A completed enumeration with one finalized solution has no alternative to rank.
|
for (const obligationSolution of rankedSolutions) {
|
||||||
const soleSolution =
|
// A solution whose earned leave comes too late restarts the stages on the next one.
|
||||||
obligationSolutions.length === 1 &&
|
state = obligationSolution;
|
||||||
(obligationSolutionLimit === 0 || obligationSolutions.length < obligationSolutionLimit)
|
stages.length = 1;
|
||||||
? obligationSolutions[0]
|
preferences.length = 0;
|
||||||
: undefined;
|
checkpointState = 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;
|
|
||||||
stages.push('obligations');
|
stages.push('obligations');
|
||||||
publishCheckpoint();
|
publishCheckpoint();
|
||||||
for (let preferenceIndex = 0; preferenceIndex < preferenceRules.length; preferenceIndex++) {
|
for (
|
||||||
|
let preferenceIndex = 0;
|
||||||
|
preferenceIndex < preferenceRules.length;
|
||||||
|
preferenceIndex++
|
||||||
|
) {
|
||||||
yield;
|
yield;
|
||||||
const rule = preferenceRules[preferenceIndex];
|
const rule = preferenceRules[preferenceIndex];
|
||||||
markProgress(context, ruleStage(rule));
|
markProgress(context, ruleStage(rule));
|
||||||
@@ -4636,14 +4566,25 @@ function* calculatePlanningSteps(
|
|||||||
}
|
}
|
||||||
markProgress(context, 'expiry');
|
markProgress(context, 'expiry');
|
||||||
yield true;
|
yield true;
|
||||||
state = yield* optimizeRulePlacements(state, context, preferenceRules.length - 1, false);
|
state = yield* optimizeRulePlacements(
|
||||||
|
state,
|
||||||
|
context,
|
||||||
|
preferenceRules.length - 1,
|
||||||
|
false,
|
||||||
|
);
|
||||||
stages.push('expiry');
|
stages.push('expiry');
|
||||||
publishCheckpoint();
|
publishCheckpoint();
|
||||||
markProgress(context, 'dates');
|
markProgress(context, 'dates');
|
||||||
yield true;
|
yield true;
|
||||||
stages.push('dates');
|
stages.push('dates');
|
||||||
const finalized = finalizeEarnedState(state, context);
|
const finalized = finalizeEarnedState(state, context);
|
||||||
if (!finalized) {
|
if (!finalized) continue;
|
||||||
|
state = finalized;
|
||||||
|
publishCheckpoint();
|
||||||
|
markProgress(context, 'complete');
|
||||||
|
yield true;
|
||||||
|
return finish(checkpoint!, state);
|
||||||
|
}
|
||||||
return finish({
|
return finish({
|
||||||
kind: 'PlanningConflict',
|
kind: 'PlanningConflict',
|
||||||
conflictingRuleIds: state.earnedActivations.map((activation) => activation.ruleId),
|
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) {
|
} catch (error) {
|
||||||
if (error instanceof UserCancelled && retained) {
|
if (error instanceof UserCancelled && retained) {
|
||||||
// Freeze the chosen valid candidate, rather than exposing a temporary search state.
|
// Freeze the chosen valid candidate, rather than exposing a temporary search state.
|
||||||
|
|||||||
Reference in new issue
Block a user