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 +};