Files
vac-optimizer/src/app/services/frozen-engine/leave-planner.core.frozen.mjs
T
nessarandClaude Opus 5.5 4d26e65672 test(planning): compare the engine against a frozen engine on random requests
Bundle the planning core at a871f8f (develop after PR #278) as a frozen
fixture and compare it with the current engine on 5,000 seeded random
requests. Outcomes, stable explanations, explanation errors and final-state
snapshots must be strictly deep-equal. Requests cover obligations,
preferences, IF_THEN and TIME_WINDOW rules, earned entitlements, composed
optimization rules, declared days, year boundaries and public holidays.

Refs #280

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:30:30 +02:00

3223 lines
141 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
};