import { DeclaredNonWorkingDay, parseDeclaredDays, frenchPublicHolidaysForYear, } from './declared-non-working-days'; import { addDays, compareDate, dateRange, evaluateStateOptimization, expiryTieKey, isAtRisk, optimizationBalanceObservations, optimizationObservations, periodData, derivePlacements, notePlacementAdded, notePlacementRemoved, placementTieKey, utcDate, workSequenceDates, } from './plan-observations'; import { addPlanningYears, isPlanningDate, planningDateInParis } from './planning-date'; import { compareObjectiveLists, compareObjectiveValues, evaluateOptimization, firstDifferentObjective, OPTIMIZATION_PRESETS, validateOptimizationConfiguration, type OptimizationConfiguration, type OptimizationObservations, } from './planning-objectives'; export type { DeclaredNonWorkingDay } from './declared-non-working-days'; /** Date-only values are serialized as YYYY-MM-DD at the planning boundary. */ export type PlanningDate = string | Date; export type LeaveType = 'cp' | 'rtt' | 'other'; export type LeaveSelector = LeaveType | 'any'; export type RuleForce = 'obligation' | 'preference'; export type PlacementMode = 'before' | 'after' | 'around' | 'auto'; export interface PlanningHorizon { start: PlanningDate; end: PlanningDate; } export interface LeaveBalanceInput { id: string; type: LeaveType | string; quantity: number; availableFrom?: PlanningDate; expiryDate: PlanningDate; name?: string; label?: string; } export interface TimeWindowPlanningRule { id: string; name?: string; type: 'time_window' | 'TIME_WINDOW' | string; enabled?: boolean; force?: RuleForce | string; startDate: PlanningDate; endDate: PlanningDate; placeDays: number; placeType: LeaveSelector | string; } export interface IfThenPlanningRule { id: string; name?: string; type: 'if_then' | 'IF_THEN' | string; enabled?: boolean; force?: RuleForce | string; dayOfWeek: number; placeDays: number; placeType: LeaveSelector | string; placement: PlacementMode | string; rewardDays: number; rewardType?: LeaveType | string; } export interface OptimizationPlanningRule { id: string; name?: string; type: 'optimization'; enabled?: boolean; force?: 'preference'; configuration: OptimizationConfiguration; } export type PlanningRule = TimeWindowPlanningRule | IfThenPlanningRule | OptimizationPlanningRule; export interface PlanningRequest { horizon: PlanningHorizon; balances: readonly LeaveBalanceInput[]; rules: readonly PlanningRule[]; declaredDays?: readonly DeclaredNonWorkingDay[]; /** Optional caller-owned display identities copied into a calculation snapshot. */ identities?: unknown; /** Optional caller-owned human-readable labels copied into a calculation snapshot. */ labels?: unknown; /** Opt-in planning-work budget in milliseconds; omitted means run until completion or cancellation. */ deadlineMs?: number; progress?: (stage: string) => void; } export interface PlanningReason { code: string; message: string; ruleIds?: string[]; } export interface InvalidInputOutcome { kind: 'InvalidInput'; calculationDate?: string; reasons: PlanningReason[]; } export interface PlanningConflictOutcome { kind: 'PlanningConflict'; calculationDate?: string; reasons: PlanningReason[]; conflictingRuleIds: string[]; } export type RemainingClassification = 'unavoidable-expiry' | 'at-risk' | 'deferred'; export interface RemainingBalance { id: string; type: LeaveType; quantity: number; availableFrom: string; expiryDate: string; classification: RemainingClassification; reason: PlanningReason; earned: boolean; sourceRuleId?: string; } export interface LeavePlacement { date: string; balanceId: string; type: LeaveType; ruleIds: string[]; supporting: boolean; } export interface TimeOffPeriod { start: string; end: string; duration: number; placementDates: string[]; declaredDates?: string[]; nonWorkingDates: string[]; nonWorkingDayGain: number; } export interface TimeWindowContribution { ruleId: string; label: string; requested: number; declaredDays: readonly DeclaredNonWorkingDay[]; placementDates: readonly string[]; } export interface IfThenContribution extends TimeWindowContribution { occurrence: string; earnedEntitlement?: Readonly<{ id: string; type: LeaveType; quantity: number; availableFrom: string; expiryDate: string; added: boolean; }>; } export interface PreferenceResult { ruleId: string; requested: number; satisfied: number; completeOccurrences: number; reason?: PlanningReason; } export type PlanningObjectiveId = | 'obligation-validity' | 'unplaced-at-risk' | `preference:${string}` | `optimization:${string}` | 'holiday-week-value' | 'non-working-day-gain' | 'longest-work-sequence' | 'short-sequence-deficit' | 'supporting-placements' | 'balance-expiry' | 'placement-date'; export interface PlanningObjective { id: PlanningObjectiveId; direction: 'minimize' | 'maximize'; value: number | string; label?: string; } /** The real ordered objective values. It deliberately has no scalar score. */ export interface PlanningObjectiveVector { objectives: readonly PlanningObjective[]; } export interface PlanningObjectiveDelta { id: PlanningObjectiveId; direction: 'minimize' | 'maximize'; value: number | string | null; label?: string; } export interface PlanningObjectiveComparison { before: PlanningObjectiveVector; after: PlanningObjectiveVector; delta: readonly PlanningObjectiveDelta[]; firstDecisiveObjective?: PlanningObjectiveId; tieBreak?: 'balance-expiry' | 'placement-date'; } export interface PlacementContribution { kind: 'entitlement' | 'rule' | 'at-risk' | 'supporting' | 'time-off-period' | 'metric'; code: string; label: string; ruleId?: string; value?: number; dates?: readonly string[]; } export interface PlacementExplanation { placement: LeavePlacement; balance?: Readonly; ruleIds: readonly string[]; supporting: boolean; timeOffPeriod?: TimeOffPeriod; remainingBalance?: RemainingBalance; remainingQuantity: number; contributions: readonly PlacementContribution[]; finalMetrics: PlanningMetrics; objectiveVector: PlanningObjectiveVector; /** The final-plan comparison with this placement removed and restored here. */ comparison: PlanningObjectiveComparison; firstDecisiveObjective?: PlanningObjectiveId; } export interface PlanExplanation { kind: 'PlanExplanation'; calculationDate?: string; declaredDays?: readonly DeclaredNonWorkingDay[]; timeOffPeriods?: readonly TimeOffPeriod[]; timeWindowContributions?: readonly TimeWindowContribution[]; ifThenContributions?: readonly IfThenContribution[]; placements: readonly PlacementExplanation[]; /** Present when the explained plan is the incumbent of an interrupted calculation. */ incomplete?: IncompleteExplanation; } export interface ConflictExplanation { kind: 'ConflictExplanation'; calculationDate?: string; conflictingRules: readonly Readonly<{ id: string; label: string }>[]; bottleneck: PlanningReason; } export interface IncompleteExplanation { kind: 'IncompleteExplanation'; calculationDate?: string; reason: PlanningReason; completedStages: readonly string[]; uncompletedStages: readonly string[]; } export type PlanningExplanation = PlanExplanation | ConflictExplanation | IncompleteExplanation; export interface PlanningSnapshot { calculationDate?: string; declaredDays?: readonly DeclaredNonWorkingDay[]; horizon: Readonly<{ start: string; end: string }>; balances: readonly Readonly[]; rules: readonly Readonly[]; identities: Readonly>; labels: Readonly>; } export interface PlanningStateSnapshot { calculationDate?: string; declaredDays?: readonly DeclaredNonWorkingDay[]; horizon: Readonly<{ start: string; end: string }>; balances: readonly Readonly<{ id: string; type: LeaveType; quantity: number; remaining: number; availableFrom: string; expiryDate: string; earned: boolean; sourceRuleId?: string; }>[]; placements: readonly LeavePlacement[]; earnedActivations: readonly Readonly<{ id: string; ruleId: string; occurrence: string; dates: readonly string[]; }>[]; } export interface PlanningArtifactTimings { planningWorkMs: number; } export type CounterfactualReasonCode = | 'non-working' | 'declared-day' | 'occupied' | 'unavailable' | 'expired' | 'type-incompatible' | 'rule-invalid'; export interface CounterfactualDateResult { date: string; status: 'eligible' | 'ineligible'; reason?: PlanningReason & { code: CounterfactualReasonCode }; comparison?: PlanningObjectiveComparison; } export interface CounterfactualAnalysis { kind: 'Counterfactual'; calculationId: string; placement: LeavePlacement; hypothetical: true; baseline: PlanningObjectiveVector; results: readonly CounterfactualDateResult[]; } export interface StaleCounterfactualAnalysis { kind: 'CounterfactualStale'; calculationId: string; placement: LeavePlacement; hypothetical: true; results: readonly []; } export type CounterfactualSelection = LeavePlacement | string; export interface PlanningMetrics { unplacedAtRisk: number; holidayWeekValue: number; nonWorkingDayGain: number; longestWorkSequence: number; averageWorkSequence: number | null; averageTimeOffPeriod: number | null; longestTimeOffPeriod: number | null; shortSequenceDeficit: number; supportingPlacements: number; preferenceSatisfaction: number[]; } export interface PlanOutcome { kind: 'Plan'; calculationDate?: string; declaredDays?: readonly DeclaredNonWorkingDay[]; timeWindowContributions?: readonly TimeWindowContribution[]; ifThenContributions?: readonly IfThenContribution[]; horizon: { start: string; end: string }; placements: LeavePlacement[]; timeOffPeriods: TimeOffPeriod[]; preferences: PreferenceResult[]; remainingBalances: RemainingBalance[]; metrics: PlanningMetrics; completedStages: string[]; lastCompletedStage: string; } export interface PlanningIncompleteOutcome { kind: 'PlanningIncomplete'; calculationDate?: string; incumbent?: PlanOutcome; completedStages: string[]; lastCompletedStage?: string; reasons: PlanningReason[]; } export type PlanningOutcome = | InvalidInputOutcome | PlanningConflictOutcome | PlanningIncompleteOutcome | PlanOutcome; interface NormalizedBalance { id: string; type: LeaveType; quantity: number; availableFrom: string; expiryDate: string; earned: boolean; sourceRuleId?: string; } interface NormalizedBaseRule { id: string; name: string; enabled: boolean; force: RuleForce; } interface NormalizedCalendarRule extends NormalizedBaseRule { type: 'time_window' | 'if_then'; placeDays: number; placeType: LeaveSelector; startDate?: string; endDate?: string; dayOfWeek?: number; placement?: PlacementMode; rewardDays?: number; rewardType?: LeaveType; } interface NormalizedOptimizationRule extends NormalizedBaseRule { type: 'optimization'; force: 'preference'; configuration: OptimizationConfiguration; } type NormalizedRule = NormalizedCalendarRule | NormalizedOptimizationRule; interface InternalBalance extends NormalizedBalance { remaining: number; } interface InternalPlacement { date: string; balanceId: string; type: LeaveType; ruleIds: Set; supporting: boolean; } interface EarnedActivation { id: string; ruleId: string; occurrence: string; dates: string[]; } interface State { balances: InternalBalance[]; placements: Map; earnedActivations: EarnedActivation[]; } interface RuntimePlanningRequest { calculationDate: string; horizon?: unknown; balances?: unknown; declaredDays?: unknown; rules?: unknown; identities?: unknown; labels?: unknown; deadlineMs?: unknown; progress?: unknown; } interface RuntimeRecord { calculationDate?: unknown; horizon?: unknown; start?: unknown; end?: unknown; balances?: unknown; declaredDays?: unknown; rules?: unknown; identities?: unknown; labels?: unknown; deadlineMs?: unknown; progress?: unknown; id?: unknown; name?: unknown; type?: unknown; quantity?: unknown; label?: unknown; availableFrom?: unknown; expiryDate?: unknown; enabled?: unknown; force?: unknown; startDate?: unknown; endDate?: unknown; placeDays?: unknown; placeType?: unknown; dayOfWeek?: unknown; placement?: unknown; rewardDays?: unknown; rewardType?: unknown; configuration?: unknown; } interface Requirement { rule: NormalizedCalendarRule; requiredPlacements: number; anchor: string; index: number; occurrenceDate?: string; optional?: boolean; patterns?: string[][]; candidates?: string[]; } const obligationRequirementCache = new WeakMap(); const occurrenceCache = new WeakMap>(); const calendarPatternCache = new WeakMap>(); const preferencePatternCache = new WeakMap>(); const declaredWindowCache = new WeakMap>(); interface SearchContext { declaredDays: readonly DeclaredNonWorkingDay[]; declaredDates: ReadonlySet; horizon: { start: string; end: string }; holidays: Set; holidayWeeks: HolidayWeekCandidate[]; calendarDays: string[]; calendarIndex: Map; workingCalendar: Set; workingPrefix: number[]; workingDays: string[]; initialBalances: NormalizedBalance[]; rules: NormalizedRule[]; deadline: number; started: number; request: RuntimePlanningRequest; nodes: number; check: () => void; reservedTypes: Set; sharedTypesByDate: Map>; protectedTypesByDate: Map>; preferenceDemandByDate: Map; anyPreferenceDemandByDate: Map; observerTime: number; completedPreferenceStates: Map; completedGenericState?: string; /** Supplied metrics were evaluated after reconciling earned entitlement dates. */ retain?: ( state: State, finalizedMetrics?: PlanningMetrics, vector?: PlanningObjectiveVector, ) => void; } type PlanningCalendar = Pick< SearchContext, | 'holidays' | 'workingDays' | 'calendarDays' | 'calendarIndex' | 'workingCalendar' | 'workingPrefix' >; interface ObligationPreview { obligationState: State; state: State; preferences: PreferenceResult[]; metrics: PlanningMetrics; } function now(): number { return typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now(); } function observeCallback(context: SearchContext, callback: () => void): void { const started = now(); try { callback(); } finally { context.observerTime += now() - started; } } class DeadlineReached extends Error {} class UserCancelled extends Error {} // A true value asks the asynchronous driver to pause immediately at a phase boundary. type PlanningSteps = Generator; // ponytail: cap the number of feasible obligation branches previewed for a // loaded year; replace with bounded branch-and-bound if mobile evidence shows // that this ceiling misses a better lexicographic assignment. const OBLIGATION_SOLUTION_LIMIT = 4096; const EXACT_OBLIGATION_BRANCH_LIMIT = 10_000; const WINDOW_OBLIGATION_PREVIEW_LIMIT = 8; const VALID_TYPES = new Set(['cp', 'rtt', 'other']); const VALID_SELECTORS = new Set(['cp', 'rtt', 'other', 'any']); const VALID_MODES = new Set(['before', 'after', 'around', 'auto']); function dateKey(value: unknown): string | null { if (typeof value === 'string') { // date-holidays returns date-only strings with a time suffix. Treat the // leading calendar date as data, rather than letting the host timezone // shift it across a midnight boundary. 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: unknown, calculationDate?: string): RuntimePlanningRequest { 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: unknown): RuntimeRecord | undefined { return typeof value === 'object' && value !== null ? (value as RuntimeRecord) : undefined; } function compareText(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0; } function defaultHorizon(start: string): { start: string; end: string } { return { start, end: addPlanningYears(start, 1) }; } function holidaySet(horizon: { start: string; end: string }): Set { 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: string): boolean { const day = utcDate(key).getUTCDay(); return day === 0 || day === 6; } function isWorking(key: string, holidays: Set): boolean { return !isWeekend(key) && !holidays.has(key); } interface HolidayWeekCandidate { start: string; dates: string[]; value: number; } function holidayWeekCandidates(context: SearchContext): HolidayWeekCandidate[] { const candidates = new Map(); const inHorizon = (date: string) => 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: string, message: string, ruleIds?: string[]): PlanningReason { return ruleIds?.length ? { code, message, ruleIds } : { code, message }; } function cloneState(state: State, placements?: State['placements']): State { const clonedPlacements = placements ?? new Map( [...state.placements.entries()].map(([date, placement]) => [ date, { ...placement, ruleIds: new Set(placement.ruleIds), }, ]), ); derivePlacements(state.placements, clonedPlacements); return { balances: state.balances.map((balance) => ({ ...balance })), placements: clonedPlacements, earnedActivations: state.earnedActivations.map((activation) => ({ ...activation, dates: [...activation.dates], })), }; } function planningStateSnapshot(state: State, context: SearchContext): PlanningStateSnapshot { 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: unknown): LeaveType | null { const normalized = String(value ?? '').toLowerCase(); return VALID_TYPES.has(normalized as LeaveType) ? (normalized as LeaveType) : null; } function normalizeSelector(value: unknown): LeaveSelector | null { const normalized = String(value ?? '').toLowerCase(); return VALID_SELECTORS.has(normalized as LeaveSelector) ? (normalized as LeaveSelector) : null; } function normalizeForce(value: unknown): RuleForce | null { if (value === undefined || value === '') return 'preference'; const normalized = String(value).toLowerCase(); if (normalized === 'obligation' || normalized === 'preference') return normalized; return null; } function normalizeRuleType(value: unknown): NormalizedRule['type'] | null { const normalized = String(value ?? '').toLowerCase(); return normalized === 'time_window' || normalized === 'if_then' || normalized === 'optimization' ? normalized : null; } function normalizeMode(value: unknown): PlacementMode | null { const normalized = String(value ?? '').toLowerCase(); return VALID_MODES.has(normalized as PlacementMode) ? (normalized as PlacementMode) : null; } function compareDateLists(a: readonly string[], b: readonly string[]): number { 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: unknown): number | null { return typeof value === 'number' && Number.isInteger(value) ? value : null; } function normalizeBalances( request: RuntimePlanningRequest, horizon: { start: string; end: string }, ): { balances: NormalizedBalance[]; reasons: PlanningReason[]; } { const rawInput = request.balances; const reasons: PlanningReason[] = []; if (!Array.isArray(rawInput)) { return { balances: [], reasons: [reason('balances', 'Leave balances must be an array.')], }; } const raw = rawInput; const ids = new Set(); const balances: NormalizedBalance[] = []; 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.`, ), ); // A legacy balance without availability is anchored at the horizon start. // If it already expired before that horizon, keep it reportable as // unavoidable expiry instead of turning migration into invalid input. 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: RuntimePlanningRequest): { rules: NormalizedRule[]; reasons: PlanningReason[]; } { const rawInput = request.rules; const reasons: PlanningReason[] = []; if (!Array.isArray(rawInput)) { return { rules: [], reasons: [reason('rules', 'Planning rules must be an array.')] }; } const raw = rawInput; const ids = new Set(); const rules: NormalizedRule[] = []; 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: unknown): { declaredDays: DeclaredNonWorkingDay[]; horizon?: { start: string; end: string }; balances: NormalizedBalance[]; rules: NormalizedRule[]; reasons: PlanningReason[]; } { 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: PlanningReason[] = []; 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: DeclaredNonWorkingDay[] = []; 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, }; } export function invalidInputFor(request: unknown): InvalidInputOutcome | undefined { const normalized = normalizeRequest(request); if (!normalized.reasons.length) return undefined; return { kind: 'InvalidInput', reasons: normalized.reasons, }; } function futurePreferenceProtection( rules: NormalizedRule[], startIndex: number, context: SearchContext, ): Map> { 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: NormalizedRule, context: SearchContext): string[] { 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: string, direction: 1 | -1, count: number, context: SearchContext, ): string[] { const result: string[] = []; for ( let current = addDays(date, direction); result.length < count; current = addDays(current, direction) ) { context.check?.(); if (isWorking(current, context.holidays)) result.push(current); // A one-year horizon makes an unbounded malformed search unnecessary. if ( compareDate(current, addDays(context.horizon.start, -400)) < 0 || compareDate(current, addDays(context.horizon.end, 400)) > 0 ) break; } return result; } function patternsFor( rule: NormalizedCalendarRule, holiday: string, context: SearchContext, ): string[][] { 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: number, after: number) => [ ...workingNeighbors(holiday, -1, before, context), ...workingNeighbors(holiday, 1, after, context).reverse(), ].sort(compareDate); const patterns: string[][] = []; 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: NormalizedRule[], context: SearchContext, force: RuleForce, ): Requirement[] { if (force === 'obligation' && rules === context.rules) { const cached = obligationRequirementCache.get(context); if (cached) return cached; } const requirements: Requirement[] = []; 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, }); } } } // IF_THEN occurrences are entitlement producers. Resolve them before a // TIME_WINDOW consumer even when the window starts earlier; availability // checks still prevent an earned balance from funding an earlier date, while // this ordering keeps valid later chains visible to the joint search. const ordered = requirements.sort(compareRequirements); if (force === 'obligation' && rules === context.rules) obligationRequirementCache.set(context, ordered); return ordered; } function compareRequirements(a: Requirement, b: Requirement): number { 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: Requirement[]): number { 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: LeaveSelector, type: LeaveType): boolean { return selector === 'any' || selector === type; } function declaredWindowDays(rule: NormalizedRule, context: SearchContext) { 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: string, selector: LeaveSelector, context: SearchContext) { if (!context.declaredDates.has(date)) return undefined; return context.declaredDays.find( (day) => day.date === date && selectorMatches(selector, day.type), ); } function patternDateSatisfied( date: string, rule: NormalizedCalendarRule, state: State, context: SearchContext, ): boolean { return Boolean( declaredPatternDay(date, rule.placeType, context) || (state.placements.has(date) && selectorMatches(rule.placeType, state.placements.get(date)!.type)), ); } function selectedOccurrencePattern( rule: NormalizedCalendarRule, occurrence: string, state: State, context: SearchContext, ): string[] | undefined { 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: State, date: string, selector: LeaveSelector, ruleId: string, context: SearchContext, ): State[] { 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; // A later typed preference can share a placement on this date. Prefer // that type when an ANY preference has an otherwise equivalent choice; // this preserves the later lexicographic criterion without consuming a // scarce balance on a date it cannot use. 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--; notePlacementAdded(state.placements, result.placements, date); 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: State, date: string, balanceId: string, context: SearchContext, ruleIds = new Set(), ): State | null { 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; } // Reject impossible mandatory moves before copying; validate complete patterns later. if (!mandatoryRelocationDate(date, balance, ruleIds, context)) return null; // Existing placements stay read-only; this assignment owns only its new placement. const result = cloneState(state, new Map(state.placements)); const target = result.balances.find((item) => item.id === balanceId)!; target.remaining--; notePlacementAdded(state.placements, result.placements, date); result.placements.set(date, { date, balanceId, type: balance.type, ruleIds: new Set(ruleIds), supporting: false, }); return result; } function addEarnedBalance( state: State, rule: NormalizedCalendarRule, occurrence: string, dates: string[], context: SearchContext, ): State { 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: State, activation: EarnedActivation, context: SearchContext, ): { availableFrom: string; expiryDate: string } { 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) }; } /** * Reconcile generated balances whenever a branch gains placements. * A later placement can extend the generating merged period, so a * provisional reward must not finance an earlier placement in that same final * period. */ function finalizeEarnedState(state: State, context: SearchContext): State | null { let reconciled = state; // Earning follows actual completed patterns, including those another rule already placed. // Both comparison sides must contain these rights before an objective can rank them. 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((balance) => balance.id !== activation.id); continue; } let balance = result.balances.find((item) => item.id === activation.id); if (!balance) { const rule = context.rules.find( (rule): rule is NormalizedCalendarRule => rule.type === 'if_then' && rule.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: State, dates: string[], rule: NormalizedCalendarRule, context: SearchContext, ): PlanningSteps { 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: State[] = []; 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: Requirement[], state: State, context: SearchContext, index = 0, solutions?: State[], solutionLimit = OBLIGATION_SOLUTION_LIMIT, chooseOrder = true, ): PlanningSteps { 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]; // Extending a reported generator requires filling its first working gap. // Only move requirements that can fill that gap ahead of a funded consumer. 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: number, picked: string[], current: State, ): PlanningSteps { 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: State, date: string): State { // Removing a slot never edits surviving placements. Later assignments and // metadata reconciliation still detach their placements before changing them. const result: State = { 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)!; notePlacementRemoved(state.placements, result.placements, date); result.placements.delete(date); result.balances.find((balance) => balance.id === placement.balanceId)!.remaining++; return result; } function reconcileOptimizationProvenance( state: State, context: SearchContext, before: State, objectives: readonly PlanningObjective[] = activeObjectiveVector(state, context), ): State { const optimizations = objectives.filter(({ id }) => id.startsWith('optimization:')); if (!optimizations.length) return state; let result = state; let observations: OptimizationObservations | undefined; 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 }) => id === objective.id)!; const id = objective.id.slice('optimization:'.length); const rule = context.rules.find( (rule): rule is NormalizedOptimizationRule => rule.id === id && rule.type === 'optimization', )!; const compared = compareObjectiveValues( objective.direction, objective.value, without.value, ); let contributes = compared < 0; if (compared === 0 && rule.configuration.aggregation === 'maximum') { // Another tied maximum can mask a placement's improvement to its own element. observations ??= optimizationObservations(state, context); const withoutObservations = optimizationObservations(withoutState, context); const summed = { ...rule.configuration, aggregation: 'sum' as const }; 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: OptimizationObservations, after: State, context: SearchContext, ): boolean { 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: string, balance: InternalBalance, ruleIds: ReadonlySet, context: SearchContext, ): boolean { 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), ), ); }); } /** Optimistic reachability ignores preference values and complete-pattern validity. */ function relocationCanReachFree( state: State, context: SearchContext, balanceId: string, ruleIds: ReadonlySet, visited: ReadonlySet, failed: Set, ): boolean { 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; } /** Relocate occupied dates along an augmenting path and validate only the complete addition. */ function* relocateForAddition( state: State, context: SearchContext, date: string, balanceId: string, ruleIds: Set, supporting: boolean, accept: (candidate: State) => boolean, visited = new Set(), failedReachability = new Set(), failedSearches = new Set(), ): PlanningSteps { 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: State, context: SearchContext, before: State): State | null { 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 is NormalizedCalendarRule => 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: SearchContext): Set { 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: State, targetDates: string[], ruleId: string, context: SearchContext, accept: (candidate: State, addition?: boolean) => boolean, ): PlanningSteps { 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: { date: string; objectives: PlanningObjective[] }[] = []; 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), }); } // shortcut: recursion retains the original donor ranking; rerank when a measured bundle misses a better allocation. rankedDonors.sort( (a, b) => compareObjectiveLists(a.objectives, b.objectives) || compareDate(b.date, a.date), ); const fill = function* ( index: number, current: State, additions: string[] = [], ): PlanningSteps { 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 date of additions) { const placement = { ...current.placements.get(date)! }; current.placements.set(date, 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), ]); // shortcut: equivalent donor tokens share one deterministic source; expand coordinated donor choices if a measured plan needs them. 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: State, ruleId: string, context: SearchContext, accept: (candidate: State, addition?: boolean) => boolean, ): PlanningSteps { for (const week of context.holidayWeeks) yield* calendarPlacementCandidates(state, week.dates, ruleId, context, accept); } function* timeOffPeriodPlacementCandidates( state: State, ruleId: string, context: SearchContext, accept: (candidate: State, addition?: boolean) => boolean, ): PlanningSteps { 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); } } type FinalizeInvariance = 'exact' | 'without-earning' | 'unknown'; const finalizeInvarianceCache = new WeakMap(); /** * Finalizing never moves or retypes placements; it only edits ruleIds, supporting, and earned * activations/balances. Preference results and the calendar optimizations (public-holiday-weeks, * time-off-periods, work-sequences) read only placement dates and types, so the pre-finalize * value is already the final one. leave-balances is exact only when nothing can be earned (no * rewarding if_then rule and no earned activation on the candidate). leave-placements reads * `supporting`, which finalizing rewrites. Entries follow activeObjectives order. */ function finalizeInvariance(context: SearchContext): FinalizeInvariance[] { let entries = finalizeInvarianceCache.get(context); if (!entries) { const earning = context.rules.some( (rule) => rule.enabled && rule.type === 'if_then' && rule.rewardDays, ); entries = context.rules .filter((rule) => rule.enabled && rule.force === 'preference') .map((rule) => { if (rule.type !== 'optimization') return 'exact'; switch (rule.configuration.elements) { case 'public-holiday-weeks': case 'time-off-periods': case 'work-sequences': return 'exact'; case 'leave-balances': return earning ? 'unknown' : 'without-earning'; default: // New element kinds stay unpruned until their bound is argued. return 'unknown'; } }); finalizeInvarianceCache.set(context, entries); } return entries; } /** True when the first differing finalize-invariant protected objective is already worse than the baseline. */ function provablyWorseThanProtectedPrefix( candidate: State, context: SearchContext, throughIndex: number, baseline: readonly PlanningObjective[], ): boolean { const invariance = finalizeInvariance(context); let index = 0; for (const objective of activeObjectives(candidate, context, throughIndex)) { const kind = invariance[index]; if ( kind === 'unknown' || (kind === 'without-earning' && candidate.earnedActivations.length) ) return false; const comparison = compareObjectiveValues( objective.direction, objective.value, baseline[index].value, ); if (comparison !== 0) return comparison > 0; index++; } return false; } function evaluateRuleCandidate( before: State, candidate: State | null, context: SearchContext, throughIndex: number, addition = false, baseline = activeObjectiveVector(before, context, throughIndex), incumbentVector = objectiveVector(before, context), ): { state: State; vector?: PlanningObjectiveVector } | undefined { if (!candidate) return undefined; if (provablyWorseThanProtectedPrefix(candidate, context, throughIndex, baseline)) return undefined; const state = finalizeRuleCandidate(candidate, context, before); if (!state) return undefined; const active: PlanningObjective[] = []; 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, ); // A worse protected prefix cannot improve the jointly valid incumbent. if (comparison > 0) return undefined; } 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); // Preserve the best valid candidate even if the active prefix rejects it. if (compareObjectiveLists(vector.objectives, incumbentVector.objectives) < 0) context.retain?.(state, undefined, vector); // shortcut: single additions cannot cross equal-value plateaus; add coordinated balance bundles when a measured configuration needs them. if (addition && comparison === 0) return undefined; return { state, vector }; } /** Only the complete sequence change is compared; a partial gap fill can worsen the deficit. */ function* workSequencePlacementCandidates( state: State, rule: NormalizedOptimizationRule, context: SearchContext, consider: (candidate: State) => boolean, ): PlanningSteps { 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: string[][][] = []; 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: string[][] = []; 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 is string => 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: State, slots: string[][], dates: string[] = [], ): PlanningSteps { yield; context.check(); // Equivalent rejected allocations differ only in non-earned balance provenance. 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; } // shortcut: bundles keep the first accepted allocation and nearest center per balance; extend when a measured case needs another coordinated allocation. 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); } } /** Candidate additions need an active rule improvement; expiry and date ties only reshape existing placements. */ function* optimizeRulePlacements( state: State, context: SearchContext, throughIndex: number, allowAdd: boolean, rule?: NormalizedRule, ): PlanningSteps { 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 is NormalizedCalendarRule => 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): number | undefined => { 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 undefined; }); 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: OptimizationObservations | undefined; let best = result; let bestVector = beforeVector; const consider = (candidate: State | null, addition = false): boolean => { 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)), ); // shortcut: loaded plans sample flexible move destinations, upgrade to interval search when a measured case misses a better plan. 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); // shortcut: commit a strict bundle prefix improvement before exploring old-state // generic alternatives; upgrade to a combined frontier if measured cases need it. 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; // Complete single additions with equal expiry/window scores cannot beat the // first evaluated funder. Recursive bundles still need every funding choice. 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, context), 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) { // Admit only a completed fixed point, after both bundles and the generic scan. if (genericStateKey !== undefined) context.completedGenericState = genericStateKey; return result; } best = reconcileOptimizationProvenance(best, context, result, bestVector.objectives); context.retain?.(best); result = best; } } function associateCalendarPreference( state: State, rule: NormalizedCalendarRule, context: SearchContext, ): State { 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; } /** Complete the entire typed pattern before checking the ordered objectives. */ function* preferencePatternVariants( state: State, pattern: string[], rule: NormalizedCalendarRule, context: SearchContext, ): PlanningSteps { let variants = [state]; for (const date of pattern) { yield; const next: State[] = []; 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: State, rule: NormalizedCalendarRule, context: SearchContext, preferenceIndex: number, ): PlanningSteps<{ state: State; count: number }> { const matchingOccurrences = occurrences(rule, context); let bestState = state; let bestCount = 0; const baseline = activeObjectiveVector(state, context, preferenceIndex); const search = function* (index: number, current: State, count: number): PlanningSteps { 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); } } // A preference may leave an occurrence unplaced. Exploring this branch after // complete patterns keeps the result deterministic while still maximizing the // number of complete occurrences. yield* search(index + 1, current, count); }; yield* search(0, state, 0); context.retain?.(bestState); return { state: bestState, count: bestCount }; } function preferenceDateConflictScore( selector: LeaveSelector, date: string, context: SearchContext, ): number { 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; // An ANY preference should prefer a date protected by a later preference: // sharing one compatible placement lets both lexicographic criteria win. if (selector === 'any') return -(demand * 2 + (protectedTypes?.size ?? 0)); if (protectedTypes?.has(selector)) return -(demand * 2 + 1); // An untyped future preference can share any leave type, while a typed // future preference for another type must not be allowed to steal this slot. if (protectedTypes?.size) return anyDemand ? -anyDemand : protectedTypes.size; return demand ? -demand : 0; } function patternConflictScore( selector: LeaveSelector, pattern: string[], context: SearchContext, ): number { return pattern.reduce( (score, date) => score + preferenceDateConflictScore(selector, date, context), 0, ); } function preferenceReason( rule: NormalizedCalendarRule, count: number, context: SearchContext, state: State, ): PlanningReason | undefined { const requested = rule.type === 'if_then' ? occurrences(rule, context).length : rule.placeDays; if (count >= requested) return undefined; 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], ); // Include generated balances: an earned entitlement is a real source for // later preferences and must participate in both type and exhaustion reasons. 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: string; 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: State, preferenceRules: NormalizedRule[], context: SearchContext, ): PlanningSteps<{ state: State; preferences: PreferenceResult[] }> { let result = state; const preferences: PreferenceResult[] = []; for (let preferenceIndex = 0; preferenceIndex < preferenceRules.length; preferenceIndex++) { yield; result = yield* applyPreferenceStep( result, preferenceRules, preferenceIndex, preferences, context, ); } return { state: result, preferences }; } function* applyPreferenceStep( state: State, preferenceRules: NormalizedRule[], preferenceIndex: number, preferences: PreferenceResult[], context: SearchContext, ): PlanningSteps { let result = state; const apply = function* (index: number): PlanningSteps { 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); // An earned entitlement produced later can satisfy an earlier preference. let previous: PlanningObjective[]; 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 is NormalizedCalendarRule => rule.type !== 'optimization' && rule.id === preference.ruleId, )!, preference.satisfied, context, result, ), })), ); return result; } function preferenceStateKey(state: State): string { // Preserve iteration order: a coordinated allocator may keep its first valid branch. return JSON.stringify([ state.balances, [...state.placements].map(([date, placement]) => [ date, { ...placement, ruleIds: [...placement.ruleIds] }, ]), state.earnedActivations, ]); } function activeObjectiveVector( state: State, context: SearchContext, throughIndex = Number.POSITIVE_INFINITY, ): PlanningObjective[] { return [...activeObjectives(state, context, throughIndex)]; } function* activeObjectives( state: State, context: SearchContext, throughIndex = Number.POSITIVE_INFINITY, ): Generator { let preferences: PreferenceResult[] | undefined; let balances: OptimizationObservations | undefined; let calendar: OptimizationObservations | undefined; 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: State, context: SearchContext): PlanningObjective[] { return [ { id: 'balance-expiry', direction: 'minimize', value: expiryTieKey(state), label: 'Balance expiry tie-break', }, { id: 'placement-date', direction: 'minimize', value: placementTieKey(state, context), label: 'Placement date tie-break', }, ]; } function obligationPreviewObjectives( preview: { state: State }, context: SearchContext, ): PlanningObjective[] { return [ ...activeObjectiveVector(preview.state, context), ...tieBreakObjectives(preview.state, context), ]; } /** Compare complete previews so obligation DFS does not commit to its first feasible branch. */ function compareObligationPreviews( a: { state: State }, b: { state: State }, context: SearchContext, ): number { return compareObjectiveLists( obligationPreviewObjectives(a, context), obligationPreviewObjectives(b, context), ); } function qualityMetrics( state: State, context: SearchContext, preferences: readonly PreferenceResult[], ): PlanningMetrics { 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: NormalizedBalance, state: State, context: SearchContext, completed: boolean, ): PlanningReason { 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: State, context: SearchContext, completed: boolean, ): RemainingBalance[] { return state.balances .filter((balance) => balance.remaining > 0) .map((balance) => { const classification: RemainingClassification = 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: State, context: SearchContext): number { 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: State, context: SearchContext, active = activeObjectiveVector(state, context), ): PlanningObjectiveVector { return { objectives: [ { id: 'obligation-validity', direction: 'minimize', value: unsatisfiedObligationCount(state, context), label: 'Unsatisfied obligations', }, ...active, ...tieBreakObjectives(state, context), ], }; } function displayLabel(value: unknown, fallback: string): string { return typeof value === 'string' && value.trim() ? value : fallback; } function timeWindowContributions(state: State, context: SearchContext): TimeWindowContribution[] { if (!context.declaredDays.length) return []; const labels = recordValue(context.request.labels) as Record | undefined; const identities = recordValue(context.request.identities) as | Record | undefined; return context.rules .filter( (rule): rule is NormalizedCalendarRule => 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: State, context: SearchContext): IfThenContribution[] { const labels = recordValue(context.request.labels) as Record | undefined; const identities = recordValue(context.request.identities) as | Record | undefined; return context.rules .filter((rule): rule is NormalizedCalendarRule => 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: State, context: SearchContext, placements: readonly LeavePlacement[], periods: readonly TimeOffPeriod[], preferences: readonly PreferenceResult[], remaining: readonly RemainingBalance[], metrics: PlanningMetrics, ): PlanExplanation { 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) as Record | undefined; const identities = recordValue(source.identities) as Record | undefined; 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); notePlacementRemoved(state.placements, withoutPlacement.placements, placement.date); 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: PlacementContribution[] = [ { 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' as const, 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: Readonly | undefined = 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: State, context: SearchContext, preferences: PreferenceResult[], stages: string[], ): PlanOutcome { 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: PlanOutcome = { 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: SearchContext, stage: string): void { if (typeof context.request.progress === 'function') observeCallback(context, () => (context.request.progress as (stage: string) => void)(stage), ); } function ruleStage(rule: NormalizedRule): string { return `${rule.type === 'optimization' ? 'optimization' : 'preference'}:${rule.id}`; } function planningStages(context: SearchContext): string[] { return [ 'validation', 'obligations', ...context.rules .filter((rule) => rule.enabled && rule.force === 'preference') .map(ruleStage), 'expiry', 'dates', ]; } function conflictExplanation( outcome: PlanningConflictOutcome, context: SearchContext, ): ConflictExplanation { const labels = recordValue(context.request.labels) as Record | undefined; 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: readonly Requirement[], context: SearchContext, ): PlanningConflictOutcome { const ruleIds = [...new Set(requirements.map((requirement) => requirement.rule.id))]; const generatedTypes = new Set( context.rules .filter( (rule): rule is NormalizedCalendarRule => 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, ), ], }; } export type PlanningCheckpoint = (plan: PlanOutcome, state?: PlanningStateSnapshot) => void; export interface PlanningArtifacts { explanation?: PlanningExplanation; finalState?: PlanningStateSnapshot; explanationError?: PlanningReason; timings?: PlanningArtifactTimings; } export type PlanningArtifactsCallback = (artifacts: PlanningArtifacts) => void; export type PlanningOutcomeCallback = (outcome: PlanningOutcome) => void; export type PlanningExplanationCallback = ( explanation: PlanningExplanation | undefined, explanationError: PlanningReason | undefined, finalState: PlanningStateSnapshot | undefined, ) => void; /** @internal Synchronous adapter retained for non-browser callers and deterministic checks. */ export function calculatePlanningInternal( request: unknown, checkpointCallback?: PlanningCheckpoint, artifactsCallback?: PlanningArtifactsCallback, outcomeCallback?: PlanningOutcomeCallback, explanationCallback?: PlanningExplanationCallback, ): PlanningOutcome { const steps = calculatePlanningSteps( request, checkpointCallback, artifactsCallback, outcomeCallback, explanationCallback, ); let next = steps.next(); while (!next.done) next = steps.next(); return next.value; } /** @internal Runs the same calculation while allowing worker messages between CPU slices. */ export async function calculatePlanningAsyncInternal( request: unknown, checkpointCallback?: PlanningCheckpoint, artifactsCallback?: PlanningArtifactsCallback, outcomeCallback?: PlanningOutcomeCallback, explanationCallback?: PlanningExplanationCallback, isCancelled: () => boolean = () => false, ): Promise { 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: { start: string; end: string; }, progress: () => void, ): PlanningSteps { 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: string[] = []; const calendarDays: string[] = []; 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: unknown, checkpointCallback?: PlanningCheckpoint, artifactsCallback?: PlanningArtifactsCallback, outcomeCallback?: PlanningOutcomeCallback, explanationCallback?: PlanningExplanationCallback, retainIncumbents = false, ): PlanningSteps { 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: PlanningCalendar; 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: PlanningIncompleteOutcome = { kind: 'PlanningIncomplete', calculationDate: source.calculationDate, completedStages: ['validation'], lastCompletedStage: 'validation', reasons: [reason('user-cancelled', 'The calculation was cancelled by the user.')], }; const explanation: IncompleteExplanation = { 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: () => undefined, reservedTypes: new Set(), sharedTypesByDate: new Map(), protectedTypesByDate: new Map(), preferenceDemandByDate: new Map(), anyPreferenceDemandByDate: new Map(), observerTime: 0, completedPreferenceStates: new Map(), } as SearchContext; 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: State = { balances: normalized.balances.map((balance) => ({ ...balance, remaining: balance.quantity, })), placements: new Map(), earnedActivations: [], }; const stages: string[] = ['validation']; let state = initialState; const preferences: PreferenceResult[] = []; let checkpoint: PlanOutcome | undefined; let checkpointState: State | undefined; let retained: | (Pick & { vector: PlanningObjectiveVector; }) | undefined; if (retainIncumbents) context.retain = (candidate, finalizedMetrics, candidateVector) => { const check = context.check; context.check = () => undefined; 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; // Calendar association can complete provenance after the scoring candidate is retained. 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: PlanningOutcome, explanationState?: State): PlanningOutcome => { outcome.calculationDate = source.calculationDate; const planningFinishedAt = now(); const planningExcludedTime = context.observerTime; outcomeCallback?.(outcome); // Explanation materialization is post-outcome work and must not consume or // inherit the planning deadline. context.check = () => undefined; let explanation: PlanningExplanation | undefined; let explanationError: PlanningReason | undefined; let finalState: PlanningStateSnapshot | undefined; 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 = (): void => { 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; // Entirely fixed patterns already hold before any placement choices, // including preference producers needed by mandatory consumers. 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); } // Equivalent obligation placements can share compatible dates with active preferences. 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 is LeaveType => 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: State[] = []; 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 ) { // shortcut: large window searches preview the eight best active vectors; upgrade to shared bounded previews when a measured case misses a better plan. const ranked: { state: State; objectives: PlanningObjective[] }[] = []; 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 }) => state); } // A completed enumeration with one finalized solution has no alternative to rank. const soleSolution = obligationSolutions.length === 1 && (obligationSolutionLimit === 0 || obligationSolutions.length < obligationSolutionLimit) ? obligationSolutions[0] : undefined; const solePreferences = soleSolution ? preferenceResultsForState(soleSolution, context) : []; let bestPreview: ObligationPreview | null = soleSolution ? { obligationState: soleSolution, state: soleSolution, preferences: solePreferences, metrics: qualityMetrics(soleSolution, context, solePreferences), } : null; for (const obligationSolution of soleSolution ? [] : previewSolutions) { yield; context.check(); const preview = yield* applyPreferenceRules( cloneState(obligationSolution), preferenceRules, context, ); const previewState = finalizeEarnedState(preview.state, context); if (!previewState) continue; const candidate = { obligationState: obligationSolution, state: previewState, preferences: preview.preferences, metrics: qualityMetrics(previewState, context, preview.preferences), }; const compared: number | undefined = bestPreview ? compareObligationPreviews(candidate, bestPreview, context) : undefined; if (!bestPreview || compared! < 0) bestPreview = candidate; } if (!bestPreview) { 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) { // Freeze the chosen valid candidate, rather than exposing a temporary search state. context.check = () => undefined; context.retain = undefined; checkpointState = retained.state; const partialPreferences = retained.preferences.map((preference) => ({ ...preference, reason: preferenceReason( normalized.rules.find( (rule): rule is NormalizedCalendarRule => 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, ); } } /** Materialize an incumbent explanation from a delivered checkpoint without rerunning planning. */ export function explainIncompleteCheckpoint( request: unknown, incumbent: PlanOutcome, checkpoint: PlanningStateSnapshot, interruption: PlanningReason, ): PlanningArtifacts { 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: () => undefined, reservedTypes: new Set(), sharedTypesByDate: new Map>(), protectedTypesByDate: new Map>(), preferenceDemandByDate: new Map(), anyPreferenceDemandByDate: new Map(), observerTime: 0, completedPreferenceStates: new Map(), } as SearchContext; context.holidayWeeks = holidayWeekCandidates(context); const state: 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: CounterfactualReasonCode, message: string, ): PlanningReason & { code: CounterfactualReasonCode } { return { code, message }; } function counterfactualContext( source: RuntimePlanningRequest, normalized: ReturnType, horizon: { start: string; end: string }, ): SearchContext { 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: () => undefined, reservedTypes: new Set(), sharedTypesByDate: new Map>(), protectedTypesByDate: new Map>(), preferenceDemandByDate: new Map(), anyPreferenceDemandByDate: new Map(), observerTime: 0, completedPreferenceStates: new Map(), } as SearchContext; context.holidayWeeks = holidayWeekCandidates(context); return context; } function stateFromPlanningSnapshot(state: PlanningStateSnapshot): 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: number | string, b: number | string): boolean { return a === b; } function objectiveComparison( before: PlanningObjectiveVector, after: PlanningObjectiveVector, ): PlanningObjectiveComparison { const delta: PlanningObjectiveDelta[] = 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: string, type: LeaveType, ruleIds: readonly string[], context: SearchContext, baseline: State, ): boolean { 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: State, context: SearchContext, previous: State, ): boolean { return context.rules .filter( (rule): rule is NormalizedCalendarRule => rule.enabled && rule.force === 'obligation', ) .every((rule) => { if (rule.type === 'time_window') { const count = (candidate: State) => [...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: State) => patterns.some((pattern) => pattern.every((date) => patternDateSatisfied(date, rule, candidate, context), ), ); return !complete(previous) || complete(state); }); }); } function preferenceResultsForState(state: State, context: SearchContext): PreferenceResult[] { let patternsByRule = preferencePatternCache.get(context); if (!patternsByRule) { patternsByRule = new Map( context.rules .filter( (rule): rule is NormalizedCalendarRule => 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 is NormalizedCalendarRule => 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, }; }); } /** Analyze only the selected placement against the final-plan baseline. */ export function analyzeCounterfactualInternal( request: unknown, plan: PlanOutcome, selected: LeavePlacement, finalStateSnapshot?: PlanningStateSnapshot, ): CounterfactualAnalysis { 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: State = 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) { notePlacementRemoved(finalState.placements, baseline.placements, 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: CounterfactualDateResult[] = []; for (const date of dateRange(horizon.start, horizon.end)) { let ineligible: (PlanningReason & { code: CounterfactualReasonCode }) | undefined; 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, }; }