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