From b35611726324c8457633262561083a882ffdb51c Mon Sep 17 00:00:00 2001
From: Nessar
Date: Sun, 18 Oct 2026 19:03:58 +0200
Subject: [PATCH] test(benchmark): keep the a871f8f engine as a fixed
calibration engine
The differential check's frozen bundle will be rebuilt for #302; the
relative gate keeps timing the unchanged a871f8f bundle so its ratios
stay comparable with the recorded history.
Co-Authored-By: Claude Opus 5.5
---
docs/benchmark/README.md | 8 +-
.../benchmark/leave-planner-benchmark.spec.ts | 4 +-
.../leave-planner.core.calibration.d.mts | 22 +
.../leave-planner.core.calibration.mjs | 3222 +++++++++++++++++
4 files changed, 3251 insertions(+), 5 deletions(-)
create mode 100644 src/app/services/frozen-engine/leave-planner.core.calibration.d.mts
create mode 100644 src/app/services/frozen-engine/leave-planner.core.calibration.mjs
diff --git a/docs/benchmark/README.md b/docs/benchmark/README.md
index a1e4d7b..8298532 100644
--- a/docs/benchmark/README.md
+++ b/docs/benchmark/README.md
@@ -18,9 +18,11 @@ The relative gate replaces the 300-second acceptance protocol named in
policy. This is an explicit policy change from #279/#285: runners differ in
speed, so a fixed wall-clock limit was flaky.
-Before the protocol, in the same process, the spec times the frozen engine
-(`src/app/services/frozen-engine/leave-planner.core.frozen.mjs`, the engine at
-a871f8f) on the `two years` scenario, best of three, and prints the result.
+Before the protocol, in the same process, the spec times the calibration engine
+(`src/app/services/frozen-engine/leave-planner.core.calibration.mjs`, the engine at
+a871f8f) on the `two years` scenario, best of three, and prints the result. This
+bundle never changes, so ratios stay comparable with the recorded history; the
+differential check's frozen bundle can be rebuilt independently.
Each scenario's budget is its multiplier times that calibration; the mean
`planningMs` of its two measured repetitions must not exceed it. A scenario
without a multiplier fails too. `#286` tightens the multipliers once the speed
diff --git a/src/app/benchmark/leave-planner-benchmark.spec.ts b/src/app/benchmark/leave-planner-benchmark.spec.ts
index c73dc43..3a05468 100644
--- a/src/app/benchmark/leave-planner-benchmark.spec.ts
+++ b/src/app/benchmark/leave-planner-benchmark.spec.ts
@@ -1,5 +1,5 @@
import { vi } from '../../testing/vitest-compat';
-import { calculatePlanningAsyncInternal } from '../services/frozen-engine/leave-planner.core.frozen.mjs';
+import { calculatePlanningAsyncInternal } from '../services/frozen-engine/leave-planner.core.calibration.mjs';
import { createPlanningCalculation } from '../services/leave-planner.service';
import {
BENCHMARK_BUDGET_MULTIPLIERS,
@@ -69,7 +69,7 @@ describe('leave planner benchmark protocol', () => {
),
).toHaveLength(2);
}
- // Frozen engine (a871f8f) on 'two years', best of three: the runner-speed yardstick.
+ // Calibration engine (frozen at a871f8f) on 'two years', best of three: the runner-speed yardstick.
const calibrationRequest = scenarios.find((item) => item.name === 'two years')!.request;
let calibrationMs = Infinity;
for (let run = 0; run < 3; run++) {
diff --git a/src/app/services/frozen-engine/leave-planner.core.calibration.d.mts b/src/app/services/frozen-engine/leave-planner.core.calibration.d.mts
new file mode 100644
index 0000000..b3cffd4
--- /dev/null
+++ b/src/app/services/frozen-engine/leave-planner.core.calibration.d.mts
@@ -0,0 +1,22 @@
+import type {
+ PlanningArtifactsCallback,
+ PlanningCheckpoint,
+ PlanningExplanationCallback,
+ PlanningOutcome,
+ PlanningOutcomeCallback,
+} from '../leave-planner.core';
+
+export function calculatePlanningInternal(
+ request: unknown,
+ checkpointCallback?: undefined,
+ artifactsCallback?: PlanningArtifactsCallback,
+): PlanningOutcome;
+
+export function calculatePlanningAsyncInternal(
+ request: unknown,
+ checkpointCallback?: PlanningCheckpoint,
+ artifactsCallback?: PlanningArtifactsCallback,
+ outcomeCallback?: PlanningOutcomeCallback,
+ explanationCallback?: PlanningExplanationCallback,
+ isCancelled?: () => boolean,
+): Promise;
diff --git a/src/app/services/frozen-engine/leave-planner.core.calibration.mjs b/src/app/services/frozen-engine/leave-planner.core.calibration.mjs
new file mode 100644
index 0000000..54ef602
--- /dev/null
+++ b/src/app/services/frozen-engine/leave-planner.core.calibration.mjs
@@ -0,0 +1,3222 @@
+// src/app/services/declared-non-working-days.ts
+import Holidays from "date-holidays";
+
+// src/app/services/planning-date.ts
+function planningDateInParis(value) {
+ const parts = new Intl.DateTimeFormat("en-GB", {
+ timeZone: "Europe/Paris",
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit"
+ }).formatToParts(value);
+ const get = (type) => parts.find((part) => part.type === type)?.value ?? "";
+ return `${get("year").padStart(4, "0")}-${get("month")}-${get("day")}`;
+}
+function addPlanningYears(key, years) {
+ const date = new Date(`${key}T00:00:00Z`);
+ const month = date.getUTCMonth();
+ date.setUTCFullYear(date.getUTCFullYear() + years);
+ if (date.getUTCMonth() !== month)
+ date.setUTCDate(0);
+ return date.toISOString().slice(0, 10);
+}
+function isPlanningDate(value) {
+ if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value))
+ return false;
+ const date = new Date(`${value}T00:00:00Z`);
+ return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
+}
+
+// src/app/services/declared-non-working-days.ts
+var holidaysByYear = new Map;
+function frenchPublicHolidaysForYear(year) {
+ let holidays = holidaysByYear.get(year);
+ if (!holidays) {
+ holidays = new Set(new Holidays("FR", { languages: ["en"] }).getHolidays(year, "en").filter((holiday) => holiday.type === "public").map((holiday) => String(holiday.date).slice(0, 10)));
+ holidaysByYear.set(year, holidays);
+ }
+ return holidays;
+}
+function isWorkingPlanningDate(value) {
+ if (!isPlanningDate(value))
+ return false;
+ const day = new Date(`${value}T00:00:00Z`).getUTCDay();
+ return day !== 0 && day !== 6 && !frenchPublicHolidaysForYear(Number(value.slice(0, 4))).has(value);
+}
+function parseDeclaredDays(value) {
+ if (!Array.isArray(value))
+ throw new Error("Declared non-working days must be a list.");
+ const dates = new Set;
+ return value.map((item) => {
+ if (typeof item !== "object" || item === null)
+ throw new Error("Each declared non-working day needs a date and leave type.");
+ const { date, type } = item;
+ if (!isWorkingPlanningDate(date) || dates.has(date))
+ throw new Error("Declared dates must be unique working days outside French public holidays.");
+ if (type !== "cp" && type !== "rtt" && type !== "other")
+ throw new Error("Declared non-working days must have a CP, RTT, or Other type.");
+ dates.add(date);
+ return { date, type };
+ });
+}
+
+// src/app/services/planning-objectives.ts
+var OPTIMIZATION_CATALOG = [
+ {
+ elements: "leave-balances",
+ measure: "unplaced-quantity",
+ conditions: ["at-risk"],
+ aggregations: ["sum", "count", "maximum"],
+ unit: "working-days"
+ },
+ {
+ elements: "public-holiday-weeks",
+ measure: "public-holiday-week-value",
+ conditions: ["complete"],
+ aggregations: ["sum", "count", "maximum"],
+ unit: "calendar-days"
+ },
+ {
+ elements: "time-off-periods",
+ measure: "non-working-day-gain",
+ conditions: [],
+ aggregations: ["sum", "count", "maximum"],
+ unit: "calendar-days"
+ },
+ {
+ elements: "work-sequences",
+ measure: "work-sequence-length",
+ conditions: ["internal", "non-empty"],
+ aggregations: ["sum", "count", "maximum"],
+ unit: "working-days"
+ },
+ {
+ elements: "work-sequences",
+ measure: "work-sequence-deficit",
+ conditions: ["internal", "non-empty"],
+ aggregations: ["sum", "count", "maximum"],
+ unit: "working-days"
+ },
+ {
+ elements: "leave-placements",
+ measure: "placement-count",
+ conditions: ["supporting"],
+ aggregations: ["sum", "count", "maximum"],
+ unit: "count"
+ }
+];
+function validateOptimizationConfiguration(value) {
+ const invalid = (message) => ({
+ valid: false,
+ code: "invalid-optimization-configuration",
+ message
+ });
+ const unsupported = (message) => ({
+ valid: false,
+ code: "unsupported-optimization-configuration",
+ message
+ });
+ if (!value || typeof value !== "object" || Array.isArray(value))
+ return invalid("An optimization configuration must be an object.");
+ const config = value;
+ const fields = [
+ "elements",
+ "conditions",
+ "measure",
+ "aggregation",
+ "direction",
+ "thresholdDays"
+ ];
+ if (Object.keys(config).some((key) => !fields.includes(key)))
+ return unsupported("The optimization configuration contains an unsupported field.");
+ if (config["direction"] !== "minimize" && config["direction"] !== "maximize")
+ return invalid("The optimization direction must be minimize or maximize.");
+ if (!Array.isArray(config["conditions"]) || [...config["conditions"]].some((condition) => typeof condition !== "string") || new Set(config["conditions"]).size !== config["conditions"].length)
+ return invalid("Optimization conditions must be a list of distinct conditions.");
+ const row = OPTIMIZATION_CATALOG.find((item) => item.elements === config["elements"] && item.measure === config["measure"]);
+ if (!row)
+ return unsupported("The planning elements and measure are not compatible.");
+ const conditions = row.conditions;
+ if (config["conditions"].some((condition) => !conditions.includes(condition)))
+ return unsupported("A condition is not supported for these planning elements.");
+ const aggregations = row.aggregations;
+ if (typeof config["aggregation"] !== "string" || !aggregations.includes(config["aggregation"]))
+ return unsupported("The aggregation is not supported for this measure.");
+ if (config["measure"] === "work-sequence-deficit") {
+ if (!Number.isSafeInteger(config["thresholdDays"]) || Number(config["thresholdDays"]) <= 0)
+ return invalid("The deficit threshold must be a positive integer in working days.");
+ } else if ("thresholdDays" in config)
+ return unsupported("Only the work-sequence deficit accepts a working-day threshold.");
+ return { valid: true, configuration: value };
+}
+var OPTIMIZATION_PRESETS = {
+ unplacedAtRisk: {
+ elements: "leave-balances",
+ conditions: ["at-risk"],
+ measure: "unplaced-quantity",
+ aggregation: "sum",
+ direction: "minimize"
+ },
+ holidayWeekValue: {
+ elements: "public-holiday-weeks",
+ conditions: ["complete"],
+ measure: "public-holiday-week-value",
+ aggregation: "sum",
+ direction: "maximize"
+ },
+ nonWorkingDayGain: {
+ elements: "time-off-periods",
+ conditions: [],
+ measure: "non-working-day-gain",
+ aggregation: "sum",
+ direction: "maximize"
+ },
+ longestWorkSequence: {
+ elements: "work-sequences",
+ conditions: [],
+ measure: "work-sequence-length",
+ aggregation: "maximum",
+ direction: "minimize"
+ },
+ shortSequenceDeficit: {
+ elements: "work-sequences",
+ conditions: ["internal", "non-empty"],
+ measure: "work-sequence-deficit",
+ thresholdDays: 5,
+ aggregation: "sum",
+ direction: "minimize"
+ },
+ supportingPlacements: {
+ elements: "leave-placements",
+ conditions: ["supporting"],
+ measure: "placement-count",
+ aggregation: "count",
+ direction: "minimize"
+ }
+};
+var nonWorkingDateCounts = new WeakMap;
+function evaluateOptimization(configuration, observations) {
+ const validation = validateOptimizationConfiguration(configuration);
+ if (!validation.valid)
+ throw new Error(`${validation.code}: ${validation.message}`);
+ const selected = (condition) => configuration.conditions.includes(condition);
+ let values;
+ switch (configuration.elements) {
+ case "leave-balances":
+ values = observations.leaveBalances.filter((balance) => !selected("at-risk") || balance.atRisk).map((balance) => balance.unplacedQuantity);
+ break;
+ case "public-holiday-weeks":
+ values = observations.publicHolidayWeeks.filter((week) => !selected("complete") || week.complete).map((week) => week.value);
+ break;
+ case "time-off-periods":
+ values = observations.timeOffPeriods.map(({ nonWorkingDates }) => {
+ const cached = nonWorkingDateCounts.get(nonWorkingDates);
+ if (cached !== undefined)
+ return cached;
+ const count = new Set(nonWorkingDates).size;
+ if (Object.isFrozen(nonWorkingDates))
+ nonWorkingDateCounts.set(nonWorkingDates, count);
+ return count;
+ });
+ break;
+ case "work-sequences":
+ values = observations.workSequences.filter((sequence) => (!selected("internal") || sequence.internal) && (!selected("non-empty") || sequence.length > 0)).map((sequence) => configuration.measure === "work-sequence-deficit" ? sequence.length > 0 ? Math.max(0, configuration.thresholdDays - sequence.length) : 0 : sequence.length);
+ break;
+ case "leave-placements":
+ values = observations.leavePlacements.filter((placement) => !selected("supporting") || placement.supporting).map(() => 1);
+ }
+ switch (configuration.aggregation) {
+ case "count":
+ return values.length;
+ case "sum":
+ return values.reduce((total, value) => total + value, 0);
+ case "maximum":
+ return values.reduce((maximum, value) => Math.max(maximum, value), 0);
+ }
+}
+function compareObjectiveValues(direction, left, right) {
+ if (left === right)
+ return 0;
+ const difference = typeof left === "number" && typeof right === "number" ? left - right : left < right ? -1 : left > right ? 1 : 0;
+ return direction === "minimize" ? difference : -difference;
+}
+function firstDifferentObjective(left, right) {
+ return left.findIndex((objective, index) => objective.value !== right[index]?.value);
+}
+function compareObjectiveLists(left, right) {
+ const index = firstDifferentObjective(left, right);
+ return index < 0 ? 0 : compareObjectiveValues(left[index].direction, left[index].value, right[index].value);
+}
+
+// src/app/services/leave-planner.core.ts
+var obligationRequirementCache = new WeakMap;
+var occurrenceCache = new WeakMap;
+var calendarPatternCache = new WeakMap;
+var preferencePatternCache = new WeakMap;
+var declaredWindowCache = new WeakMap;
+var calendarObservationCache = new WeakMap;
+var calendarPeriodCache = new WeakMap;
+function now() {
+ return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
+}
+function observeCallback(context, callback) {
+ const started = now();
+ try {
+ callback();
+ } finally {
+ context.observerTime += now() - started;
+ }
+}
+
+class DeadlineReached extends Error {
+}
+
+class UserCancelled extends Error {
+}
+var DAY_MS = 86400000;
+var OBLIGATION_SOLUTION_LIMIT = 4096;
+var EXACT_OBLIGATION_BRANCH_LIMIT = 1e4;
+var WINDOW_OBLIGATION_PREVIEW_LIMIT = 8;
+var VALID_TYPES = new Set(["cp", "rtt", "other"]);
+var VALID_SELECTORS = new Set(["cp", "rtt", "other", "any"]);
+var VALID_MODES = new Set(["before", "after", "around", "auto"]);
+function dateKey(value) {
+ if (typeof value === "string") {
+ const dateOnly = value.match(/^(\d{4}-\d{2}-\d{2})(?:$|\s|T)/)?.[1];
+ if (dateOnly)
+ return isPlanningDate(dateOnly) ? dateOnly : null;
+ const parsed = new Date(value);
+ return Number.isNaN(parsed.getTime()) ? null : planningDateInParis(parsed);
+ }
+ if (value instanceof Date && !Number.isNaN(value.getTime()))
+ return planningDateInParis(value);
+ return null;
+}
+function runtimeRequest(value, calculationDate) {
+ const source = recordValue(value) ?? {};
+ return {
+ calculationDate: calculationDate ?? (isPlanningDate(source.calculationDate) ? source.calculationDate : planningDateInParis(new Date)),
+ horizon: source.horizon,
+ balances: source.balances,
+ declaredDays: source.declaredDays,
+ rules: source.rules,
+ identities: source.identities,
+ labels: source.labels,
+ deadlineMs: source.deadlineMs,
+ progress: source.progress
+ };
+}
+function recordValue(value) {
+ return typeof value === "object" && value !== null ? value : undefined;
+}
+function utcDate(key) {
+ return new Date(`${key}T00:00:00Z`);
+}
+function addDays(key, days) {
+ return new Date(utcDate(key).getTime() + days * DAY_MS).toISOString().slice(0, 10);
+}
+function compareDate(a, b) {
+ return a < b ? -1 : a > b ? 1 : 0;
+}
+function compareText(a, b) {
+ return a < b ? -1 : a > b ? 1 : 0;
+}
+function dateRange(start, endExclusive) {
+ const result = [];
+ for (let date = start;compareDate(date, endExclusive) < 0; date = addDays(date, 1))
+ result.push(date);
+ return result;
+}
+function defaultHorizon(start) {
+ return { start, end: addPlanningYears(start, 1) };
+}
+function holidaySet(horizon) {
+ const first = Number(addDays(horizon.start, -400).slice(0, 4));
+ const last = Number(addDays(horizon.end, 400).slice(0, 4));
+ const result = new Set;
+ for (let year = first;year <= last; year++) {
+ for (const holiday of frenchPublicHolidaysForYear(year))
+ result.add(holiday);
+ }
+ return result;
+}
+function isWeekend(key) {
+ const day = utcDate(key).getUTCDay();
+ return day === 0 || day === 6;
+}
+function isWorking(key, holidays) {
+ return !isWeekend(key) && !holidays.has(key);
+}
+function holidayWeekCandidates(context) {
+ const candidates = new Map;
+ const inHorizon = (date) => date >= context.horizon.start && date < context.horizon.end;
+ for (const holiday of [...context.holidays].sort(compareDate)) {
+ const weekday = utcDate(holiday).getUTCDay();
+ if (weekday < 1 || weekday > 5)
+ continue;
+ const start = addDays(holiday, 1 - weekday);
+ const weekdays = dateRange(start, addDays(start, 5));
+ const dates = weekdays.filter((date) => isWorking(date, context.holidays));
+ if (!dates.length || !dates.some(inHorizon) || !inHorizon(holiday) && !dates.some((date) => context.declaredDates.has(date)) || dates.some((date) => !inHorizon(date) && !context.declaredDates.has(date)))
+ continue;
+ const value = weekdays.reduce((sum, date, index) => sum + (context.holidays.has(date) ? Math.min(index + 1, 5 - index) : 0), 0);
+ candidates.set(start, { start, dates, value });
+ }
+ return [...candidates.values()].sort((a, b) => b.value - a.value || compareDate(a.start, b.start));
+}
+function reason(code, message, ruleIds) {
+ return ruleIds?.length ? { code, message, ruleIds } : { code, message };
+}
+function cloneState(state, placements) {
+ return {
+ balances: state.balances.map((balance) => ({ ...balance })),
+ placements: placements ?? new Map([...state.placements.entries()].map(([date, placement]) => [
+ date,
+ {
+ ...placement,
+ ruleIds: new Set(placement.ruleIds)
+ }
+ ])),
+ earnedActivations: state.earnedActivations.map((activation) => ({
+ ...activation,
+ dates: [...activation.dates]
+ }))
+ };
+}
+function planningStateSnapshot(state, context) {
+ return {
+ calculationDate: context.request.calculationDate,
+ horizon: context.horizon,
+ ...context.declaredDays.length ? { declaredDays: context.declaredDays } : {},
+ balances: state.balances.map((balance) => ({ ...balance })),
+ placements: [...state.placements.values()].sort((a, b) => compareDate(a.date, b.date) || compareText(a.balanceId, b.balanceId)).map((placement) => ({
+ ...placement,
+ ruleIds: [...placement.ruleIds].sort()
+ })),
+ earnedActivations: state.earnedActivations.map((activation) => ({
+ ...activation,
+ dates: [...activation.dates]
+ }))
+ };
+}
+function normalizeType(value) {
+ const normalized = String(value ?? "").toLowerCase();
+ return VALID_TYPES.has(normalized) ? normalized : null;
+}
+function normalizeSelector(value) {
+ const normalized = String(value ?? "").toLowerCase();
+ return VALID_SELECTORS.has(normalized) ? normalized : null;
+}
+function normalizeForce(value) {
+ if (value === undefined || value === "")
+ return "preference";
+ const normalized = String(value).toLowerCase();
+ if (normalized === "obligation" || normalized === "preference")
+ return normalized;
+ return null;
+}
+function normalizeRuleType(value) {
+ const normalized = String(value ?? "").toLowerCase();
+ return normalized === "time_window" || normalized === "if_then" || normalized === "optimization" ? normalized : null;
+}
+function normalizeMode(value) {
+ const normalized = String(value ?? "").toLowerCase();
+ return VALID_MODES.has(normalized) ? normalized : null;
+}
+function compareDateLists(a, b) {
+ for (let index = 0;index < Math.min(a.length, b.length); index++) {
+ const compared = compareDate(a[index], b[index]);
+ if (compared)
+ return compared;
+ }
+ return a.length - b.length;
+}
+function asInteger(value) {
+ return typeof value === "number" && Number.isInteger(value) ? value : null;
+}
+function normalizeBalances(request, horizon) {
+ const rawInput = request.balances;
+ const reasons = [];
+ if (!Array.isArray(rawInput)) {
+ return {
+ balances: [],
+ reasons: [reason("balances", "Leave balances must be an array.")]
+ };
+ }
+ const raw = rawInput;
+ const ids = new Set;
+ const balances = [];
+ for (const input of raw) {
+ const value = recordValue(input);
+ const id = typeof value?.id === "string" ? value.id : "";
+ const type = normalizeType(value?.type);
+ const quantity = asInteger(value?.quantity);
+ const rawAvailableFrom = value?.availableFrom;
+ const availableFrom = dateKey(rawAvailableFrom) ?? horizon.start;
+ const expiryDate = dateKey(value?.expiryDate);
+ if (!id)
+ reasons.push(reason("balance-id", "Each leave balance needs a stable identity."));
+ else if (ids.has(id))
+ reasons.push(reason("duplicate-balance-id", `Leave balance id "${id}" is duplicated.`));
+ else
+ ids.add(id);
+ if (!type)
+ reasons.push(reason("balance-type", `Leave balance "${id}" has an invalid leave type.`));
+ if (quantity === null || quantity < 0) {
+ reasons.push(reason("balance-quantity", `Leave balance "${id}" needs a non-negative integer quantity.`));
+ }
+ if (!expiryDate)
+ reasons.push(reason("balance-expiry", `Leave balance "${id}" needs a valid expiry date.`));
+ if (rawAvailableFrom !== undefined && !dateKey(rawAvailableFrom))
+ reasons.push(reason("balance-availability", `Leave balance "${id}" needs a valid availability date.`));
+ if (expiryDate && compareDate(availableFrom, expiryDate) > 0 && rawAvailableFrom !== undefined) {
+ reasons.push(reason("balance-dates", `Leave balance "${id}" must be available on or before expiry.`));
+ }
+ if (type && quantity !== null && quantity >= 0 && expiryDate) {
+ balances.push({
+ id,
+ type,
+ quantity,
+ availableFrom,
+ expiryDate,
+ earned: false
+ });
+ }
+ }
+ return { balances, reasons };
+}
+function normalizeRules(request) {
+ const rawInput = request.rules;
+ const reasons = [];
+ if (!Array.isArray(rawInput)) {
+ return { rules: [], reasons: [reason("rules", "Planning rules must be an array.")] };
+ }
+ const raw = rawInput;
+ const ids = new Set;
+ const rules = [];
+ for (const input of raw) {
+ const value = recordValue(input);
+ if (value?.enabled === false)
+ continue;
+ const id = typeof value?.id === "string" ? value.id : "";
+ const type = normalizeRuleType(value?.type);
+ const forceValue = normalizeForce(value?.force);
+ const force = forceValue ?? "preference";
+ if (!id)
+ reasons.push(reason("rule-id", "Each planning rule needs a stable identity."));
+ else if (ids.has(id))
+ reasons.push(reason("duplicate-rule-id", `Planning rule id "${id}" is duplicated.`));
+ else
+ ids.add(id);
+ if (!forceValue)
+ reasons.push(reason("rule-force", `Planning rule "${id}" has an invalid force.`));
+ if (!type) {
+ reasons.push(reason("rule-type", `Planning rule "${id}" has an unsupported type.`));
+ continue;
+ }
+ if (type === "optimization") {
+ const validated = validateOptimizationConfiguration(value?.configuration);
+ if (forceValue !== "preference")
+ reasons.push(reason("rule-force", "Optimization rules support preference mode only.", [id]));
+ if (!validated.valid) {
+ reasons.push(reason(validated.code, validated.message, [id]));
+ continue;
+ }
+ rules.push({
+ id,
+ name: typeof value?.name === "string" ? value.name : id,
+ type,
+ enabled: true,
+ force: "preference",
+ configuration: structuredClone(validated.configuration)
+ });
+ continue;
+ }
+ const placeDays = asInteger(value?.placeDays);
+ const placeType = normalizeSelector(value?.placeType);
+ if (placeDays === null || placeDays < 0) {
+ reasons.push(reason("rule-quantity", `Planning rule "${id}" needs a non-negative integer quantity.`));
+ continue;
+ }
+ if (!placeType)
+ reasons.push(reason("rule-selector", `Planning rule "${id}" has an invalid leave selector.`));
+ const base = {
+ id,
+ name: typeof value?.name === "string" ? value.name : id,
+ type,
+ enabled: value?.enabled !== false,
+ force,
+ placeDays,
+ placeType: placeType ?? "any"
+ };
+ if (type === "time_window") {
+ const startDate = dateKey(value?.startDate);
+ const endDate = dateKey(value?.endDate);
+ if (!startDate || !endDate || compareDate(startDate, endDate) > 0) {
+ reasons.push(reason("rule-window", `TIME_WINDOW rule "${id}" needs an inclusive, ordered date window.`));
+ continue;
+ }
+ rules.push({ ...base, startDate, endDate, type: "time_window" });
+ continue;
+ }
+ const dayOfWeek = asInteger(value?.dayOfWeek);
+ const mode = normalizeMode(value?.placement);
+ const rewardDays = asInteger(value?.rewardDays);
+ const rawRewardType = value?.rewardType;
+ const rewardType = normalizeType(rawRewardType);
+ if (dayOfWeek === null || dayOfWeek < 0 || dayOfWeek > 6) {
+ reasons.push(reason("rule-weekday", `IF_THEN rule "${id}" needs a weekday from 0 to 6.`));
+ }
+ if (!mode)
+ reasons.push(reason("rule-mode", `IF_THEN rule "${id}" has an unsupported placement mode.`));
+ if (rewardDays === null || rewardDays < 0) {
+ reasons.push(reason("earned-quantity", `IF_THEN rule "${id}" needs a non-negative earned quantity.`));
+ }
+ if ((rewardDays ?? 0) > 0 && !rewardType) {
+ reasons.push(reason("earned-type", `IF_THEN rule "${id}" needs CP, RTT, or Other as its earned type.`));
+ } else if (rawRewardType !== undefined && !rewardType) {
+ reasons.push(reason("earned-type", `IF_THEN rule "${id}" needs CP, RTT, or Other as its earned type.`));
+ }
+ if (placeType && mode && dayOfWeek !== null && rewardDays !== null && (rewardDays === 0 ? rawRewardType === undefined || rewardType : rewardType)) {
+ rules.push({
+ ...base,
+ type: "if_then",
+ dayOfWeek,
+ placement: mode,
+ rewardDays,
+ rewardType: rewardType ?? undefined
+ });
+ }
+ }
+ return { rules, reasons };
+}
+function normalizeRequest(request) {
+ const source = runtimeRequest(request);
+ const rawHorizon = recordValue(source.horizon);
+ const fallbackHorizon = defaultHorizon(source.calculationDate);
+ const start = dateKey(rawHorizon?.start) ?? fallbackHorizon.start;
+ const end = dateKey(rawHorizon?.end) ?? fallbackHorizon.end;
+ const reasons = [];
+ if (!rawHorizon) {
+ reasons.push(reason("horizon", "The planning horizon is required."));
+ } else if (!dateKey(rawHorizon.start)) {
+ reasons.push(reason("horizon-start", "The planning horizon start must be a valid calendar date."));
+ }
+ if (rawHorizon && !dateKey(rawHorizon.end)) {
+ reasons.push(reason("horizon-end", "The planning horizon end must be a valid calendar date."));
+ }
+ if (rawHorizon && compareDate(start, end) >= 0) {
+ reasons.push(reason("horizon-order", "The planning horizon must have start before end."));
+ }
+ const configuredDeadline = source.deadlineMs;
+ if (configuredDeadline !== undefined && (typeof configuredDeadline !== "number" || !Number.isFinite(configuredDeadline) || configuredDeadline < 0)) {
+ reasons.push(reason("deadline", "The planning deadline must be a finite non-negative number."));
+ }
+ let declaredDays = [];
+ try {
+ declaredDays = parseDeclaredDays(source.declaredDays === undefined ? [] : source.declaredDays);
+ } catch (error) {
+ reasons.push(reason("declared-days", error instanceof Error ? error.message : String(error)));
+ }
+ const balanceResult = normalizeBalances(source, { start, end });
+ const ruleResult = normalizeRules(source);
+ reasons.push(...balanceResult.reasons, ...ruleResult.reasons);
+ return {
+ horizon: reasons.some((item) => item.code === "horizon" || item.code.startsWith("horizon-")) ? undefined : { start, end },
+ balances: balanceResult.balances,
+ declaredDays,
+ rules: ruleResult.rules,
+ reasons
+ };
+}
+function invalidInputFor(request) {
+ const normalized = normalizeRequest(request);
+ if (!normalized.reasons.length)
+ return;
+ return {
+ kind: "InvalidInput",
+ reasons: normalized.reasons
+ };
+}
+function futurePreferenceProtection(rules, startIndex, context) {
+ const protectedTypes = new Map;
+ context.preferenceDemandByDate = new Map;
+ context.anyPreferenceDemandByDate = new Map;
+ const futureRules = rules.slice(startIndex);
+ for (const [offset, rule] of futureRules.entries()) {
+ if (!rule.enabled || rule.force !== "preference" || rule.type === "optimization")
+ continue;
+ if (rule.type === "time_window" && declaredWindowDays(rule, context).length >= rule.placeDays)
+ continue;
+ const priorityWeight = futureRules.length - offset;
+ const dates = rule.type === "time_window" ? context.workingDays.filter((date) => !context.declaredDates.has(date) && compareDate(date, rule.startDate) >= 0 && compareDate(date, rule.endDate) <= 0) : occurrences(rule, context).flatMap((occurrence) => {
+ const patterns = patternsFor(rule, occurrence, context);
+ return patterns.some((pattern) => pattern.every((date) => declaredPatternDay(date, rule.placeType, context))) ? [] : patterns.flat().filter((date) => !context.declaredDates.has(date));
+ });
+ for (const date of dates) {
+ context.preferenceDemandByDate.set(date, (context.preferenceDemandByDate.get(date) ?? 0) + priorityWeight);
+ if (rule.placeType === "any") {
+ context.anyPreferenceDemandByDate.set(date, (context.anyPreferenceDemandByDate.get(date) ?? 0) + priorityWeight);
+ continue;
+ }
+ const types = protectedTypes.get(date) ?? new Set;
+ types.add(rule.placeType);
+ protectedTypes.set(date, types);
+ }
+ }
+ return protectedTypes;
+}
+function occurrences(rule, context) {
+ if (rule.type !== "if_then")
+ return [];
+ let cached = occurrenceCache.get(context);
+ if (!cached) {
+ cached = new Map;
+ occurrenceCache.set(context, cached);
+ }
+ let dates = cached.get(rule.id);
+ if (!dates) {
+ dates = [...context.holidays].filter((date) => date >= context.horizon.start && date < context.horizon.end && utcDate(date).getUTCDay() === rule.dayOfWeek).sort(compareDate);
+ cached.set(rule.id, dates);
+ }
+ return dates;
+}
+function workingNeighbors(date, direction, count, context) {
+ const result = [];
+ for (let current = addDays(date, direction);result.length < count; current = addDays(current, direction)) {
+ context.check?.();
+ if (isWorking(current, context.holidays))
+ result.push(current);
+ if (compareDate(current, addDays(context.horizon.start, -400)) < 0 || compareDate(current, addDays(context.horizon.end, 400)) > 0)
+ break;
+ }
+ return result;
+}
+function patternsFor(rule, holiday, context) {
+ let cached = calendarPatternCache.get(context);
+ if (!cached) {
+ cached = new Map;
+ calendarPatternCache.set(context, cached);
+ }
+ const key = `${rule.id}:${holiday}`;
+ const existing = cached.get(key);
+ if (existing)
+ return existing;
+ const count = rule.placeDays;
+ const make = (before, after) => [
+ ...workingNeighbors(holiday, -1, before, context),
+ ...workingNeighbors(holiday, 1, after, context).reverse()
+ ].sort(compareDate);
+ const patterns = [];
+ if (rule.placement === "before")
+ patterns.push(make(count, 0));
+ else if (rule.placement === "after")
+ patterns.push(make(0, count));
+ else if (rule.placement === "around") {
+ for (let before = 1;before < count; before++)
+ patterns.push(make(before, count - before));
+ } else {
+ for (let before = 0;before <= count; before++)
+ patterns.push(make(before, count - before));
+ }
+ const eligible = patterns.filter((pattern) => pattern.length === count && pattern.every((date) => compareDate(date, context.horizon.start) >= 0 && compareDate(date, context.horizon.end) < 0)).sort(compareDateLists);
+ cached.set(key, eligible);
+ return eligible;
+}
+function buildRequirements(rules, context, force) {
+ if (force === "obligation" && rules === context.rules) {
+ const cached = obligationRequirementCache.get(context);
+ if (cached)
+ return cached;
+ }
+ const requirements = [];
+ let index = 0;
+ for (const rule of rules) {
+ if (!rule.enabled || rule.force !== force || rule.type === "optimization")
+ continue;
+ if (rule.type === "time_window") {
+ const start = compareDate(rule.startDate, context.horizon.start) < 0 ? context.horizon.start : rule.startDate;
+ const end = compareDate(rule.endDate, context.horizon.end) >= 0 ? addDays(context.horizon.end, -1) : rule.endDate;
+ const candidates = compareDate(start, end) <= 0 ? context.workingDays.filter((date) => !context.declaredDates.has(date) && compareDate(date, start) >= 0 && compareDate(date, end) <= 0) : [];
+ const orderedCandidates = candidates.sort((a, b) => preferenceDateConflictScore(rule.placeType, a, context) - preferenceDateConflictScore(rule.placeType, b, context) || compareDate(a, b));
+ requirements.push({
+ rule,
+ requiredPlacements: rule.placeDays - declaredWindowDays(rule, context).length,
+ anchor: start,
+ index: index++,
+ candidates: orderedCandidates
+ });
+ } else {
+ for (const occurrence of occurrences(rule, context)) {
+ const patterns = patternsFor(rule, occurrence, context).sort((a, b) => patternConflictScore(rule.placeType, a, context) - patternConflictScore(rule.placeType, b, context) || compareDateLists(a, b));
+ requirements.push({
+ rule,
+ requiredPlacements: Math.min(rule.placeDays, ...patterns.map((pattern) => pattern.filter((date) => !declaredPatternDay(date, rule.placeType, context)).length)),
+ anchor: occurrence,
+ occurrenceDate: occurrence,
+ index: index++,
+ patterns
+ });
+ }
+ }
+ }
+ const ordered = requirements.sort(compareRequirements);
+ if (force === "obligation" && rules === context.rules)
+ obligationRequirementCache.set(context, ordered);
+ return ordered;
+}
+function compareRequirements(a, b) {
+ return Number(a.rule.type !== "if_then") - Number(b.rule.type !== "if_then") || compareDate(a.anchor, b.anchor) || a.index - b.index;
+}
+function obligationBranchEstimate(requirements) {
+ let estimate = 1;
+ for (const requirement of requirements) {
+ let branches = requirement.patterns?.length ?? 1;
+ if (!requirement.patterns) {
+ const candidates = requirement.candidates?.length ?? 0;
+ const needed = requirement.requiredPlacements;
+ branches = 1;
+ for (let index = 1;index <= needed; index++) {
+ if (index > candidates) {
+ branches = 1;
+ break;
+ }
+ branches = Math.min(EXACT_OBLIGATION_BRANCH_LIMIT + 1, branches * (candidates - needed + index) / index);
+ }
+ }
+ estimate = Math.min(EXACT_OBLIGATION_BRANCH_LIMIT + 1, estimate * branches);
+ if (estimate > EXACT_OBLIGATION_BRANCH_LIMIT)
+ return estimate;
+ }
+ return estimate;
+}
+function selectorMatches(selector, type) {
+ return selector === "any" || selector === type;
+}
+function declaredWindowDays(rule, context) {
+ if (rule.type !== "time_window" || !context.declaredDays.length)
+ return [];
+ let byRule = declaredWindowCache.get(context);
+ if (!byRule) {
+ byRule = new Map;
+ declaredWindowCache.set(context, byRule);
+ }
+ let days = byRule.get(rule.id);
+ if (!days) {
+ days = context.declaredDays.filter((day) => selectorMatches(rule.placeType, day.type) && day.date >= context.horizon.start && day.date < context.horizon.end && day.date >= rule.startDate && day.date <= rule.endDate).sort((a, b) => compareDate(a.date, b.date)).slice(0, rule.placeDays);
+ byRule.set(rule.id, days);
+ }
+ return days;
+}
+function declaredPatternDay(date, selector, context) {
+ if (!context.declaredDates.has(date))
+ return;
+ return context.declaredDays.find((day) => day.date === date && selectorMatches(selector, day.type));
+}
+function patternDateSatisfied(date, rule, state, context) {
+ return Boolean(declaredPatternDay(date, rule.placeType, context) || state.placements.has(date) && selectorMatches(rule.placeType, state.placements.get(date).type));
+}
+function selectedOccurrencePattern(rule, occurrence, state, context) {
+ const activation = state.earnedActivations.find((item) => item.ruleId === rule.id && item.occurrence === occurrence);
+ return activation?.dates.every((date) => patternDateSatisfied(date, rule, state, context)) ? activation.dates : patternsFor(rule, occurrence, context).find((pattern) => pattern.every((date) => patternDateSatisfied(date, rule, state, context)));
+}
+function assignDateVariants(state, date, selector, ruleId, context) {
+ context.check();
+ if (context.declaredDates.has(date))
+ return [];
+ const existing = state.placements.get(date);
+ if (existing) {
+ if (!selectorMatches(selector, existing.type)) {
+ return [];
+ }
+ if (ruleId) {
+ const rule = context.rules.find((item) => item.id === ruleId);
+ if (rule?.type === "time_window" && (compareDate(date, rule.startDate) < 0 || compareDate(date, rule.endDate) > 0)) {
+ return [];
+ }
+ }
+ const result = cloneState(state);
+ if (ruleId)
+ result.placements.get(date).ruleIds.add(ruleId);
+ return [result];
+ }
+ const balances = state.balances.filter((balance) => balance.remaining > 0 && selectorMatches(selector, balance.type) && compareDate(balance.availableFrom, date) <= 0 && compareDate(date, balance.expiryDate) <= 0).sort((a, b) => {
+ if (selector === "any") {
+ const sharedTypes = context.sharedTypesByDate.get(date) ?? new Set;
+ if (sharedTypes.has(a.type) !== sharedTypes.has(b.type))
+ return sharedTypes.has(a.type) ? -1 : 1;
+ const protectedTypes = context.protectedTypesByDate.get(date) ?? new Set;
+ if (protectedTypes.size && protectedTypes.has(a.type) !== protectedTypes.has(b.type))
+ return protectedTypes.has(a.type) ? -1 : 1;
+ if (context.reservedTypes.has(a.type) !== context.reservedTypes.has(b.type)) {
+ return context.reservedTypes.has(a.type) ? 1 : -1;
+ }
+ }
+ return compareDate(a.expiryDate, b.expiryDate) || compareText(a.id, b.id);
+ });
+ return balances.map((balance) => {
+ const result = cloneState(state);
+ const target = result.balances.find((item) => item.id === balance.id);
+ target.remaining--;
+ result.placements.set(date, {
+ date,
+ balanceId: target.id,
+ type: target.type,
+ ruleIds: ruleId ? new Set([ruleId]) : new Set,
+ supporting: false
+ });
+ return result;
+ });
+}
+function assignDateToBalance(state, date, balanceId, context, ruleIds = new Set) {
+ context.check();
+ if (context.declaredDates.has(date))
+ return null;
+ if (state.placements.has(date)) {
+ return null;
+ }
+ const balance = state.balances.find((item) => item.id === balanceId);
+ if (!balance || balance.remaining <= 0 || compareDate(balance.availableFrom, date) > 0 || compareDate(date, balance.expiryDate) > 0) {
+ return null;
+ }
+ if (!mandatoryRelocationDate(date, balance, ruleIds, context))
+ return null;
+ const result = cloneState(state, new Map(state.placements));
+ const target = result.balances.find((item) => item.id === balanceId);
+ target.remaining--;
+ result.placements.set(date, {
+ date,
+ balanceId,
+ type: balance.type,
+ ruleIds: new Set(ruleIds),
+ supporting: false
+ });
+ return result;
+}
+function addEarnedBalance(state, rule, occurrence, dates, context) {
+ if (!rule.rewardDays || !rule.rewardType)
+ return state;
+ const result = cloneState(state);
+ const id = `earned:${rule.id}:${occurrence}`;
+ const activation = { id, ruleId: rule.id, occurrence, dates: [...dates] };
+ if (!result.earnedActivations.some((item) => item.id === id))
+ result.earnedActivations.push(activation);
+ const bounds = earnedPeriodBounds(state, activation, context);
+ if (bounds.availableFrom <= context.request.calculationDate)
+ return result;
+ if (!result.balances.some((balance) => balance.id === id)) {
+ result.balances.push({
+ id,
+ type: rule.rewardType,
+ quantity: rule.rewardDays,
+ remaining: rule.rewardDays,
+ ...bounds,
+ earned: true,
+ sourceRuleId: rule.id
+ });
+ }
+ return result;
+}
+function earnedPeriodBounds(state, activation, context) {
+ const allDates = [...activation.dates, activation.occurrence].sort(compareDate);
+ let start = allDates[0] ?? activation.occurrence;
+ let end = allDates[allDates.length - 1] ?? activation.occurrence;
+ const mergedPeriod = periodData(state, context).find((period) => activation.dates.some((date) => period.placementDates.includes(date) || period.declaredDates?.includes(date)));
+ if (mergedPeriod) {
+ start = mergedPeriod.start;
+ end = mergedPeriod.end;
+ }
+ while (!isWorking(addDays(start, -1), context.holidays))
+ start = addDays(start, -1);
+ while (!isWorking(addDays(end, 1), context.holidays))
+ end = addDays(end, 1);
+ const availableFrom = addDays(end, 1);
+ return { availableFrom, expiryDate: addPlanningYears(availableFrom, 1) };
+}
+function finalizeEarnedState(state, context) {
+ let reconciled = state;
+ for (const rule of context.rules) {
+ if (rule.type !== "if_then" || !rule.rewardDays)
+ continue;
+ for (const occurrence of occurrences(rule, context)) {
+ if (reconciled.earnedActivations.some((activation) => activation.ruleId === rule.id && activation.occurrence === occurrence))
+ continue;
+ const pattern = selectedOccurrencePattern(rule, occurrence, reconciled, context);
+ if (pattern)
+ reconciled = addEarnedBalance(reconciled, rule, occurrence, pattern, context);
+ }
+ }
+ if (!reconciled.earnedActivations.length)
+ return reconciled;
+ let result = cloneState(reconciled);
+ for (const activation of result.earnedActivations) {
+ context.check();
+ const bounds = earnedPeriodBounds(result, activation, context);
+ if (bounds.availableFrom <= context.request.calculationDate) {
+ if ([...result.placements.values()].some((placement) => placement.balanceId === activation.id))
+ return null;
+ result.balances = result.balances.filter((balance2) => balance2.id !== activation.id);
+ continue;
+ }
+ let balance = result.balances.find((item) => item.id === activation.id);
+ if (!balance) {
+ const rule = context.rules.find((rule2) => rule2.type === "if_then" && rule2.id === activation.ruleId);
+ if (!rule)
+ return null;
+ result = addEarnedBalance(result, rule, activation.occurrence, activation.dates, context);
+ balance = result.balances.find((item) => item.id === activation.id);
+ if (!balance)
+ continue;
+ }
+ balance.availableFrom = bounds.availableFrom;
+ balance.expiryDate = bounds.expiryDate;
+ for (const placement of result.placements.values()) {
+ if (placement.balanceId === activation.id && (compareDate(placement.date, balance.availableFrom) < 0 || compareDate(placement.date, balance.expiryDate) > 0))
+ return null;
+ }
+ }
+ return result;
+}
+function* assignDatesVariants(state, dates, rule, context) {
+ if (!dates.length)
+ return [state];
+ const [date, ...rest] = dates;
+ if (!isWorking(date, context.holidays) || compareDate(date, context.horizon.start) < 0 || compareDate(date, context.horizon.end) >= 0)
+ return [];
+ if (context.declaredDates.has(date)) {
+ if (declaredPatternDay(date, rule.placeType, context))
+ return yield* assignDatesVariants(state, rest, rule, context);
+ return [];
+ }
+ const result = [];
+ for (const next of assignDateVariants(state, date, rule.placeType, rule.id, context)) {
+ yield;
+ result.push(...yield* assignDatesVariants(next, rest, rule, context));
+ }
+ return result;
+}
+function* satisfyObligations(requirements, state, context, index = 0, solutions, solutionLimit = OBLIGATION_SOLUTION_LIMIT, chooseOrder = true) {
+ yield;
+ context.check();
+ if (index >= requirements.length) {
+ const finalized = finalizeRuleCandidate(state, context, state);
+ if (!finalized)
+ return null;
+ if (solutions)
+ solutions.push(finalized);
+ context.retain?.(finalized);
+ return solutions ? null : finalized;
+ }
+ const requirement = requirements[index];
+ const extensionDates = chooseOrder && context.horizon.end > context.request.calculationDate ? state.earnedActivations.map((activation) => state.balances.find((balance) => balance.id === activation.id)?.availableFrom ?? earnedPeriodBounds(state, activation, context).availableFrom) : [];
+ const extensions = extensionDates.length ? requirements.slice(index + 1).flatMap((candidate, offset) => (candidate.patterns ? candidate.patterns.some((pattern) => pattern.some((date) => extensionDates.includes(date))) : candidate.requiredPlacements > 0 && candidate.candidates?.some((date) => extensionDates.includes(date))) ? [index + offset + 1] : []) : [];
+ if (extensions.length) {
+ for (const next of [index, ...extensions]) {
+ yield;
+ const ordered = next === index ? requirements : [
+ ...requirements.slice(0, index),
+ requirements[next],
+ ...requirements.slice(index, next),
+ ...requirements.slice(next + 1)
+ ];
+ const complete = yield* satisfyObligations(ordered, state, context, index, solutions, solutionLimit, false);
+ if (complete && !solutions)
+ return complete;
+ if (solutions && solutionLimit > 0 && solutions.length >= solutionLimit)
+ break;
+ }
+ return null;
+ }
+ if (requirement.optional) {
+ const complete = yield* satisfyObligations(requirements, state, context, index + 1, solutions, solutionLimit);
+ if (complete && !solutions)
+ return complete;
+ }
+ if (requirement.patterns) {
+ for (const pattern of requirement.patterns) {
+ yield;
+ for (const assigned of yield* assignDatesVariants(state, pattern, requirement.rule, context)) {
+ yield;
+ const candidate = finalizeEarnedState(addEarnedBalance(assigned, requirement.rule, requirement.occurrenceDate, pattern, context), context);
+ if (!candidate)
+ continue;
+ const complete = yield* satisfyObligations(requirements, candidate, context, index + 1, solutions, solutionLimit);
+ if (complete && !solutions)
+ return complete;
+ if (solutions && solutionLimit > 0 && solutions.length >= solutionLimit) {
+ return null;
+ }
+ }
+ }
+ return null;
+ }
+ const candidates = requirement.candidates ?? [];
+ const needed = requirement.requiredPlacements;
+ const choose = function* (position, picked, current) {
+ yield;
+ context.check();
+ if (picked.length === needed) {
+ const finalized = finalizeEarnedState(current, context);
+ if (!finalized)
+ return null;
+ const complete = yield* satisfyObligations(requirements, finalized, context, index + 1, solutions, solutionLimit);
+ if (complete && !solutions)
+ return complete;
+ return null;
+ }
+ if (candidates.length - position < needed - picked.length) {
+ return null;
+ }
+ for (let i = position;i < candidates.length; i++) {
+ yield;
+ for (const next of assignDateVariants(current, candidates[i], requirement.rule.placeType, requirement.rule.id, context)) {
+ yield;
+ const complete = yield* choose(i + 1, [...picked, candidates[i]], next);
+ if (complete && !solutions)
+ return complete;
+ if (solutions && solutionLimit > 0 && solutions.length >= solutionLimit) {
+ return null;
+ }
+ }
+ }
+ return null;
+ };
+ return yield* choose(0, [], state);
+}
+function releasePlacement(state, date) {
+ const result = {
+ balances: state.balances.map((balance) => ({ ...balance })),
+ placements: new Map(state.placements),
+ earnedActivations: state.earnedActivations.map((activation) => ({
+ ...activation,
+ dates: [...activation.dates]
+ }))
+ };
+ const placement = result.placements.get(date);
+ result.placements.delete(date);
+ result.balances.find((balance) => balance.id === placement.balanceId).remaining++;
+ return result;
+}
+function reconcileOptimizationProvenance(state, context, before, objectives = activeObjectiveVector(state, context)) {
+ const optimizations = objectives.filter(({ id }) => id.startsWith("optimization:"));
+ if (!optimizations.length)
+ return state;
+ let result = state;
+ let observations;
+ for (const placement of state.placements.values()) {
+ const previous = before.placements.get(placement.date);
+ const creditedIds = new Set([...placement.ruleIds, ...previous?.ruleIds ?? []]);
+ const credits = optimizations.filter(({ id }) => creditedIds.has(id.slice("optimization:".length)));
+ if (!credits.length)
+ continue;
+ const withoutState = releasePlacement(state, placement.date);
+ const withoutPlacement = activeObjectiveVector(withoutState, context);
+ const ruleIds = new Set([...placement.ruleIds].filter((id) => !credits.some((objective) => objective.id === `optimization:${id}`)));
+ for (const objective of credits) {
+ const without = withoutPlacement.find(({ id: id2 }) => id2 === objective.id);
+ const id = objective.id.slice("optimization:".length);
+ const rule = context.rules.find((rule2) => rule2.id === id && rule2.type === "optimization");
+ const compared = compareObjectiveValues(objective.direction, objective.value, without.value);
+ let contributes = compared < 0;
+ if (compared === 0 && rule.configuration.aggregation === "maximum") {
+ observations ??= optimizationObservations(state, context);
+ const withoutObservations = optimizationObservations(withoutState, context);
+ const summed = { ...rule.configuration, aggregation: "sum" };
+ const summedComparison = compareObjectiveValues(objective.direction, evaluateOptimization(summed, observations), evaluateOptimization(summed, withoutObservations));
+ contributes = summedComparison < 0 || summedComparison === 0 && rule.configuration.elements === "time-off-periods" && objective.direction === "maximize" && observations.timeOffPeriods.length < withoutObservations.timeOffPeriods.length;
+ }
+ if (contributes)
+ ruleIds.add(id);
+ }
+ if (ruleIds.size === placement.ruleIds.size && [...ruleIds].every((id) => placement.ruleIds.has(id)))
+ continue;
+ if (result === state)
+ result = cloneState(state);
+ result.placements.get(placement.date).ruleIds = ruleIds;
+ }
+ return result;
+}
+function supportingCalendarBenefit(before, after, context) {
+ const observations = optimizationObservations(after, context);
+ return evaluateOptimization(OPTIMIZATION_PRESETS.holidayWeekValue, observations) > evaluateOptimization(OPTIMIZATION_PRESETS.holidayWeekValue, before) || evaluateOptimization(OPTIMIZATION_PRESETS.nonWorkingDayGain, observations) > evaluateOptimization(OPTIMIZATION_PRESETS.nonWorkingDayGain, before) || evaluateOptimization(OPTIMIZATION_PRESETS.longestWorkSequence, observations) < evaluateOptimization(OPTIMIZATION_PRESETS.longestWorkSequence, before) || evaluateOptimization(OPTIMIZATION_PRESETS.shortSequenceDeficit, observations) < evaluateOptimization(OPTIMIZATION_PRESETS.shortSequenceDeficit, before);
+}
+function mandatoryRelocationDate(date, balance, ruleIds, context) {
+ return [...ruleIds].every((id) => {
+ const rule = context.rules.find((item) => item.id === id);
+ if (rule?.force !== "obligation")
+ return true;
+ if (!selectorMatches(rule.placeType, balance.type))
+ return false;
+ return rule.type === "time_window" ? date >= rule.startDate && date <= rule.endDate : occurrences(rule, context).some((occurrence) => patternsFor(rule, occurrence, context).some((pattern) => pattern.includes(date)));
+ });
+}
+function relocationCanReachFree(state, context, balanceId, ruleIds, visited, failed) {
+ const key = JSON.stringify([balanceId, [...ruleIds].sort(), [...visited].sort()]);
+ if (failed.has(key))
+ return false;
+ const queue = [{ balanceId, ruleIds }];
+ const seen = new Set;
+ for (let index = 0;index < queue.length; index++) {
+ const entry = queue[index];
+ const identity = JSON.stringify([entry.balanceId, [...entry.ruleIds].sort()]);
+ if (seen.has(identity))
+ continue;
+ seen.add(identity);
+ const balance = state.balances.find((item) => item.id === entry.balanceId);
+ for (const date of context.workingDays) {
+ context.check();
+ if (visited.has(date) || context.declaredDates.has(date) || date < balance.availableFrom || date > balance.expiryDate || !mandatoryRelocationDate(date, balance, entry.ruleIds, context))
+ continue;
+ const placement = state.placements.get(date);
+ if (!placement)
+ return true;
+ queue.push({ balanceId: placement.balanceId, ruleIds: placement.ruleIds });
+ }
+ }
+ failed.add(key);
+ return false;
+}
+function* relocateForAddition(state, context, date, balanceId, ruleIds, supporting, accept, visited = new Set, failedReachability = new Set, failedSearches = new Set) {
+ yield;
+ context.check();
+ if (visited.has(date) || context.declaredDates.has(date))
+ return false;
+ const existing = state.placements.get(date);
+ if (existing?.balanceId === balanceId && existing.supporting === supporting && existing.ruleIds.size === ruleIds.size && [...existing.ruleIds].every((id) => ruleIds.has(id)))
+ return false;
+ const key = JSON.stringify([
+ date,
+ balanceId,
+ [...ruleIds].sort(),
+ supporting,
+ [...visited].sort(),
+ state.balances.map((balance) => [balance.id, balance.remaining]),
+ [...state.placements.values()].sort((a, b) => compareDate(a.date, b.date)).map((placement) => [
+ placement.date,
+ placement.balanceId,
+ [...placement.ruleIds].sort(),
+ placement.supporting
+ ])
+ ]);
+ if (failedSearches.has(key)) {
+ return false;
+ }
+ const assigned = assignDateToBalance(existing ? releasePlacement(state, date) : state, date, balanceId, context, ruleIds);
+ if (!assigned)
+ return false;
+ assigned.placements.get(date).supporting = supporting;
+ if (!existing) {
+ const accepted = accept(assigned);
+ if (!accepted)
+ failedSearches.add(key);
+ return accepted;
+ }
+ const displaced = assigned.balances.find((balance) => balance.id === existing.balanceId);
+ const nextVisited = new Set([...visited, date]);
+ if (!relocationCanReachFree(assigned, context, existing.balanceId, existing.ruleIds, nextVisited, failedReachability))
+ return false;
+ for (const destination of context.workingDays) {
+ yield;
+ if (destination < displaced.availableFrom || destination > displaced.expiryDate)
+ continue;
+ if (!mandatoryRelocationDate(destination, displaced, existing.ruleIds, context))
+ continue;
+ if (yield* relocateForAddition(assigned, context, destination, existing.balanceId, existing.ruleIds, existing.supporting, accept, nextVisited, failedReachability, failedSearches))
+ return true;
+ }
+ failedSearches.add(key);
+ return false;
+}
+function finalizeRuleCandidate(state, context, before) {
+ let result = state;
+ const windowCredits = new Map;
+ const patternCredits = new Map;
+ for (const placement of state.placements.values()) {
+ let lostCalendarRequest = false;
+ let hasCalendarRequest = false;
+ for (const id of placement.ruleIds) {
+ const rule = context.rules.find((item) => item.id === id);
+ if (rule?.type === "optimization")
+ continue;
+ const valid = counterfactualRuleValid(placement.date, placement.type, [id], context, state);
+ const credited = windowCredits.get(id) ?? 0;
+ let withinRequest = true;
+ if (rule?.type === "time_window")
+ withinRequest = credited < Math.max(0, rule.placeDays - declaredWindowDays(rule, context).length);
+ else if (rule?.type === "if_then") {
+ if (!patternCredits.has(id))
+ patternCredits.set(id, new Set(occurrences(rule, context).flatMap((occurrence) => selectedOccurrencePattern(rule, occurrence, state, context) ?? [])));
+ withinRequest = patternCredits.get(id).has(placement.date);
+ }
+ if (valid && withinRequest) {
+ hasCalendarRequest = true;
+ if (rule?.type === "time_window")
+ windowCredits.set(id, credited + 1);
+ continue;
+ }
+ if (!valid && rule?.force === "obligation")
+ return null;
+ if (result === state)
+ result = cloneState(state);
+ result.placements.get(placement.date).ruleIds.delete(id);
+ lostCalendarRequest = true;
+ }
+ const current = result.placements.get(placement.date);
+ const previous = before.placements.get(placement.date);
+ let supporting = current.supporting;
+ if (hasCalendarRequest)
+ supporting = false;
+ else if (lostCalendarRequest || previous && (previous.balanceId !== placement.balanceId || previous.supporting !== current.supporting)) {
+ const balance = result.balances.find((item) => item.id === placement.balanceId);
+ supporting = !isAtRisk(balance, context) && (current.supporting || previous?.balanceId === placement.balanceId && previous.supporting || supportingCalendarBenefit(optimizationObservations(releasePlacement(result, placement.date), context), result, context));
+ }
+ if (supporting !== current.supporting) {
+ if (result === state)
+ result = cloneState(state);
+ result.placements.get(placement.date).supporting = supporting;
+ }
+ }
+ for (const activation of state.earnedActivations) {
+ const rule = context.rules.find((item) => item.type === "if_then" && item.id === activation.ruleId);
+ if (activation.dates.every((date) => patternDateSatisfied(date, rule, state, context)))
+ continue;
+ if ([...state.placements.values()].some((placement) => placement.balanceId === activation.id))
+ return null;
+ result = cloneState(result);
+ result.earnedActivations = result.earnedActivations.filter((item) => item.id !== activation.id);
+ result.balances = result.balances.filter((balance) => balance.id !== activation.id);
+ }
+ const finalized = finalizeEarnedState(result, context);
+ return finalized && !unsatisfiedObligationCount(finalized, context) ? finalized : null;
+}
+function typedCalendarDates(context) {
+ const typedDates = new Set;
+ for (const rule of context.rules) {
+ if (rule.type === "optimization" || rule.placeType === "any")
+ continue;
+ const dates = rule.type === "time_window" ? context.workingDays.filter((date) => rule.startDate <= date && date <= rule.endDate) : occurrences(rule, context).flatMap((occurrence) => patternsFor(rule, occurrence, context).flat());
+ for (const date of dates)
+ typedDates.add(date);
+ }
+ return typedDates;
+}
+function* calendarPlacementCandidates(state, targetDates, ruleId, context, accept) {
+ const missing = targetDates.filter((date) => !state.placements.has(date) && !context.declaredDates.has(date));
+ if (!missing.length)
+ return false;
+ const before = optimizationObservations(state, context);
+ const typedDates = typedCalendarDates(context);
+ const failed = new Set;
+ const rankedDonors = [];
+ for (const placement of state.placements.values()) {
+ yield;
+ context.check();
+ if (targetDates.includes(placement.date))
+ continue;
+ rankedDonors.push({
+ date: placement.date,
+ objectives: activeObjectiveVector(releasePlacement(state, placement.date), context)
+ });
+ }
+ rankedDonors.sort((a, b) => compareObjectiveLists(a.objectives, b.objectives) || compareDate(b.date, a.date));
+ const fill = function* (index, current, additions = []) {
+ yield;
+ context.check();
+ const key = JSON.stringify([
+ index,
+ current.balances.map((balance) => [balance.id, balance.remaining]),
+ [...current.placements.values()].sort((a, b) => compareDate(a.date, b.date)).map((placement) => [
+ placement.date,
+ targetDates.includes(placement.date) ? typedDates.has(placement.date) ? placement.type : null : placement.type,
+ targetDates.includes(placement.date) && !current.balances.find((balance) => balance.id === placement.balanceId).earned ? null : placement.balanceId,
+ [...placement.ruleIds].sort(),
+ placement.supporting,
+ additions.includes(placement.date)
+ ])
+ ]);
+ if (failed.has(key))
+ return false;
+ if (index === missing.length) {
+ const benefit = supportingCalendarBenefit(before, current, context);
+ for (const date2 of additions) {
+ const placement = { ...current.placements.get(date2) };
+ current.placements.set(date2, placement);
+ const balance = current.balances.find((item) => item.id === placement.balanceId);
+ placement.supporting = benefit && !isAtRisk(balance, context);
+ }
+ const accepted = accept(current, additions.length > 0);
+ if (!accepted)
+ failed.add(key);
+ return accepted;
+ }
+ const date = missing[index];
+ for (const balance of current.balances) {
+ if (balance.remaining <= 0 || date < balance.availableFrom || date > balance.expiryDate)
+ continue;
+ const assigned = assignDateToBalance(current, date, balance.id, context, new Set([ruleId]));
+ if (!assigned)
+ continue;
+ if (yield* fill(index + 1, assigned, [...additions, date]))
+ return true;
+ }
+ const donors = new Set;
+ for (const donor of rankedDonors) {
+ yield;
+ const source = current.placements.get(donor.date);
+ if (!source)
+ continue;
+ const balance = current.balances.find((item) => item.id === source.balanceId);
+ if (date < balance.availableFrom || date > balance.expiryDate || !mandatoryRelocationDate(date, balance, source.ruleIds, context))
+ continue;
+ const without = releasePlacement(current, source.date);
+ const identity = JSON.stringify([
+ source.balanceId,
+ [...source.ruleIds].sort(),
+ source.supporting,
+ activeObjectiveVector(without, context)
+ ]);
+ if (donors.has(identity))
+ continue;
+ donors.add(identity);
+ const assigned = assignDateToBalance(without, date, source.balanceId, context, source.ruleIds);
+ if (!assigned)
+ continue;
+ assigned.placements.get(date).supporting = source.supporting;
+ if (yield* fill(index + 1, assigned, additions))
+ return true;
+ }
+ failed.add(key);
+ return false;
+ };
+ return yield* fill(0, state);
+}
+function* holidayWeekPlacementCandidates(state, ruleId, context, accept) {
+ for (const week of context.holidayWeeks)
+ yield* calendarPlacementCandidates(state, week.dates, ruleId, context, accept);
+}
+function* timeOffPeriodPlacementCandidates(state, ruleId, context, accept) {
+ const periods = periodData(state, context);
+ for (let index = 1;index < periods.length; index++) {
+ const dates = context.workingDays.filter((date) => date > periods[index - 1].end && date < periods[index].start);
+ if (dates.length > 1)
+ yield* calendarPlacementCandidates(state, dates, ruleId, context, accept);
+ }
+}
+function evaluateRuleCandidate(before, candidate, context, throughIndex, addition = false, baseline = activeObjectiveVector(before, context, throughIndex), incumbentVector = objectiveVector(before, context)) {
+ if (!candidate)
+ return;
+ const state = finalizeRuleCandidate(candidate, context, before);
+ if (!state)
+ return;
+ const active = [];
+ let comparison = 0;
+ let incumbentComparison = 0;
+ for (const objective of activeObjectives(state, context)) {
+ const index = active.length;
+ active.push(objective);
+ if (index <= throughIndex && comparison === 0) {
+ comparison = compareObjectiveValues(objective.direction, objective.value, baseline[index].value);
+ if (comparison > 0)
+ return;
+ }
+ if (incumbentComparison === 0)
+ incumbentComparison = compareObjectiveValues(objective.direction, objective.value, incumbentVector.objectives[index + 1].value);
+ if (incumbentComparison > 0 && (comparison < 0 || index >= throughIndex))
+ return addition && comparison === 0 ? undefined : { state };
+ }
+ const vector = objectiveVector(state, context, active);
+ if (compareObjectiveLists(vector.objectives, incumbentVector.objectives) < 0)
+ context.retain?.(state, undefined, vector);
+ if (addition && comparison === 0)
+ return;
+ return { state, vector };
+}
+function* workSequencePlacementCandidates(state, rule, context, consider) {
+ const configuration = rule.configuration;
+ if (configuration.direction !== "minimize")
+ return;
+ const sequences = workSequenceDates(state, context).filter(({ dates, internal }) => dates.length > 0 && (!configuration.conditions.includes("internal") || internal));
+ const bundles = [];
+ if (configuration.aggregation === "count")
+ bundles.push(...sequences.map(({ dates }) => dates.map((date) => [date])));
+ else if (configuration.measure === "work-sequence-deficit") {
+ const short = sequences.filter(({ dates }) => dates.length < configuration.thresholdDays);
+ if (configuration.aggregation === "maximum") {
+ const shortest = Math.min(...short.map(({ dates }) => dates.length));
+ bundles.push(short.filter(({ dates }) => dates.length === shortest).flatMap(({ dates }) => dates.map((date) => [date])));
+ } else
+ bundles.push(...short.map(({ dates }) => dates.map((date) => [date])));
+ } else if (configuration.measure === "work-sequence-length" && configuration.aggregation === "maximum") {
+ const maximum = Math.max(0, ...sequences.map(({ dates }) => dates.length));
+ const tied = sequences.filter(({ dates }) => dates.length === maximum);
+ if (tied.length > 1) {
+ for (const offset of [0, 1]) {
+ const bundle = [];
+ for (const { dates } of tied) {
+ const middle = Math.floor((dates.length - 1 + offset) / 2);
+ const centered = dates.map((date, index) => ({ date, index })).sort((a, b) => Math.abs(a.index - middle) - Math.abs(b.index - middle) || compareDate(a.date, b.date));
+ const eligible = [
+ ...new Set(state.balances.filter((balance) => balance.remaining > 0).map((balance) => centered.find(({ date }) => balance.availableFrom <= date && date <= balance.expiryDate)?.date).filter((date) => date !== undefined))
+ ];
+ if (!eligible.length)
+ break;
+ bundle.push(eligible.sort((a, b) => centered.findIndex(({ date }) => date === a) - centered.findIndex(({ date }) => date === b)));
+ }
+ if (bundle.length === tied.length)
+ bundles.push(bundle);
+ }
+ }
+ }
+ const before = optimizationObservations(state, context);
+ const available = state.balances.reduce((total, balance) => total + balance.remaining, 0);
+ const typedDates = typedCalendarDates(context);
+ const failedAssignments = new Set;
+ function* assignBundle(candidate, slots, dates = []) {
+ yield;
+ context.check();
+ const key = JSON.stringify([
+ slots,
+ dates,
+ candidate.balances.map(({ id, remaining }) => [id, remaining]),
+ dates.map((date) => {
+ const placement = candidate.placements.get(date);
+ const balance = candidate.balances.find((item) => item.id === placement.balanceId);
+ return [
+ typedDates.has(date) ? placement.type : undefined,
+ balance.earned ? balance.id : undefined
+ ];
+ })
+ ]);
+ if (failedAssignments.has(key))
+ return false;
+ if (dates.length === slots.length) {
+ const supporting = supportingCalendarBenefit(before, candidate, context);
+ for (const date of dates) {
+ const placement = candidate.placements.get(date);
+ const balance = candidate.balances.find((item) => item.id === placement.balanceId);
+ placement.supporting = supporting && !isAtRisk(balance, context);
+ }
+ const accepted = consider(candidate);
+ if (!accepted)
+ failedAssignments.add(key);
+ return accepted;
+ }
+ for (const date of slots[dates.length]) {
+ for (const next of assignDateVariants(candidate, date, "any", rule.id, context))
+ if (yield* assignBundle(next, slots, [...dates, date]))
+ return true;
+ }
+ failedAssignments.add(key);
+ return false;
+ }
+ const examined = new Set;
+ for (const slots of bundles) {
+ yield;
+ const key = JSON.stringify(slots);
+ if (slots.length <= 1 || slots.length > available || examined.has(key))
+ continue;
+ examined.add(key);
+ yield* assignBundle(state, slots);
+ }
+}
+function* optimizeRulePlacements(state, context, throughIndex, allowAdd, rule) {
+ let result = state;
+ const addingBalanceQuantity = allowAdd && rule?.type === "optimization" && rule.configuration.elements === "leave-balances" && rule.configuration.measure === "unplaced-quantity" && rule.configuration.aggregation === "sum" && rule.configuration.direction === "minimize";
+ const equivalentAdditionFunding = addingBalanceQuantity && throughIndex === 0 && !context.rules.some((item) => item.enabled && item.type === "if_then") && context.rules.every((item) => !item.enabled || item.type !== "optimization" || item.configuration.elements !== "leave-balances" || item.configuration.aggregation !== "maximum");
+ const fundingWindows = context.rules.filter((item) => item.enabled && item.type === "time_window");
+ const preferencePrefix = context.rules.filter((item) => item.enabled && item.force === "preference").slice(0, throughIndex + 1);
+ const prefixBounds = preferencePrefix.map((item, index) => {
+ if (index === 0)
+ return item.type === "optimization" && item.configuration.elements === "leave-balances" && item.configuration.measure === "unplaced-quantity" && item.configuration.aggregation === "sum" && item.configuration.direction === "minimize" ? 0 : undefined;
+ if (item.type === "time_window")
+ return item.placeDays;
+ if (item.type === "optimization" && item.configuration.elements === "public-holiday-weeks" && item.configuration.measure === "public-holiday-week-value" && item.configuration.direction === "maximize")
+ return evaluateOptimization(item.configuration, {
+ leaveBalances: [],
+ publicHolidayWeeks: context.holidayWeeks.map(({ value }) => ({
+ value,
+ complete: true
+ })),
+ timeOffPeriods: [],
+ workSequences: [],
+ leavePlacements: []
+ });
+ return;
+ });
+ const dateInvariantObjectives = context.rules.filter((item) => item.force === "preference").every((item) => item.type === "optimization" && (item.configuration.elements === "leave-balances" || item.configuration.elements === "leave-placements"));
+ while (true) {
+ yield;
+ context.check();
+ const active = activeObjectiveVector(result, context);
+ const baseline = active.slice(0, throughIndex + 1);
+ const beforeVector = objectiveVector(result, context, active);
+ context.retain?.(result, undefined, beforeVector);
+ if (allowAdd && prefixBounds.length > 0 && prefixBounds.every((bound, index) => bound !== undefined && active[index].value === bound)) {
+ return result;
+ }
+ const genericStateKey = allowAdd && result.balances.every(({ remaining }) => remaining === 0) && !context.rules.some((item) => item.enabled && item.type === "if_then") ? preferenceStateKey(result) : undefined;
+ const reuseGeneric = genericStateKey !== undefined && context.completedGenericState === genericStateKey;
+ const compareEquivalentFunding = equivalentAdditionFunding && result.earnedActivations.length === 0 && Number.isSafeInteger(result.balances.reduce((sum, balance) => sum + balance.remaining, 0));
+ let beforeCalendar;
+ let best = result;
+ let bestVector = beforeVector;
+ const consider = (candidate, addition = false) => {
+ const evaluated = evaluateRuleCandidate(result, candidate, context, throughIndex, addition, baseline, bestVector);
+ if (!evaluated)
+ return false;
+ if (!evaluated.vector)
+ return true;
+ const compared = compareObjectiveLists(evaluated.vector.objectives, bestVector.objectives);
+ if (compared >= 0)
+ return true;
+ best = evaluated.state;
+ bestVector = evaluated.vector;
+ return true;
+ };
+ const dates = [...context.workingDays].sort((a, b) => Number(result.placements.has(a)) - Number(result.placements.has(b)));
+ const moveDates = result.placements.size <= 24 ? context.workingDays : context.workingDays.filter((date, index) => !isWorking(addDays(date, -1), context.holidays) || !isWorking(addDays(date, 1), context.holidays) || context.declaredDates.has(addDays(date, -1)) || context.declaredDates.has(addDays(date, 1)) || index % 7 === 0 || context.rules.some((item) => item.type === "time_window" && date >= item.startDate && date <= item.endDate));
+ if (allowAdd && rule?.type === "optimization" && rule.configuration.elements === "work-sequences")
+ yield* workSequencePlacementCandidates(result, rule, context, (candidate) => consider(candidate, true));
+ if (allowAdd && rule?.type === "optimization" && rule.configuration.elements === "public-holiday-weeks" && rule.configuration.conditions.includes("complete") && rule.configuration.direction === "maximize")
+ yield* holidayWeekPlacementCandidates(result, rule.id, context, consider);
+ if (allowAdd && rule?.type === "optimization" && rule.configuration.elements === "time-off-periods" && (rule.configuration.aggregation === "maximum" && rule.configuration.direction === "maximize" || rule.configuration.aggregation === "count" && rule.configuration.direction === "minimize"))
+ yield* timeOffPeriodPlacementCandidates(result, rule.id, context, consider);
+ const deferGeneric = allowAdd && best !== result && compareObjectiveLists(bestVector.objectives.slice(1, throughIndex + 2), baseline) < 0;
+ for (const date of reuseGeneric || deferGeneric ? [] : dates) {
+ yield;
+ context.check();
+ if (context.declaredDates.has(date))
+ continue;
+ const existing = result.placements.get(date);
+ if (!existing && allowAdd) {
+ if (rule?.type === "time_window" && (date < rule.startDate || date > rule.endDate))
+ continue;
+ const evaluatedFunding = new Set;
+ for (const balance of result.balances) {
+ yield;
+ if (rule?.type === "time_window" && !selectorMatches(rule.placeType, balance.type))
+ continue;
+ if (balance.remaining <= 0 || balance.availableFrom > date || balance.expiryDate < date)
+ continue;
+ const fundingKey = compareEquivalentFunding && isAtRisk(balance, context) ? `${balance.expiryDate}:${fundingWindows.map((window) => Number(date >= window.startDate && date <= window.endDate && selectorMatches(window.placeType, balance.type))).join("")}` : undefined;
+ if (fundingKey !== undefined && evaluatedFunding.has(fundingKey)) {
+ continue;
+ }
+ const assigned = assignDateToBalance(result, date, balance.id, context, rule ? new Set([rule.id]) : new Set);
+ if (assigned && rule?.type === "optimization" && !isAtRisk(balance, context)) {
+ beforeCalendar ??= optimizationObservations(result, context);
+ assigned.placements.get(date).supporting = supportingCalendarBenefit(beforeCalendar, assigned, context);
+ }
+ const evaluated = consider(assigned, true);
+ if (evaluated && fundingKey !== undefined)
+ evaluatedFunding.add(fundingKey);
+ }
+ }
+ if (!existing || addingBalanceQuantity && best !== result)
+ continue;
+ const without = releasePlacement(result, date);
+ consider(without);
+ for (const balance of without.balances) {
+ yield;
+ if (balance.id === existing.balanceId || balance.remaining <= 0 || balance.availableFrom > date || balance.expiryDate < date)
+ continue;
+ const reassigned = assignDateToBalance(without, date, balance.id, context, existing.ruleIds);
+ if (reassigned)
+ reassigned.placements.get(date).supporting = existing.supporting && !isAtRisk(balance, context);
+ consider(reassigned);
+ }
+ for (const destination of moveDates) {
+ yield;
+ context.check();
+ if (destination === date || context.declaredDates.has(destination))
+ continue;
+ const other = without.placements.get(destination);
+ if (other) {
+ if (other.balanceId === existing.balanceId)
+ continue;
+ const swapped = releasePlacement(without, destination);
+ const first = assignDateToBalance(swapped, destination, existing.balanceId, context, existing.ruleIds);
+ const second = first && assignDateToBalance(first, date, other.balanceId, context, other.ruleIds);
+ if (second) {
+ second.placements.get(destination).supporting = existing.supporting;
+ second.placements.get(date).supporting = other.supporting;
+ }
+ consider(second);
+ } else {
+ const moved = assignDateToBalance(without, destination, existing.balanceId, context, existing.ruleIds);
+ if (moved)
+ moved.placements.get(destination).supporting = existing.supporting;
+ if (moved && dateInvariantObjectives && compareObjectiveLists(tieBreakObjectives(moved), beforeVector.objectives.slice(-2)) >= 0) {
+ continue;
+ }
+ consider(moved);
+ }
+ }
+ }
+ if (allowAdd && (addingBalanceQuantity || rule?.type === "time_window" && Number(active[throughIndex].value) < rule.placeDays) && compareObjectiveLists(bestVector.objectives.slice(1, throughIndex + 2), baseline) === 0) {
+ for (const balance of result.balances) {
+ if (balance.remaining <= 0)
+ continue;
+ for (const date of result.placements.keys()) {
+ yield;
+ if (date < balance.availableFrom || date > balance.expiryDate)
+ continue;
+ const sourceRuleApplies = rule?.type !== "time_window" || date >= rule.startDate && date <= rule.endDate && selectorMatches(rule.placeType, balance.type);
+ yield* relocateForAddition(result, context, date, balance.id, rule && sourceRuleApplies ? new Set([rule.id]) : new Set, false, (candidate) => {
+ if (rule?.type === "optimization" && !isAtRisk(balance, context)) {
+ beforeCalendar ??= optimizationObservations(result, context);
+ candidate.placements.set(date, {
+ ...candidate.placements.get(date),
+ supporting: supportingCalendarBenefit(beforeCalendar, candidate, context)
+ });
+ }
+ return consider(candidate, true);
+ });
+ }
+ }
+ }
+ if (best === result) {
+ if (genericStateKey !== undefined)
+ context.completedGenericState = genericStateKey;
+ return result;
+ }
+ best = reconcileOptimizationProvenance(best, context, result, bestVector.objectives);
+ context.retain?.(best);
+ result = best;
+ }
+}
+function associateCalendarPreference(state, rule, context) {
+ const result = cloneState(state);
+ const dates = rule.type === "time_window" ? [...result.placements.values()].filter((placement) => placement.date >= rule.startDate && placement.date <= rule.endDate && selectorMatches(rule.placeType, placement.type)).sort((a, b) => Number(b.ruleIds.has(rule.id)) - Number(a.ruleIds.has(rule.id))).slice(0, Math.max(0, rule.placeDays - declaredWindowDays(rule, context).length)).map((placement) => placement.date) : occurrences(rule, context).flatMap((occurrence) => selectedOccurrencePattern(rule, occurrence, result, context) ?? []);
+ for (const date of dates) {
+ const placement = result.placements.get(date);
+ if (!placement)
+ continue;
+ placement.ruleIds.add(rule.id);
+ placement.supporting = false;
+ }
+ return result;
+}
+function* preferencePatternVariants(state, pattern, rule, context) {
+ let variants = [state];
+ for (const date of pattern) {
+ yield;
+ const next = [];
+ for (const current of variants) {
+ yield;
+ const existing = current.placements.get(date);
+ if (!existing || selectorMatches(rule.placeType, existing.type)) {
+ if (declaredPatternDay(date, rule.placeType, context))
+ next.push(current);
+ else
+ next.push(...assignDateVariants(current, date, rule.placeType, rule.id, context));
+ continue;
+ }
+ const without = releasePlacement(current, date);
+ for (const balance of without.balances) {
+ yield;
+ if (!selectorMatches(rule.placeType, balance.type))
+ continue;
+ const reassigned = assignDateToBalance(without, date, balance.id, context, new Set([rule.id]));
+ if (reassigned)
+ next.push(reassigned);
+ }
+ for (const other of without.placements.values()) {
+ yield;
+ if (pattern.includes(other.date) || !selectorMatches(rule.placeType, other.type))
+ continue;
+ const released = releasePlacement(without, other.date);
+ const first = assignDateToBalance(released, date, other.balanceId, context, new Set([...other.ruleIds, rule.id]));
+ const swapped = first && assignDateToBalance(first, other.date, existing.balanceId, context, existing.ruleIds);
+ if (!swapped)
+ continue;
+ swapped.placements.get(date).supporting = other.supporting;
+ swapped.placements.get(other.date).supporting = existing.supporting;
+ next.push(swapped);
+ }
+ }
+ variants = next;
+ if (!variants.length)
+ break;
+ }
+ return variants;
+}
+function* preferenceIfThen(state, rule, context, preferenceIndex) {
+ const matchingOccurrences = occurrences(rule, context);
+ let bestState = state;
+ let bestCount = 0;
+ const baseline = activeObjectiveVector(state, context, preferenceIndex);
+ const search = function* (index, current, count) {
+ yield;
+ context.check();
+ const maximumCount = count + matchingOccurrences.length - index;
+ if (maximumCount < bestCount && baseline.slice(0, preferenceIndex).every((objective) => {
+ if (objective.direction === "minimize")
+ return objective.value === 0;
+ const earlier = context.rules.find((item) => `preference:${item.id}` === objective.id);
+ return earlier && objective.value === (earlier.type === "time_window" ? earlier.placeDays : occurrences(earlier, context).length);
+ })) {
+ return;
+ }
+ if (index >= matchingOccurrences.length) {
+ const finalized = finalizeRuleCandidate(current, context, state);
+ if (!finalized) {
+ return;
+ }
+ if (unsatisfiedObligationCount(finalized, context) || compareObjectiveLists(activeObjectiveVector(finalized, context, preferenceIndex), baseline) > 0)
+ return;
+ context.retain?.(finalized);
+ const better = compareObligationPreviews({ state: finalized }, { state: bestState }, context) < 0;
+ if (better) {
+ bestCount = count;
+ bestState = finalized;
+ }
+ return;
+ }
+ const occurrence = matchingOccurrences[index];
+ const patterns = [...patternsFor(rule, occurrence, context)].sort((a, b) => patternConflictScore(rule.placeType, a, context) - patternConflictScore(rule.placeType, b, context) || compareDateLists(a, b));
+ for (const pattern of patterns) {
+ yield;
+ const variants = yield* preferencePatternVariants(current, pattern, rule, context);
+ for (const variant of variants) {
+ yield;
+ const finalized = finalizeEarnedState(addEarnedBalance(variant, rule, occurrence, pattern, context), context);
+ if (!finalized)
+ continue;
+ yield* search(index + 1, finalized, count + 1);
+ }
+ }
+ yield* search(index + 1, current, count);
+ };
+ yield* search(0, state, 0);
+ context.retain?.(bestState);
+ return { state: bestState, count: bestCount };
+}
+function preferenceDateConflictScore(selector, date, context) {
+ const protectedTypes = context.protectedTypesByDate.get(date);
+ const demand = context.preferenceDemandByDate.get(date) ?? 0;
+ const anyDemand = context.anyPreferenceDemandByDate.get(date) ?? 0;
+ if (!protectedTypes?.size && !demand)
+ return 0;
+ if (selector === "any")
+ return -(demand * 2 + (protectedTypes?.size ?? 0));
+ if (protectedTypes?.has(selector))
+ return -(demand * 2 + 1);
+ if (protectedTypes?.size)
+ return anyDemand ? -anyDemand : protectedTypes.size;
+ return demand ? -demand : 0;
+}
+function patternConflictScore(selector, pattern, context) {
+ return pattern.reduce((score, date) => score + preferenceDateConflictScore(selector, date, context), 0);
+}
+function preferenceReason(rule, count, context, state) {
+ const requested = rule.type === "if_then" ? occurrences(rule, context).length : rule.placeDays;
+ if (count >= requested)
+ return;
+ const candidateDates = rule.type === "time_window" ? context.workingDays.filter((date) => !context.declaredDates.has(date) && compareDate(date, rule.startDate) >= 0 && compareDate(date, rule.endDate) <= 0) : occurrences(rule, context).flatMap((occurrence) => patternsFor(rule, occurrence, context).flat());
+ if (!candidateDates.length)
+ return reason("horizon", `Preference "${rule.name}" has no placeable date in the horizon.`, [rule.id]);
+ const matchingBalances = state.balances.filter((balance) => selectorMatches(rule.placeType, balance.type));
+ const availableBalances = matchingBalances.filter((balance) => candidateDates.some((date) => compareDate(balance.availableFrom, date) <= 0 && compareDate(date, balance.expiryDate) <= 0));
+ const currentRuleIndex = context.rules.findIndex((item) => item.id === rule.id);
+ const consumedByHigherPriority = matchingBalances.some((balance) => balance.remaining === 0 && [...state.placements.values()].some((placement) => placement.balanceId === balance.id && [...placement.ruleIds].some((id) => {
+ const index = context.rules.findIndex((item) => item.id === id);
+ return index >= 0 && (context.rules[index].force === "obligation" || index < currentRuleIndex);
+ })));
+ const freeEligibleDate = candidateDates.some((date) => !state.placements.has(date) && state.balances.some((balance) => balance.remaining > 0 && selectorMatches(rule.placeType, balance.type) && compareDate(balance.availableFrom, date) <= 0 && compareDate(date, balance.expiryDate) <= 0));
+ const compatibleExisting = candidateDates.some((date) => {
+ const placement = state.placements.get(date);
+ return placement && selectorMatches(rule.placeType, placement.type);
+ });
+ const blockedByHigherPriority = candidateDates.some((date) => {
+ const placement = state.placements.get(date);
+ return placement && !selectorMatches(rule.placeType, placement.type) && [...placement.ruleIds].some((id) => {
+ const index = context.rules.findIndex((item) => item.id === id);
+ return index >= 0 && (context.rules[index].force === "obligation" || index < currentRuleIndex);
+ });
+ });
+ let code;
+ if (blockedByHigherPriority && !freeEligibleDate && !compatibleExisting)
+ code = "higher-priority";
+ else if (!matchingBalances.length && !compatibleExisting)
+ code = "type";
+ else if (!availableBalances.length)
+ code = "availability";
+ else if (consumedByHigherPriority && !freeEligibleDate && !compatibleExisting)
+ code = "higher-priority";
+ else
+ code = "capacity";
+ return reason(code, `Preference "${rule.name}" could not be completely satisfied.`, [rule.id]);
+}
+function* applyPreferenceRules(state, preferenceRules, context) {
+ let result = state;
+ const preferences = [];
+ for (let preferenceIndex = 0;preferenceIndex < preferenceRules.length; preferenceIndex++) {
+ yield;
+ result = yield* applyPreferenceStep(result, preferenceRules, preferenceIndex, preferences, context);
+ }
+ return { state: result, preferences };
+}
+function* applyPreferenceStep(state, preferenceRules, preferenceIndex, preferences, context) {
+ let result = state;
+ const apply = function* (index) {
+ const rule = preferenceRules[index];
+ context.protectedTypesByDate = futurePreferenceProtection(preferenceRules, index + 1, context);
+ if (context.completedPreferenceStates.get(index) === preferenceStateKey(result)) {
+ return;
+ }
+ if (rule.type === "optimization")
+ result = yield* optimizeRulePlacements(result, context, index, true, rule);
+ else if (rule.type === "time_window")
+ result = associateCalendarPreference(yield* optimizeRulePlacements(result, context, index, true, rule), rule, context);
+ else
+ result = associateCalendarPreference((yield* preferenceIfThen(result, rule, context, index)).state, rule, context);
+ context.completedPreferenceStates.set(index, preferenceStateKey(result));
+ };
+ yield* apply(preferenceIndex);
+ let previous;
+ do {
+ previous = activeObjectiveVector(result, context);
+ const entitlements = result.balances.map(({ id, quantity, availableFrom, expiryDate }) => `${id}:${quantity}:${availableFrom}:${expiryDate}`).join("|");
+ for (let index = 0;index < preferenceIndex; index++) {
+ yield;
+ yield* apply(index);
+ }
+ if (compareObjectiveLists(activeObjectiveVector(result, context), previous) < 0 || result.balances.map(({ id, quantity, availableFrom, expiryDate }) => `${id}:${quantity}:${availableFrom}:${expiryDate}`).join("|") !== entitlements)
+ yield* apply(preferenceIndex);
+ } while (compareObjectiveLists(activeObjectiveVector(result, context), previous) < 0);
+ preferences.splice(0, preferences.length, ...preferenceResultsForState(result, context).map((preference) => ({
+ ...preference,
+ reason: preferenceReason(context.rules.find((rule) => rule.type !== "optimization" && rule.id === preference.ruleId), preference.satisfied, context, result)
+ })));
+ return result;
+}
+function preferenceStateKey(state) {
+ return JSON.stringify([
+ state.balances,
+ [...state.placements].map(([date, placement]) => [
+ date,
+ { ...placement, ruleIds: [...placement.ruleIds] }
+ ]),
+ state.earnedActivations
+ ]);
+}
+function placementTieKey(state) {
+ return [...state.placements.keys()].sort().join("|");
+}
+function expiryTieKey(state) {
+ return [...state.placements.values()].map((placement) => state.balances.find((item) => item.id === placement.balanceId)?.expiryDate ?? "").sort(compareDate).join("|");
+}
+function activeObjectiveVector(state, context, throughIndex = Number.POSITIVE_INFINITY) {
+ return [...activeObjectives(state, context, throughIndex)];
+}
+function* activeObjectives(state, context, throughIndex = Number.POSITIVE_INFINITY) {
+ let preferences;
+ let balances;
+ let calendar;
+ let index = 0;
+ for (const rule of context.rules) {
+ if (!rule.enabled || rule.force !== "preference")
+ continue;
+ if (index++ > throughIndex)
+ return;
+ if (rule.type === "optimization") {
+ const observations = rule.configuration.elements === "leave-balances" ? balances ??= {
+ leaveBalances: optimizationBalanceObservations(state, context),
+ publicHolidayWeeks: [],
+ timeOffPeriods: [],
+ workSequences: [],
+ leavePlacements: []
+ } : calendar ??= optimizationObservations(state, context);
+ yield {
+ id: `optimization:${rule.id}`,
+ direction: rule.configuration.direction,
+ value: evaluateStateOptimization(rule.configuration, state, context, observations),
+ label: rule.name
+ };
+ } else {
+ preferences ??= preferenceResultsForState(state, context);
+ yield {
+ id: `preference:${rule.id}`,
+ direction: "maximize",
+ value: preferences.find((preference) => preference.ruleId === rule.id)?.satisfied ?? 0,
+ label: rule.name
+ };
+ }
+ }
+}
+function tieBreakObjectives(state) {
+ return [
+ {
+ id: "balance-expiry",
+ direction: "minimize",
+ value: expiryTieKey(state),
+ label: "Balance expiry tie-break"
+ },
+ {
+ id: "placement-date",
+ direction: "minimize",
+ value: placementTieKey(state),
+ label: "Placement date tie-break"
+ }
+ ];
+}
+function obligationPreviewObjectives(preview, context) {
+ return [...activeObjectiveVector(preview.state, context), ...tieBreakObjectives(preview.state)];
+}
+function compareObligationPreviews(a, b, context) {
+ return compareObjectiveLists(obligationPreviewObjectives(a, context), obligationPreviewObjectives(b, context));
+}
+function isAtRisk(balance, context) {
+ return compareDate(balance.expiryDate, context.horizon.start) >= 0 && compareDate(balance.expiryDate, context.horizon.end) <= 0;
+}
+function isNaturalNonWorkingDate(date, context) {
+ return context.calendarIndex.has(date) ? !context.workingCalendar.has(date) : !isWorkingPlanningDate(date);
+}
+function calendarDateAt(index, context) {
+ return context.calendarDays[index] ?? addDays(context.calendarDays[0], index);
+}
+function periodBounds(state, context) {
+ const dates = [...new Set([...state.placements.keys(), ...context.declaredDates])].filter((date) => date >= context.horizon.start && date < context.horizon.end).sort(compareDate);
+ if (!dates.length)
+ return [];
+ const groups = [];
+ let group = [dates[0]];
+ for (let i = 1;i < dates.length; i++) {
+ context.check();
+ const previousIndex = context.calendarIndex.get(dates[i - 1]);
+ const currentIndex = context.calendarIndex.get(dates[i]);
+ const workingInGap = context.workingPrefix[currentIndex] - context.workingPrefix[previousIndex + 1];
+ if (workingInGap === 0)
+ group.push(dates[i]);
+ else {
+ groups.push(group);
+ group = [dates[i]];
+ }
+ }
+ groups.push(group);
+ return groups.map((anchors) => {
+ context.check();
+ let startIndex = context.calendarIndex.get(anchors[0]);
+ let endIndex = context.calendarIndex.get(anchors[anchors.length - 1]);
+ while (context.declaredDates.has(calendarDateAt(startIndex - 1, context)) || isNaturalNonWorkingDate(calendarDateAt(startIndex - 1, context), context)) {
+ context.check();
+ startIndex--;
+ }
+ while (context.declaredDates.has(calendarDateAt(endIndex + 1, context)) || isNaturalNonWorkingDate(calendarDateAt(endIndex + 1, context), context)) {
+ context.check();
+ endIndex++;
+ }
+ return { anchors, startIndex, endIndex };
+ });
+}
+function calendarPeriod(startIndex, endIndex, context) {
+ let periods = calendarPeriodCache.get(context);
+ if (!periods) {
+ periods = new Map;
+ calendarPeriodCache.set(context, periods);
+ }
+ const key = `${startIndex}:${endIndex}`;
+ let period = periods.get(key);
+ if (period)
+ periods.delete(key);
+ else {
+ const start = calendarDateAt(startIndex, context);
+ const end = calendarDateAt(endIndex, context);
+ const dates = startIndex >= 0 && endIndex < context.calendarDays.length ? context.calendarDays.slice(startIndex, endIndex + 1) : dateRange(start, addDays(end, 1));
+ period = Object.freeze({
+ start,
+ end,
+ duration: dates.length,
+ nonWorkingDates: Object.freeze(dates.filter((date) => isNaturalNonWorkingDate(date, context))),
+ declaredDates: Object.freeze(dates.filter((date) => context.declaredDates.has(date)))
+ });
+ }
+ periods.set(key, period);
+ if (periods.size > 4096)
+ periods.delete(periods.keys().next().value);
+ return period;
+}
+function periodData(state, context) {
+ return periodBounds(state, context).map(({ anchors, startIndex, endIndex }) => {
+ const period = calendarPeriod(startIndex, endIndex, context);
+ return {
+ start: period.start,
+ end: period.end,
+ duration: period.duration,
+ placementDates: anchors.filter((date) => state.placements.has(date)),
+ ...period.declaredDates.length ? { declaredDates: [...period.declaredDates] } : {},
+ nonWorkingDates: [...period.nonWorkingDates],
+ nonWorkingDayGain: period.nonWorkingDates.length
+ };
+ });
+}
+function optimizationBalanceObservations(state, context) {
+ return state.balances.map((balance) => ({
+ unplacedQuantity: balance.remaining,
+ atRisk: isAtRisk(balance, context)
+ }));
+}
+function optimizationObservations(state, context, periods) {
+ const key = placementTieKey(state);
+ let byDates = calendarObservationCache.get(context);
+ if (!byDates) {
+ byDates = new Map;
+ calendarObservationCache.set(context, byDates);
+ }
+ const calendar = byDates.get(key) ?? {};
+ if (byDates.has(key))
+ byDates.delete(key);
+ else if (byDates.size >= 4096)
+ byDates.delete(byDates.keys().next().value);
+ byDates.set(key, calendar);
+ let bounds;
+ return {
+ leaveBalances: optimizationBalanceObservations(state, context),
+ get publicHolidayWeeks() {
+ return calendar.publicHolidayWeeks ??= Object.freeze(context.holidayWeeks.map((week) => Object.freeze({
+ value: week.value,
+ complete: week.dates.every((date) => state.placements.has(date) || context.declaredDates.has(date))
+ })));
+ },
+ get timeOffPeriods() {
+ return calendar.timeOffPeriods ??= Object.freeze(periods ? periods.map((period) => Object.freeze({
+ nonWorkingDates: Object.freeze([...period.nonWorkingDates])
+ })) : (bounds ??= periodBounds(state, context)).map(({ startIndex, endIndex }) => Object.freeze({
+ nonWorkingDates: calendarPeriod(startIndex, endIndex, context).nonWorkingDates
+ })));
+ },
+ get workSequences() {
+ return calendar.workSequences ??= Object.freeze(workSequenceLengths(bounds ??= periodBounds(state, context), context).map((sequence) => Object.freeze(sequence)));
+ },
+ leavePlacements: [...state.placements.values()]
+ };
+}
+function workSequenceLengths(bounds, context) {
+ if (!context.workingDays.length)
+ return [];
+ let nextIndex = context.calendarIndex.get(context.horizon.start);
+ const sequences = bounds.map(({ anchors }, index) => {
+ const previousIndex = nextIndex;
+ nextIndex = context.calendarIndex.get(anchors[anchors.length - 1]) + 1;
+ return {
+ length: context.workingPrefix[context.calendarIndex.get(anchors[0])] - context.workingPrefix[previousIndex],
+ internal: index > 0
+ };
+ });
+ sequences.push({
+ length: context.workingPrefix[context.calendarIndex.get(context.horizon.end)] - context.workingPrefix[nextIndex],
+ internal: false
+ });
+ return sequences;
+}
+function workSequenceDates(state, context) {
+ const sequences = [];
+ let current = [];
+ let inTimeOffPeriod = false;
+ for (const date of context.workingDays) {
+ context.check();
+ if (state.placements.has(date) || context.declaredDates.has(date)) {
+ if (!inTimeOffPeriod)
+ sequences.push(current);
+ current = [];
+ inTimeOffPeriod = true;
+ } else {
+ current.push(date);
+ inTimeOffPeriod = false;
+ }
+ }
+ if (context.workingDays.length)
+ sequences.push(current);
+ return sequences.map((dates, index) => ({
+ dates,
+ internal: index > 0 && index < sequences.length - 1
+ }));
+}
+function evaluateStateOptimization(configuration, state, context, observations = optimizationObservations(state, context)) {
+ return evaluateOptimization(configuration, observations);
+}
+function qualityMetrics(state, context, preferences) {
+ const periods = periodData(state, context);
+ const observations = optimizationObservations(state, context, periods);
+ const nonEmptySequences = observations.workSequences.filter((sequence) => sequence.length > 0);
+ return {
+ unplacedAtRisk: evaluateStateOptimization(OPTIMIZATION_PRESETS.unplacedAtRisk, state, context, observations),
+ holidayWeekValue: evaluateStateOptimization(OPTIMIZATION_PRESETS.holidayWeekValue, state, context, observations),
+ nonWorkingDayGain: evaluateStateOptimization(OPTIMIZATION_PRESETS.nonWorkingDayGain, state, context, observations),
+ longestWorkSequence: evaluateStateOptimization(OPTIMIZATION_PRESETS.longestWorkSequence, state, context, observations),
+ averageWorkSequence: nonEmptySequences.length ? nonEmptySequences.reduce((sum, sequence) => sum + sequence.length, 0) / nonEmptySequences.length : null,
+ averageTimeOffPeriod: periods.length ? periods.reduce((sum, period) => sum + period.duration, 0) / periods.length : null,
+ longestTimeOffPeriod: periods.length ? Math.max(...periods.map((period) => period.duration)) : null,
+ shortSequenceDeficit: evaluateStateOptimization(OPTIMIZATION_PRESETS.shortSequenceDeficit, state, context, observations),
+ supportingPlacements: evaluateStateOptimization(OPTIMIZATION_PRESETS.supportingPlacements, state, context, observations),
+ preferenceSatisfaction: preferences.map((preference) => preference.satisfied)
+ };
+}
+function nonPlacementReason(balance, state, context, completed) {
+ if (balance.expiryDate < context.horizon.start)
+ return reason("unavoidable-expiry", "This balance expired before the planning horizon.");
+ if (balance.availableFrom >= context.horizon.end)
+ return reason("available-after-horizon", "This balance becomes available after the last planning date.");
+ const dates = context.workingDays.filter((date) => date >= balance.availableFrom && date <= balance.expiryDate);
+ if (!dates.length)
+ return reason("no-working-date", "There is no working date within both the horizon and this balance’s validity period.");
+ if (dates.every((date) => context.declaredDates.has(date) || state.placements.has(date)))
+ return reason("all-dates-occupied", "Every working date within this balance’s validity period is already occupied in the displayed plan.");
+ if (!completed)
+ return reason("calculation-incomplete", "The calculation stopped before completing its planning priorities.");
+ if (balance.expiryDate > context.horizon.end)
+ return reason("preserved", "This balance expires after the horizon; the planner retained no additional placement under its planning priorities.");
+ return reason("active-priorities", "The calculation retained no additional placement under the active planning rules.");
+}
+function remainingBalances(state, context, completed) {
+ return state.balances.filter((balance) => balance.remaining > 0).map((balance) => {
+ const classification = compareDate(balance.expiryDate, context.horizon.start) < 0 ? "unavoidable-expiry" : compareDate(balance.expiryDate, context.horizon.end) <= 0 ? "at-risk" : "deferred";
+ return {
+ id: balance.id,
+ type: balance.type,
+ quantity: balance.remaining,
+ availableFrom: balance.availableFrom,
+ expiryDate: balance.expiryDate,
+ classification,
+ reason: nonPlacementReason(balance, state, context, completed),
+ earned: balance.earned,
+ sourceRuleId: balance.sourceRuleId
+ };
+ });
+}
+function unsatisfiedObligationCount(state, context) {
+ const placementCounts = new Map;
+ for (const placement of state.placements.values())
+ for (const ruleId of placement.ruleIds)
+ placementCounts.set(ruleId, (placementCounts.get(ruleId) ?? 0) + 1);
+ let unsatisfied = 0;
+ for (const requirement of buildRequirements(context.rules, context, "obligation")) {
+ if (!requirement.patterns) {
+ if ((placementCounts.get(requirement.rule.id) ?? 0) !== requirement.requiredPlacements)
+ unsatisfied++;
+ continue;
+ }
+ let complete = false;
+ for (const pattern of requirement.patterns) {
+ if (pattern.every((date) => patternDateSatisfied(date, requirement.rule, state, context))) {
+ complete = true;
+ break;
+ }
+ }
+ if (!complete)
+ unsatisfied++;
+ }
+ return unsatisfied;
+}
+function objectiveVector(state, context, active = activeObjectiveVector(state, context)) {
+ return {
+ objectives: [
+ {
+ id: "obligation-validity",
+ direction: "minimize",
+ value: unsatisfiedObligationCount(state, context),
+ label: "Unsatisfied obligations"
+ },
+ ...active,
+ ...tieBreakObjectives(state)
+ ]
+ };
+}
+function displayLabel(value, fallback) {
+ return typeof value === "string" && value.trim() ? value : fallback;
+}
+function timeWindowContributions(state, context) {
+ if (!context.declaredDays.length)
+ return [];
+ const labels = recordValue(context.request.labels);
+ const identities = recordValue(context.request.identities);
+ return context.rules.filter((rule) => rule.enabled && rule.type === "time_window").flatMap((rule) => {
+ const declaredDays = declaredWindowDays(rule, context);
+ return declaredDays.length ? [
+ {
+ ruleId: rule.id,
+ label: displayLabel(labels?.[rule.id] ?? identities?.[rule.id] ?? rule.name, rule.id),
+ requested: rule.placeDays,
+ declaredDays,
+ placementDates: [...state.placements.values()].filter((placement) => placement.ruleIds.has(rule.id)).map(({ date }) => date).sort(compareDate)
+ }
+ ] : [];
+ });
+}
+function ifThenContributions(state, context) {
+ const labels = recordValue(context.request.labels);
+ const identities = recordValue(context.request.identities);
+ return context.rules.filter((rule) => rule.enabled && rule.type === "if_then").flatMap((rule) => occurrences(rule, context).flatMap((occurrence) => {
+ const id = `earned:${rule.id}:${occurrence}`;
+ const activation = state.earnedActivations.find((item) => item.id === id);
+ const pattern = selectedOccurrencePattern(rule, occurrence, state, context);
+ if (!pattern)
+ return [];
+ const declaredDays = pattern.flatMap((date) => {
+ const day = declaredPatternDay(date, rule.placeType, context);
+ return day ? [day] : [];
+ });
+ if (!declaredDays.length && !activation)
+ return [];
+ const bounds = activation ? earnedPeriodBounds(state, activation, context) : undefined;
+ return [
+ {
+ ruleId: rule.id,
+ label: displayLabel(labels?.[rule.id] ?? identities?.[rule.id] ?? rule.name, rule.id),
+ occurrence,
+ requested: rule.placeDays,
+ declaredDays,
+ placementDates: pattern.filter((date) => !context.declaredDates.has(date)),
+ ...activation && bounds ? {
+ earnedEntitlement: {
+ id,
+ type: rule.rewardType,
+ quantity: rule.rewardDays,
+ ...bounds,
+ added: bounds.availableFrom > context.request.calculationDate
+ }
+ } : {}
+ }
+ ];
+ }));
+}
+function planExplanation(state, context, placements, periods, preferences, remaining, metrics) {
+ const source = context.request;
+ const rawBalances = Array.isArray(source.balances) ? source.balances : [];
+ const rawRules = Array.isArray(source.rules) ? source.rules : [];
+ const labels = recordValue(source.labels);
+ const identities = recordValue(source.identities);
+ const vector = objectiveVector(state, context);
+ const windowContributions = timeWindowContributions(state, context);
+ const patternContributions = ifThenContributions(state, context);
+ return {
+ kind: "PlanExplanation",
+ calculationDate: context.request.calculationDate,
+ ...context.declaredDays.length ? { declaredDays: context.declaredDays } : {},
+ timeOffPeriods: periods,
+ ...windowContributions.length ? { timeWindowContributions: windowContributions } : {},
+ ...patternContributions.length ? { ifThenContributions: patternContributions } : {},
+ placements: placements.map((placement) => {
+ const balance = rawBalances.map(recordValue).find((candidate) => candidate?.id === placement.balanceId);
+ const normalizedBalance = state.balances.find((candidate) => candidate.id === placement.balanceId);
+ const period = periods.find((candidate) => candidate.placementDates.includes(placement.date));
+ const sourceRules = placement.ruleIds.map((id) => {
+ const rule = context.rules.find((candidate) => candidate.id === id);
+ const rawRule = rawRules.map(recordValue).find((candidate) => candidate?.id === id);
+ const label = displayLabel(labels?.[id] ?? identities?.[id] ?? rawRule?.name ?? rule?.name, id);
+ return { id, label };
+ });
+ const atRisk = normalizedBalance ? compareDate(normalizedBalance.expiryDate, context.horizon.start) >= 0 && compareDate(normalizedBalance.expiryDate, context.horizon.end) <= 0 : false;
+ const withoutPlacement = cloneState(state);
+ withoutPlacement.placements.delete(placement.date);
+ const released = withoutPlacement.balances.find((candidate) => candidate.id === placement.balanceId);
+ if (released)
+ released.remaining++;
+ const comparison = objectiveComparison(objectiveVector(withoutPlacement, context), vector);
+ const contributions = [
+ {
+ kind: "entitlement",
+ code: "balance",
+ label: displayLabel(labels?.[placement.balanceId] ?? identities?.[placement.balanceId] ?? balance?.label ?? balance?.name ?? balance?.id, placement.balanceId)
+ },
+ ...sourceRules.map((rule) => ({
+ kind: "rule",
+ code: "rule",
+ label: rule.label,
+ ruleId: rule.id
+ }))
+ ];
+ if (atRisk)
+ contributions.push({
+ kind: "at-risk",
+ code: "at-risk",
+ label: "Protects leave at risk of expiry."
+ });
+ if (placement.supporting)
+ contributions.push({
+ kind: "supporting",
+ code: "supporting",
+ label: "Supports the surrounding time-off plan."
+ });
+ if (period)
+ contributions.push({
+ kind: "time-off-period",
+ code: "time-off-period",
+ label: `Contributes to the time-off period ${period.start}–${period.end}.`,
+ dates: period.nonWorkingDates
+ });
+ for (const delta of comparison.delta) {
+ if (typeof delta.value !== "number" || delta.value === 0)
+ continue;
+ const objective = vector.objectives.find((candidate) => candidate.id === delta.id);
+ contributions.push({
+ kind: "metric",
+ code: objective.id,
+ label: objective.label ?? objective.id,
+ value: delta.value
+ });
+ }
+ const balanceSnapshot = balance ? {
+ id: typeof balance.id === "string" ? balance.id : placement.balanceId,
+ type: typeof balance.type === "string" ? balance.type : placement.type,
+ quantity: typeof balance.quantity === "number" ? balance.quantity : 0,
+ name: typeof balance.name === "string" ? balance.name : undefined,
+ label: typeof balance.label === "string" ? balance.label : undefined,
+ availableFrom: typeof balance.availableFrom === "string" || balance.availableFrom instanceof Date ? balance.availableFrom : normalizedBalance?.availableFrom,
+ expiryDate: typeof balance.expiryDate === "string" || balance.expiryDate instanceof Date ? balance.expiryDate : normalizedBalance?.expiryDate ?? ""
+ } : normalizedBalance ? {
+ id: normalizedBalance.id,
+ type: normalizedBalance.type,
+ quantity: normalizedBalance.quantity,
+ name: normalizedBalance.sourceRuleId ? `Earned from ${displayLabel(labels?.[normalizedBalance.sourceRuleId], normalizedBalance.sourceRuleId)}` : undefined,
+ availableFrom: normalizedBalance.availableFrom,
+ expiryDate: normalizedBalance.expiryDate
+ } : undefined;
+ return {
+ placement: { ...placement, ruleIds: [...placement.ruleIds] },
+ balance: balanceSnapshot,
+ ruleIds: [...placement.ruleIds],
+ supporting: placement.supporting,
+ timeOffPeriod: period,
+ remainingBalance: remaining.find((candidate) => candidate.id === placement.balanceId),
+ remainingQuantity: normalizedBalance?.remaining ?? 0,
+ contributions,
+ finalMetrics: metrics,
+ objectiveVector: vector,
+ comparison,
+ firstDecisiveObjective: comparison.firstDecisiveObjective
+ };
+ })
+ };
+}
+function makePlan(state, context, preferences, stages) {
+ const periods = periodData(state, context);
+ const metrics = qualityMetrics(state, context, preferences);
+ const placements = [...state.placements.values()].sort((a, b) => compareDate(a.date, b.date) || compareText(a.balanceId, b.balanceId)).map((placement) => ({
+ date: placement.date,
+ balanceId: placement.balanceId,
+ type: placement.type,
+ ruleIds: [...placement.ruleIds].sort(),
+ supporting: placement.supporting
+ }));
+ const remaining = remainingBalances(state, context, stages.includes("dates"));
+ const windowContributions = timeWindowContributions(state, context);
+ const patternContributions = ifThenContributions(state, context);
+ const plan = {
+ kind: "Plan",
+ calculationDate: context.request.calculationDate,
+ ...context.declaredDays.length ? { declaredDays: context.declaredDays } : {},
+ ...windowContributions.length ? { timeWindowContributions: windowContributions } : {},
+ ...patternContributions.length ? { ifThenContributions: patternContributions } : {},
+ horizon: context.horizon,
+ placements,
+ timeOffPeriods: periods,
+ preferences,
+ remainingBalances: remaining,
+ metrics,
+ completedStages: [...stages],
+ lastCompletedStage: stages[stages.length - 1] ?? "validation"
+ };
+ context.retain?.(state);
+ return plan;
+}
+function markProgress(context, stage) {
+ if (typeof context.request.progress === "function")
+ observeCallback(context, () => context.request.progress(stage));
+}
+function ruleStage(rule) {
+ return `${rule.type === "optimization" ? "optimization" : "preference"}:${rule.id}`;
+}
+function planningStages(context) {
+ return [
+ "validation",
+ "obligations",
+ ...context.rules.filter((rule) => rule.enabled && rule.force === "preference").map(ruleStage),
+ "expiry",
+ "dates"
+ ];
+}
+function conflictExplanation(outcome, context) {
+ const labels = recordValue(context.request.labels);
+ return {
+ kind: "ConflictExplanation",
+ conflictingRules: outcome.conflictingRuleIds.map((id) => ({
+ id,
+ label: displayLabel(labels?.[id] ?? context.rules.find((rule) => rule.id === id)?.name, id)
+ })),
+ bottleneck: outcome.reasons[0]
+ };
+}
+function obligationConflict(requirements, context) {
+ const ruleIds = [...new Set(requirements.map((requirement) => requirement.rule.id))];
+ const generatedTypes = new Set(context.rules.filter((rule) => rule.enabled && rule.type === "if_then" && (rule.rewardDays ?? 0) > 0 && !!rule.rewardType).map((rule) => rule.rewardType));
+ let generatedCapacityBottleneck = false;
+ for (const requirement of requirements) {
+ if (requirement.rule.placeType === "any")
+ continue;
+ const required = requirement.requiredPlacements;
+ const capacity = context.initialBalances.filter((balance) => balance.type === requirement.rule.placeType).reduce((sum, balance) => sum + balance.quantity, 0);
+ if (capacity < required) {
+ if (!generatedTypes.has(requirement.rule.placeType))
+ return {
+ kind: "PlanningConflict",
+ conflictingRuleIds: ruleIds,
+ reasons: [
+ reason("typed-capacity", `The obligations require more ${requirement.rule.placeType.toUpperCase()} leave than is available.`, ruleIds)
+ ]
+ };
+ generatedCapacityBottleneck = true;
+ }
+ }
+ if (requirements.some((requirement) => requirement.patterns !== undefined && requirement.patterns.length === 0 || requirement.candidates !== undefined && requirement.requiredPlacements > 0 && requirement.candidates.length === 0))
+ return {
+ kind: "PlanningConflict",
+ conflictingRuleIds: ruleIds,
+ reasons: [
+ reason("horizon-bottleneck", "The planning horizon contains no eligible dates for at least one obligation.", ruleIds)
+ ]
+ };
+ if (generatedCapacityBottleneck)
+ return {
+ kind: "PlanningConflict",
+ conflictingRuleIds: ruleIds,
+ reasons: [
+ reason("earned-entitlement-bottleneck", "An earned leave entitlement is not available early enough to satisfy every obligation.", ruleIds)
+ ]
+ };
+ return {
+ kind: "PlanningConflict",
+ conflictingRuleIds: ruleIds,
+ reasons: [
+ reason("joint-date-conflict", "The obligation date patterns cannot be satisfied together.", ruleIds)
+ ]
+ };
+}
+function calculatePlanningInternal(request, checkpointCallback, artifactsCallback, outcomeCallback, explanationCallback) {
+ const steps = calculatePlanningSteps(request, checkpointCallback, artifactsCallback, outcomeCallback, explanationCallback);
+ let next = steps.next();
+ while (!next.done)
+ next = steps.next();
+ return next.value;
+}
+async function calculatePlanningAsyncInternal(request, checkpointCallback, artifactsCallback, outcomeCallback, explanationCallback, isCancelled = () => false) {
+ const steps = calculatePlanningSteps(request, checkpointCallback, artifactsCallback, outcomeCallback, explanationCallback, true);
+ let next = steps.next();
+ let pauseAt = now() + 8;
+ while (!next.done) {
+ if (next.value || now() >= pauseAt) {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ pauseAt = now() + 8;
+ }
+ next = isCancelled() ? steps.throw(new UserCancelled) : steps.next();
+ }
+ return next.value;
+}
+function* prepareCalendar(horizon, progress) {
+ const start = addDays(horizon.start, -400);
+ const end = addDays(horizon.end, 400);
+ const holidays = new Set;
+ for (let year = Number(start.slice(0, 4));year <= Number(end.slice(0, 4)); year++) {
+ yield true;
+ for (const holiday of frenchPublicHolidaysForYear(year))
+ holidays.add(holiday);
+ progress();
+ }
+ const workingDays = [];
+ const calendarDays = [];
+ const calendarIndex = new Map;
+ const workingCalendar = new Set;
+ const workingPrefix = [0];
+ for (let date = start;compareDate(date, end) < 0; date = addDays(date, 1)) {
+ yield;
+ calendarIndex.set(date, calendarDays.length);
+ calendarDays.push(date);
+ const working = isWorking(date, holidays);
+ if (working) {
+ workingCalendar.add(date);
+ if (date >= horizon.start && date < horizon.end)
+ workingDays.push(date);
+ }
+ workingPrefix.push(workingPrefix[workingPrefix.length - 1] + Number(working));
+ }
+ return { holidays, workingDays, calendarDays, calendarIndex, workingCalendar, workingPrefix };
+}
+function* calculatePlanningSteps(request, checkpointCallback, artifactsCallback, outcomeCallback, explanationCallback, retainIncumbents = false) {
+ const source = runtimeRequest(request);
+ const normalized = normalizeRequest(source);
+ if (normalized.reasons.length) {
+ const outcome = { ...invalidInputFor(source), calculationDate: source.calculationDate };
+ outcomeCallback?.(outcome);
+ artifactsCallback?.({});
+ return outcome;
+ }
+ const horizon = normalized.horizon;
+ let calendar;
+ try {
+ if (typeof source.progress === "function")
+ source.progress("preparation");
+ yield true;
+ calendar = yield* prepareCalendar(horizon, () => {
+ if (typeof source.progress === "function")
+ source.progress("preparation");
+ });
+ } catch (error) {
+ if (!(error instanceof UserCancelled))
+ throw error;
+ const outcome = {
+ kind: "PlanningIncomplete",
+ calculationDate: source.calculationDate,
+ completedStages: ["validation"],
+ lastCompletedStage: "validation",
+ reasons: [reason("user-cancelled", "The calculation was cancelled by the user.")]
+ };
+ const explanation = {
+ kind: "IncompleteExplanation",
+ calculationDate: source.calculationDate,
+ reason: outcome.reasons[0],
+ completedStages: outcome.completedStages,
+ uncompletedStages: ["preparation", "obligations"]
+ };
+ outcomeCallback?.(outcome);
+ explanationCallback?.(explanation, undefined, undefined);
+ artifactsCallback?.({
+ explanation
+ });
+ return outcome;
+ }
+ const { holidays, workingDays, calendarDays, calendarIndex, workingCalendar, workingPrefix } = calendar;
+ const started = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
+ const deadline = typeof source.deadlineMs === "number" ? source.deadlineMs : Number.POSITIVE_INFINITY;
+ const context = {
+ horizon,
+ holidays,
+ holidayWeeks: [],
+ calendarDays,
+ calendarIndex,
+ workingCalendar,
+ workingPrefix,
+ workingDays,
+ declaredDays: normalized.declaredDays,
+ declaredDates: new Set(normalized.declaredDays.map(({ date }) => date)),
+ initialBalances: normalized.balances,
+ rules: normalized.rules,
+ deadline,
+ started,
+ request: source,
+ nodes: 0,
+ check: () => {
+ return;
+ },
+ reservedTypes: new Set,
+ sharedTypesByDate: new Map,
+ protectedTypesByDate: new Map,
+ preferenceDemandByDate: new Map,
+ anyPreferenceDemandByDate: new Map,
+ observerTime: 0,
+ completedPreferenceStates: new Map
+ };
+ context.holidayWeeks = holidayWeekCandidates(context);
+ context.check = () => {
+ context.nodes++;
+ if (context.deadline <= 1 || context.nodes % 16 === 0) {
+ if (now() - context.started - context.observerTime >= context.deadline)
+ throw new DeadlineReached;
+ }
+ };
+ const initialState = {
+ balances: normalized.balances.map((balance) => ({
+ ...balance,
+ remaining: balance.quantity
+ })),
+ placements: new Map,
+ earnedActivations: []
+ };
+ const stages = ["validation"];
+ let state = initialState;
+ const preferences = [];
+ let checkpoint;
+ let checkpointState;
+ let retained;
+ if (retainIncumbents)
+ context.retain = (candidate, finalizedMetrics, candidateVector) => {
+ const check = context.check;
+ context.check = () => {
+ return;
+ };
+ try {
+ const finalized = finalizedMetrics || candidateVector ? candidate : finalizeEarnedState(candidate, context);
+ if (!finalized)
+ return;
+ if (unsatisfiedObligationCount(finalized, context))
+ return;
+ if (retained && preferenceStateKey(retained.state) === preferenceStateKey(finalized))
+ return;
+ const vector = candidateVector ?? objectiveVector(finalized, context);
+ const compared = retained ? compareObjectiveLists(vector.objectives, retained.vector.objectives) : -1;
+ const provenanceRefresh = compared === 0 && !!retained && JSON.stringify(retained.state.balances) === JSON.stringify(finalized.balances) && retained.state.placements.size === finalized.placements.size && [...retained.state.placements].every(([date, previous]) => {
+ const placement = finalized.placements.get(date);
+ return placement && placement.balanceId === previous.balanceId && placement.type === previous.type && placement.supporting === previous.supporting;
+ }) && JSON.stringify(retained.state.earnedActivations) === JSON.stringify(finalized.earnedActivations);
+ const chosen = compared < 0 || provenanceRefresh;
+ if (chosen) {
+ const firstValidCandidate = !retained;
+ const candidatePreferences = preferenceResultsForState(finalized, context);
+ retained = {
+ state: cloneState(reconcileOptimizationProvenance(finalized, context, retained?.state ?? initialState, vector.objectives)),
+ vector,
+ preferences: candidatePreferences,
+ metrics: finalizedMetrics ? {
+ ...finalizedMetrics,
+ preferenceSatisfaction: candidatePreferences.map(({ satisfied }) => satisfied)
+ } : qualityMetrics(finalized, context, candidatePreferences)
+ };
+ if (firstValidCandidate && checkpointCallback)
+ observeCallback(context, () => checkpointCallback(makePlan(retained.state, context, retained.preferences, stages), planningStateSnapshot(retained.state, context)));
+ }
+ } finally {
+ context.check = check;
+ }
+ };
+ const finish = (outcome, explanationState) => {
+ outcome.calculationDate = source.calculationDate;
+ const planningFinishedAt = now();
+ const planningExcludedTime = context.observerTime;
+ outcomeCallback?.(outcome);
+ context.check = () => {
+ return;
+ };
+ let explanation;
+ let explanationError;
+ let finalState;
+ try {
+ if (outcome.kind === "Plan" && explanationState)
+ explanation = planExplanation(explanationState, context, outcome.placements, outcome.timeOffPeriods, outcome.preferences, outcome.remainingBalances, outcome.metrics);
+ else if (outcome.kind === "PlanningIncomplete") {
+ if (outcome.incumbent && explanationState) {
+ const incumbentExplanation = planExplanation(explanationState, context, outcome.incumbent.placements, outcome.incumbent.timeOffPeriods, outcome.incumbent.preferences, outcome.incumbent.remainingBalances, outcome.incumbent.metrics);
+ explanation = {
+ ...incumbentExplanation,
+ incomplete: {
+ kind: "IncompleteExplanation",
+ reason: outcome.reasons[0],
+ completedStages: [...outcome.completedStages],
+ uncompletedStages: planningStages(context).filter((stage) => !outcome.completedStages.includes(stage))
+ }
+ };
+ } else
+ explanation = {
+ kind: "IncompleteExplanation",
+ reason: outcome.reasons[0],
+ completedStages: [...outcome.completedStages],
+ uncompletedStages: planningStages(context).filter((stage) => !outcome.completedStages.includes(stage))
+ };
+ } else if (outcome.kind === "PlanningConflict")
+ explanation = conflictExplanation(outcome, context);
+ } catch (error) {
+ explanationError = reason("explanation-error", error instanceof Error ? error.message : String(error));
+ }
+ try {
+ if (explanationState)
+ finalState = planningStateSnapshot(explanationState, context);
+ } catch (error) {
+ explanationError ??= reason("explanation-error", error instanceof Error ? error.message : String(error));
+ }
+ if (explanation)
+ explanation.calculationDate = source.calculationDate;
+ try {
+ explanationCallback?.(explanation, explanationError, finalState);
+ } catch (error) {
+ explanationError ??= reason("explanation-error", error instanceof Error ? error.message : String(error));
+ }
+ artifactsCallback?.({
+ explanation,
+ finalState,
+ explanationError,
+ timings: {
+ planningWorkMs: Math.max(0, planningFinishedAt - started - planningExcludedTime)
+ }
+ });
+ return outcome;
+ };
+ const publishCheckpoint = () => {
+ state = reconcileOptimizationProvenance(state, context, checkpointState ?? initialState);
+ context.retain?.(state);
+ checkpoint = makePlan(state, context, preferences, stages);
+ checkpointState = cloneState(state);
+ if (checkpointCallback) {
+ observeCallback(context, () => checkpointCallback(checkpoint, planningStateSnapshot(checkpointState, context)));
+ }
+ };
+ try {
+ markProgress(context, "validation");
+ yield true;
+ if (context.declaredDays.length) {
+ for (const rule of context.rules) {
+ if (!rule.enabled || rule.type !== "if_then" || !rule.rewardDays)
+ continue;
+ for (const occurrence of occurrences(rule, context)) {
+ yield;
+ const pattern = patternsFor(rule, occurrence, context).find((dates) => dates.every((date) => declaredPatternDay(date, rule.placeType, context)));
+ if (pattern)
+ state = addEarnedBalance(state, rule, occurrence, pattern, context);
+ }
+ }
+ if (state !== initialState)
+ context.retain?.(state);
+ }
+ context.protectedTypesByDate = futurePreferenceProtection(normalized.rules, 0, context);
+ markProgress(context, "obligations");
+ yield true;
+ const obligationRequirements = buildRequirements(normalized.rules, context, "obligation");
+ context.reservedTypes = new Set(obligationRequirements.filter((requirement) => requirement.requiredPlacements > 0).map((requirement) => requirement.rule.placeType).filter((selector) => selector !== "any"));
+ for (const requirement of obligationRequirements) {
+ yield;
+ if (!requirement.candidates || !requirement.requiredPlacements || requirement.candidates.length !== 1 || requirement.rule.placeType === "any")
+ continue;
+ const types = context.sharedTypesByDate.get(requirement.candidates[0]) ?? new Set;
+ types.add(requirement.rule.placeType);
+ context.sharedTypesByDate.set(requirement.candidates[0], types);
+ }
+ const obligationSolutions = [];
+ const optionalProducers = obligationRequirements.some((requirement) => requirement.requiredPlacements > 0) ? buildRequirements(normalized.rules, context, "preference").filter((requirement) => requirement.patterns?.length && requirement.rule.rewardDays && requirement.requiredPlacements > 0).map((requirement) => ({ ...requirement, optional: true })) : [];
+ const jointRequirements = optionalProducers.length ? [...obligationRequirements, ...optionalProducers].sort(compareRequirements) : obligationRequirements;
+ const hasEarnedObligation = optionalProducers.length > 0 || obligationRequirements.some((requirement) => (requirement.rule.rewardDays ?? 0) > 0);
+ const obligationSolutionLimit = hasEarnedObligation || obligationBranchEstimate(obligationRequirements) <= EXACT_OBLIGATION_BRANCH_LIMIT ? 0 : OBLIGATION_SOLUTION_LIMIT;
+ yield* satisfyObligations(jointRequirements, state, context, 0, obligationSolutions, obligationSolutionLimit);
+ if (!obligationSolutions.length) {
+ stages.push("obligations");
+ return finish(obligationConflict(obligationRequirements, context));
+ }
+ 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) {
+ const ranked = [];
+ for (const candidate of obligationSolutions) {
+ yield;
+ context.check();
+ ranked.push({
+ state: candidate,
+ objectives: obligationPreviewObjectives({ state: candidate }, context)
+ });
+ }
+ ranked.sort((a, b) => compareObjectiveLists(a.objectives, b.objectives));
+ previewSolutions = ranked.slice(0, WINDOW_OBLIGATION_PREVIEW_LIMIT).map(({ state: state2 }) => state2);
+ }
+ const soleSolution = obligationSolutions.length === 1 && (obligationSolutionLimit === 0 || obligationSolutions.length < obligationSolutionLimit) ? obligationSolutions[0] : undefined;
+ const solePreferences = soleSolution ? preferenceResultsForState(soleSolution, context) : [];
+ let bestPreview = 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 = 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");
+ 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) {
+ 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);
+ } catch (error) {
+ if (error instanceof UserCancelled && retained) {
+ context.check = () => {
+ return;
+ };
+ context.retain = undefined;
+ checkpointState = retained.state;
+ const partialPreferences = retained.preferences.map((preference) => ({
+ ...preference,
+ reason: preferenceReason(normalized.rules.find((rule) => rule.type !== "optimization" && rule.id === preference.ruleId), preference.satisfied, context, retained.state)
+ }));
+ checkpoint = makePlan(checkpointState, context, partialPreferences, stages);
+ }
+ const interruption = error instanceof UserCancelled ? reason("user-cancelled", "The calculation was cancelled by the user.") : error instanceof DeadlineReached ? reason("deadline", `Planning stopped after ${context.deadline}ms before all quality stages completed.`) : reason("planner-error", error instanceof Error ? error.message : String(error));
+ return finish({
+ kind: "PlanningIncomplete",
+ incumbent: checkpoint,
+ completedStages: [...stages],
+ lastCompletedStage: stages[stages.length - 1],
+ reasons: [interruption]
+ }, checkpointState);
+ }
+}
+function explainIncompleteCheckpoint(request, incumbent, checkpoint, interruption) {
+ try {
+ const source = runtimeRequest(request, incumbent.calculationDate ?? checkpoint.calculationDate);
+ const normalized = normalizeRequest(source);
+ if (normalized.reasons.length || !normalized.horizon)
+ throw new Error("The checkpoint explanation request is invalid.");
+ const horizon = normalized.horizon;
+ const holidays = holidaySet(horizon);
+ const calendarDays = dateRange(addDays(horizon.start, -400), addDays(horizon.end, 400));
+ const calendarIndex = new Map(calendarDays.map((date, index) => [date, index]));
+ const workingCalendar = new Set(calendarDays.filter((date) => isWorking(date, holidays)));
+ const workingPrefix = [0];
+ for (const date of calendarDays)
+ workingPrefix.push(workingPrefix[workingPrefix.length - 1] + Number(workingCalendar.has(date)));
+ const context = {
+ horizon,
+ holidayWeeks: [],
+ holidays,
+ calendarDays,
+ calendarIndex,
+ workingCalendar,
+ workingPrefix,
+ workingDays: dateRange(horizon.start, horizon.end).filter((date) => isWorking(date, holidays)),
+ declaredDays: normalized.declaredDays,
+ declaredDates: new Set(normalized.declaredDays.map(({ date }) => date)),
+ initialBalances: normalized.balances,
+ rules: normalized.rules,
+ deadline: Number.POSITIVE_INFINITY,
+ started: 0,
+ request: source,
+ nodes: 0,
+ check: () => {
+ return;
+ },
+ reservedTypes: new Set,
+ sharedTypesByDate: new Map,
+ protectedTypesByDate: new Map,
+ preferenceDemandByDate: new Map,
+ anyPreferenceDemandByDate: new Map,
+ observerTime: 0,
+ completedPreferenceStates: new Map
+ };
+ context.holidayWeeks = holidayWeekCandidates(context);
+ const state = {
+ balances: checkpoint.balances.map((balance) => ({ ...balance })),
+ placements: new Map(checkpoint.placements.map((placement) => [
+ placement.date,
+ { ...placement, ruleIds: new Set(placement.ruleIds) }
+ ])),
+ earnedActivations: checkpoint.earnedActivations.map((activation) => ({
+ ...activation,
+ dates: [...activation.dates]
+ }))
+ };
+ const explanation = planExplanation(state, context, incumbent.placements, incumbent.timeOffPeriods, incumbent.preferences, incumbent.remainingBalances, incumbent.metrics);
+ const uncompletedStages = planningStages(context).filter((stage) => !incumbent.completedStages.includes(stage));
+ return {
+ explanation: uncompletedStages.length === 0 ? explanation : {
+ ...explanation,
+ incomplete: {
+ kind: "IncompleteExplanation",
+ reason: interruption,
+ completedStages: [...incumbent.completedStages],
+ uncompletedStages
+ }
+ },
+ finalState: planningStateSnapshot(state, context)
+ };
+ } catch (error) {
+ return {
+ explanationError: reason("explanation-error", error instanceof Error ? error.message : String(error))
+ };
+ }
+}
+function counterfactualReason(code, message) {
+ return { code, message };
+}
+function counterfactualContext(source, normalized, horizon) {
+ const holidays = holidaySet(horizon);
+ const calendarDays = dateRange(addDays(horizon.start, -400), addDays(horizon.end, 400));
+ const calendarIndex = new Map(calendarDays.map((date, index) => [date, index]));
+ const workingCalendar = new Set(calendarDays.filter((date) => isWorking(date, holidays)));
+ const workingPrefix = [0];
+ for (const date of calendarDays)
+ workingPrefix.push(workingPrefix[workingPrefix.length - 1] + Number(workingCalendar.has(date)));
+ const context = {
+ horizon,
+ holidayWeeks: [],
+ holidays,
+ calendarDays,
+ calendarIndex,
+ workingCalendar,
+ workingPrefix,
+ workingDays: dateRange(horizon.start, horizon.end).filter((date) => isWorking(date, holidays)),
+ declaredDays: normalized.declaredDays,
+ declaredDates: new Set(normalized.declaredDays.map(({ date }) => date)),
+ initialBalances: normalized.balances,
+ rules: normalized.rules,
+ deadline: Number.POSITIVE_INFINITY,
+ started: 0,
+ request: source,
+ nodes: 0,
+ check: () => {
+ return;
+ },
+ reservedTypes: new Set,
+ sharedTypesByDate: new Map,
+ protectedTypesByDate: new Map,
+ preferenceDemandByDate: new Map,
+ anyPreferenceDemandByDate: new Map,
+ observerTime: 0,
+ completedPreferenceStates: new Map
+ };
+ context.holidayWeeks = holidayWeekCandidates(context);
+ return context;
+}
+function stateFromPlanningSnapshot(state) {
+ return {
+ balances: state.balances.map((balance) => ({ ...balance })),
+ placements: new Map(state.placements.map((placement) => [
+ placement.date,
+ { ...placement, ruleIds: new Set(placement.ruleIds) }
+ ])),
+ earnedActivations: state.earnedActivations.map((activation) => ({
+ ...activation,
+ dates: [...activation.dates]
+ }))
+ };
+}
+function sameObjectiveValue(a, b) {
+ return a === b;
+}
+function objectiveComparison(before, after) {
+ const delta = after.objectives.map((objective, index) => {
+ const previous = before.objectives[index];
+ const value = typeof objective.value === "number" && typeof previous?.value === "number" ? objective.value - previous.value : sameObjectiveValue(objective.value, previous?.value ?? objective.value) ? 0 : null;
+ return {
+ id: objective.id,
+ direction: objective.direction,
+ value,
+ label: objective.label
+ };
+ });
+ const first = firstDifferentObjective(after.objectives, before.objectives);
+ const decisive = first >= 0 ? after.objectives[first].id : undefined;
+ return {
+ before,
+ after,
+ delta,
+ firstDecisiveObjective: decisive,
+ tieBreak: decisive === "balance-expiry" ? "balance-expiry" : decisive === "placement-date" ? "placement-date" : undefined
+ };
+}
+function counterfactualRuleValid(date, type, ruleIds, context, baseline) {
+ for (const ruleId of ruleIds) {
+ const rule = context.rules.find((candidate) => candidate.id === ruleId);
+ if (!rule)
+ return false;
+ if (rule.type === "optimization")
+ continue;
+ if (rule.type === "time_window") {
+ if (!selectorMatches(rule.placeType, type) || compareDate(date, rule.startDate) < 0 || compareDate(date, rule.endDate) > 0)
+ return false;
+ continue;
+ }
+ const matchesPattern = occurrences(rule, context).some((occurrence) => patternsFor(rule, occurrence, context).some((pattern) => pattern.includes(date) && pattern.every((candidateDate) => candidateDate === date || patternDateSatisfied(candidateDate, rule, baseline, context))));
+ if (!matchesPattern || !selectorMatches(rule.placeType, type))
+ return false;
+ }
+ return true;
+}
+function counterfactualObligationsValid(state, context, previous) {
+ return context.rules.filter((rule) => rule.enabled && rule.force === "obligation").every((rule) => {
+ if (rule.type === "time_window") {
+ const count = (candidate) => [...candidate.placements.values()].filter((placement) => placement.ruleIds.has(rule.id)).length;
+ const requiredPlacements = rule.placeDays - declaredWindowDays(rule, context).length;
+ return count(previous) !== requiredPlacements || count(state) === requiredPlacements;
+ }
+ return occurrences(rule, context).every((occurrence) => {
+ const patterns = patternsFor(rule, occurrence, context);
+ const complete = (candidate) => patterns.some((pattern) => pattern.every((date) => patternDateSatisfied(date, rule, candidate, context)));
+ return !complete(previous) || complete(state);
+ });
+ });
+}
+function preferenceResultsForState(state, context) {
+ let patternsByRule = preferencePatternCache.get(context);
+ if (!patternsByRule) {
+ patternsByRule = new Map(context.rules.filter((rule) => rule.enabled && rule.force === "preference" && rule.type === "if_then").map((rule) => [
+ rule.id,
+ occurrences(rule, context).map((occurrence) => patternsFor(rule, occurrence, context))
+ ]));
+ preferencePatternCache.set(context, patternsByRule);
+ }
+ return context.rules.filter((rule) => rule.enabled && rule.force === "preference" && rule.type !== "optimization").map((rule) => {
+ const occurrencePatterns = patternsByRule.get(rule.id) ?? [];
+ const requested = rule.type === "time_window" ? rule.placeDays : occurrencePatterns.length;
+ const satisfied = rule.type === "time_window" ? Math.min(rule.placeDays, declaredWindowDays(rule, context).length + [...state.placements.values()].filter((placement) => placement.date >= rule.startDate && placement.date <= rule.endDate && selectorMatches(rule.placeType, placement.type)).length) : occurrencePatterns.filter((patterns) => patterns.some((pattern) => pattern.every((date) => patternDateSatisfied(date, rule, state, context)))).length;
+ return {
+ ruleId: rule.id,
+ requested,
+ satisfied,
+ completeOccurrences: rule.type === "if_then" ? satisfied : 0
+ };
+ });
+}
+function analyzeCounterfactualInternal(request, plan, selected, finalStateSnapshot) {
+ const source = runtimeRequest(request, plan.calculationDate ?? finalStateSnapshot?.calculationDate);
+ const normalized = normalizeRequest(source);
+ const horizon = normalized.horizon ?? plan.horizon;
+ const context = counterfactualContext(source, normalized, horizon);
+ const finalState = finalStateSnapshot ? stateFromPlanningSnapshot(finalStateSnapshot) : (() => {
+ const balances = normalized.balances.map((balance) => ({
+ ...balance,
+ remaining: balance.quantity
+ }));
+ for (const placement of plan.placements) {
+ let balance = balances.find((candidate) => candidate.id === placement.balanceId);
+ if (!balance) {
+ const remaining = plan.remainingBalances.find((candidate) => candidate.id === placement.balanceId);
+ balance = {
+ id: placement.balanceId,
+ type: placement.type,
+ quantity: 0,
+ remaining: 0,
+ availableFrom: remaining?.availableFrom ?? horizon.start,
+ expiryDate: remaining?.expiryDate ?? horizon.end,
+ earned: remaining?.earned ?? true,
+ sourceRuleId: remaining?.sourceRuleId
+ };
+ balances.push(balance);
+ }
+ balance.remaining = Math.max(0, balance.remaining - 1);
+ }
+ return {
+ balances,
+ placements: new Map(plan.placements.map((placement) => [
+ placement.date,
+ { ...placement, ruleIds: new Set(placement.ruleIds) }
+ ])),
+ earnedActivations: []
+ };
+ })();
+ const comparisonState = cloneState(finalState);
+ const baseline = cloneState(finalState);
+ const removeDate = selected.date;
+ const removed = removeDate ? baseline.placements.get(removeDate) : undefined;
+ if (removeDate)
+ baseline.placements.delete(removeDate);
+ const released = baseline.balances.find((balance) => balance.id === selected.balanceId);
+ if (released && removed?.balanceId === selected.balanceId)
+ released.remaining++;
+ const beforeState = baseline;
+ const before = objectiveVector(beforeState, context);
+ const results = [];
+ for (const date of dateRange(horizon.start, horizon.end)) {
+ let ineligible;
+ if (!isWorking(date, context.holidays))
+ ineligible = counterfactualReason("non-working", "The date is a weekend or public holiday.");
+ else if (context.declaredDates.has(date))
+ ineligible = counterfactualReason("declared-day", "The date is a declared non-working day fixed by the user.");
+ else if (baseline.placements.has(date))
+ ineligible = counterfactualReason("occupied", "Another final placement already uses this date.");
+ else if (!released)
+ ineligible = counterfactualReason("unavailable", "The selected leave entitlement is unavailable.");
+ else if (compareDate(released.availableFrom, date) > 0)
+ ineligible = counterfactualReason("unavailable", "The selected entitlement is not available yet.");
+ else if (compareDate(date, released.expiryDate) > 0)
+ ineligible = counterfactualReason("expired", "The selected entitlement has expired by this date.");
+ else if (released.type !== selected.type)
+ ineligible = counterfactualReason("type-incompatible", "The date cannot host this leave type.");
+ else if (!counterfactualRuleValid(date, selected.type, selected.ruleIds, context, baseline))
+ ineligible = counterfactualReason("rule-invalid", "The date does not preserve the placement rules.");
+ if (ineligible) {
+ results.push({ date, status: "ineligible", reason: ineligible });
+ continue;
+ }
+ const candidate = assignDateToBalance(baseline, date, selected.balanceId, context, new Set(selected.ruleIds));
+ if (!candidate) {
+ results.push({
+ date,
+ status: "ineligible",
+ reason: counterfactualReason("unavailable", "The selected entitlement cannot be placed on this date.")
+ });
+ continue;
+ }
+ candidate.placements.get(date).supporting = selected.supporting;
+ if (removeDate && removeDate !== date)
+ for (const activation of candidate.earnedActivations) {
+ if (!selected.ruleIds.includes(activation.ruleId))
+ continue;
+ activation.dates = activation.dates.map((activationDate) => activationDate === removeDate ? date : activationDate);
+ }
+ const finalized = finalizeEarnedState(candidate, context);
+ if (!finalized) {
+ results.push({
+ date,
+ status: "ineligible",
+ reason: counterfactualReason("unavailable", "This date would make generated leave unavailable until its producing period ends.")
+ });
+ continue;
+ }
+ if (!counterfactualObligationsValid(finalized, context, comparisonState)) {
+ results.push({
+ date,
+ status: "ineligible",
+ reason: counterfactualReason("rule-invalid", "The date would leave an obligation unsatisfied.")
+ });
+ continue;
+ }
+ const after = objectiveVector(finalized, context);
+ const comparison = objectiveComparison(before, after);
+ results.push({ date, status: "eligible", comparison });
+ }
+ return {
+ kind: "Counterfactual",
+ calculationId: "",
+ placement: { ...selected, ruleIds: [...selected.ruleIds] },
+ hypothetical: true,
+ baseline: before,
+ results
+ };
+}
+export {
+ invalidInputFor,
+ explainIncompleteCheckpoint,
+ calculatePlanningInternal,
+ calculatePlanningAsyncInternal,
+ analyzeCounterfactualInternal
+};