From 8168137cd56b6fcf75e9356d8d6d101470662db6 Mon Sep 17 00:00:00 2001 From: Nessar Date: Sun, 18 Oct 2026 21:17:53 +0200 Subject: [PATCH] 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 --- CONTEXT.md | 3 + ...constraint-first-lexicographic-planning.md | 2 + ...obligation-solutions-before-preferences.md | 9 + docs/benchmark/README.md | 6 + docs/benchmark/issue-302.md | 109 ++++++++++ docs/planner-cancellation.md | 2 +- docs/planning-objectives.md | 27 +-- src/app/services/frozen-engine.spec.ts | 145 +------------- .../services/frozen-engine/compare-engines.ts | 80 ++++++++ .../frozen-engine/random-planning-requests.ts | 179 +++++++++++++++++ ...leave-planner-obligation-solutions.spec.ts | 102 ++++++++++ ...eave-planner-supporting-placements.spec.ts | 4 +- src/app/services/leave-planner.core.ts | 189 ++++++------------ 13 files changed, 579 insertions(+), 278 deletions(-) create mode 100644 docs/adr/0010-rank-obligation-solutions-before-preferences.md create mode 100644 src/app/services/frozen-engine/compare-engines.ts create mode 100644 src/app/services/frozen-engine/random-planning-requests.ts create mode 100644 src/app/services/leave-planner-obligation-solutions.spec.ts diff --git a/CONTEXT.md b/CONTEXT.md index c1cce13..953db94 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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. diff --git a/docs/adr/0003-constraint-first-lexicographic-planning.md b/docs/adr/0003-constraint-first-lexicographic-planning.md index e475ecb..5a6cf35 100644 --- a/docs/adr/0003-constraint-first-lexicographic-planning.md +++ b/docs/adr/0003-constraint-first-lexicographic-planning.md @@ -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. diff --git a/docs/adr/0010-rank-obligation-solutions-before-preferences.md b/docs/adr/0010-rank-obligation-solutions-before-preferences.md new file mode 100644 index 0000000..4bf77c4 --- /dev/null +++ b/docs/adr/0010-rank-obligation-solutions-before-preferences.md @@ -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. diff --git a/docs/benchmark/README.md b/docs/benchmark/README.md index 8298532..499893b 100644 --- a/docs/benchmark/README.md +++ b/docs/benchmark/README.md @@ -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 diff --git a/docs/benchmark/issue-302.md b/docs/benchmark/issue-302.md index 9a0f880..c38a54b 100644 --- a/docs/benchmark/issue-302.md +++ b/docs/benchmark/issue-302.md @@ -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. diff --git a/docs/planner-cancellation.md b/docs/planner-cancellation.md index 813c171..1df8558 100644 --- a/docs/planner-cancellation.md +++ b/docs/planner-cancellation.md @@ -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. diff --git a/docs/planning-objectives.md b/docs/planning-objectives.md index b5911f6..6254d92 100644 --- a/docs/planning-objectives.md +++ b/docs/planning-objectives.md @@ -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 diff --git a/src/app/services/frozen-engine.spec.ts b/src/app/services/frozen-engine.spec.ts index a872711..ef29c70 100644 --- a/src/app/services/frozen-engine.spec.ts +++ b/src/app/services/frozen-engine.spec.ts @@ -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 & { rules: Record[] }; - -function generateRequest(seed: number): Req { - const rnd = mulberry32(seed); - const int = (min: number, max: number) => min + Math.floor(rnd() * (max - min + 1)); - const pick = (items: readonly T[]): T => items[int(0, items.length - 1)]; - const chance = (p: number) => rnd() < p; - const shuffle = (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[] = []; - 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[] = shuffle( - createOptimizationPresetRules() as unknown as Record[], - ).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; diff --git a/src/app/services/frozen-engine/compare-engines.ts b/src/app/services/frozen-engine/compare-engines.ts new file mode 100644 index 0000000..a1dd0bd --- /dev/null +++ b/src/app/services/frozen-engine/compare-engines.ts @@ -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['result']): readonly PlanningObjective[] { + return result.explanation?.kind === 'PlanExplanation' + ? (result.explanation.placements[0]?.objectiveVector.objectives ?? []) + : []; +} + +const counts = new Map(); +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`, +); diff --git a/src/app/services/frozen-engine/random-planning-requests.ts b/src/app/services/frozen-engine/random-planning-requests.ts new file mode 100644 index 0000000..d6e2489 --- /dev/null +++ b/src/app/services/frozen-engine/random-planning-requests.ts @@ -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 & { rules: Record[] }; + +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 = (items: readonly T[]): T => items[int(0, items.length - 1)]; + const chance = (p: number) => rnd() < p; + const shuffle = (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[] = []; + 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[] = shuffle( + createOptimizationPresetRules() as unknown as Record[], + ).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 = (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[]; + 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)], + }; +} diff --git a/src/app/services/leave-planner-obligation-solutions.spec.ts b/src/app/services/leave-planner-obligation-solutions.spec.ts new file mode 100644 index 0000000..427da39 --- /dev/null +++ b/src/app/services/leave-planner-obligation-solutions.spec.ts @@ -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']], + ]); + }); +}); diff --git a/src/app/services/leave-planner-supporting-placements.spec.ts b/src/app/services/leave-planner-supporting-placements.spec.ts index 1e87b94..e32d05f 100644 --- a/src/app/services/leave-planner-supporting-placements.spec.ts +++ b/src/app/services/leave-planner-supporting-placements.spec.ts @@ -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, }), ]); diff --git a/src/app/services/leave-planner.core.ts b/src/app/services/leave-planner.core.ts index f659554..bd430fd 100644 --- a/src/app/services/leave-planner.core.ts +++ b/src/app/services/leave-planner.core.ts @@ -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 = Generator; -// 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(['cp', 'rtt', 'other']); const VALID_SELECTORS = new Set(['cp', 'rtt', 'other', 'any']); const VALID_MODES = new Set(['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 & { - 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,97 +4533,68 @@ 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) { + 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'); - return finish(obligationConflict(obligationRequirements, context)); - } - state = bestPreview.obligationState; - stages.push('obligations'); - publishCheckpoint(); - for (let preferenceIndex = 0; preferenceIndex < preferenceRules.length; preferenceIndex++) { - yield; - const rule = preferenceRules[preferenceIndex]; - markProgress(context, ruleStage(rule)); - yield true; - state = yield* applyPreferenceStep( - state, - preferenceRules, - preferenceIndex, - preferences, - context, - ); - stages.push(ruleStage(rule)); publishCheckpoint(); + for ( + let preferenceIndex = 0; + preferenceIndex < preferenceRules.length; + preferenceIndex++ + ) { + yield; + const rule = preferenceRules[preferenceIndex]; + markProgress(context, ruleStage(rule)); + yield true; + state = yield* applyPreferenceStep( + state, + preferenceRules, + preferenceIndex, + preferences, + context, + ); + stages.push(ruleStage(rule)); + publishCheckpoint(); + } + markProgress(context, 'expiry'); + yield true; + 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) continue; + state = finalized; + publishCheckpoint(); + markProgress(context, 'complete'); + yield true; + return finish(checkpoint!, state); } - markProgress(context, 'expiry'); - yield true; - 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) { - return finish({ - kind: 'PlanningConflict', - conflictingRuleIds: state.earnedActivations.map((activation) => activation.ruleId), - reasons: [ - reason( - 'earned-entitlement-order', - 'Generated leave became available only after its final merged time-off period.', - ), - ], - }); - } - state = finalized; - publishCheckpoint(); - markProgress(context, 'complete'); - yield true; - return finish(checkpoint!, state); + return finish({ + kind: 'PlanningConflict', + conflictingRuleIds: state.earnedActivations.map((activation) => activation.ruleId), + reasons: [ + reason( + 'earned-entitlement-order', + 'Generated leave became available only after its final merged time-off period.', + ), + ], + }); } catch (error) { if (error instanceof UserCancelled && retained) { // Freeze the chosen valid candidate, rather than exposing a temporary search state.