import Holidays from 'date-holidays'; import { addPlanningYears, planningDateInParis } from './planning-date'; /** 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 type PlanningRule = TimeWindowPlanningRule | IfThenPlanningRule; export interface PlanningRequest { horizon: PlanningHorizon; balances: readonly LeaveBalanceInput[]; rules: readonly PlanningRule[]; /** 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; /** A testable deadline in milliseconds. The production default is ten seconds. */ deadlineMs?: number; progress?: (stage: string) => void; } export interface PlanningReason { code: string; message: string; ruleIds?: string[]; } export interface InvalidInputOutcome { kind: 'InvalidInput'; reasons: PlanningReason[]; } export interface PlanningConflictOutcome { kind: 'PlanningConflict'; 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[]; nonWorkingDates: string[]; nonWorkingDayGain: number; } export interface PreferenceResult { ruleId: string; requested: number; satisfied: number; completeOccurrences: number; reason?: PlanningReason; } export type PlanningObjectiveId = | 'obligation-validity' | 'unplaced-at-risk' | `preference:${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'; placements: readonly PlacementExplanation[]; /** Present when the explained plan is the incumbent of an interrupted calculation. */ incomplete?: IncompleteExplanation; } export interface ConflictExplanation { kind: 'ConflictExplanation'; conflictingRules: readonly Readonly<{ id: string; label: string }>[]; bottleneck: PlanningReason; } export interface IncompleteExplanation { kind: 'IncompleteExplanation'; reason: PlanningReason; completedStages: readonly string[]; uncompletedStages: readonly string[]; } export type PlanningExplanation = PlanExplanation | ConflictExplanation | IncompleteExplanation; export interface PlanningSnapshot { horizon: Readonly<{ start: string; end: string }>; balances: readonly Readonly[]; rules: readonly Readonly[]; identities: Readonly>; labels: Readonly>; } export type TraceCandidateStatus = | 'chosen' | 'evaluated-rejected' | 'pruned' | 'not-examined' | 'ineligible'; export interface PlanningTraceState { 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 PlanningTraceDelta { kind: | 'placement-added' | 'placement-removed' | 'placement-moved' | 'placement-updated' | 'balance-changed' | 'earned-activated'; date?: string; fromDate?: string; toDate?: string; balanceId?: string; placement?: LeavePlacement; previousPlacement?: LeavePlacement; remainingBefore?: number; remainingAfter?: number; /** Full metadata when a generated entitlement first enters the traced state. */ balance?: PlanningTraceState['balances'][number]; activation?: Readonly<{ id: string; ruleId: string; occurrence: string; dates: readonly string[]; }>; } export interface PlanningTraceCandidate { status: TraceCandidateStatus; sourceDate?: string; date?: string; dates?: readonly string[]; reason?: PlanningReason; comparison?: PlanningObjectiveComparison; /** Raw non-objective comparator values used by search branches. */ technicalComparison?: readonly Readonly<{ name: string; direction: 'minimize' | 'maximize'; before: number | string; after: number | string; }>[]; firstDecisiveComparator?: string; placement?: LeavePlacement; } export interface PlanningTraceDecision { id: string; action: 'validate' | 'choose' | 'share' | 'add' | 'move' | 'remove' | 'activate' | 'reject'; dates: readonly string[]; candidates: readonly PlanningTraceCandidate[]; deltas: readonly PlanningTraceDelta[]; /** Local transition for an explored alternative branch, separate from committed history. */ branch?: Readonly<{ parentDecisionId?: string; deltas: readonly PlanningTraceDelta[]; }>; reason?: PlanningReason; } export interface PlanningTracePhase { id: string; name: string; completed: boolean; status: 'completed' | 'interrupted' | 'not-run'; decisions: readonly PlanningTraceDecision[]; } export interface PlanningTrace { status: 'ready' | 'incomplete' | 'failed'; initialState: PlanningTraceState; phases: readonly PlanningTracePhase[]; completedStages: readonly string[]; nonWorkingDates?: readonly Readonly<{ date: string; reason: PlanningReason }>[]; error?: PlanningReason; } export type PlanningTraceOperation = | { kind: 'phase'; phase: Pick; } | { kind: 'decision'; phaseId: string; decision: Pick; } | { kind: 'candidate'; phaseId: string; decisionId: string; index: number; mode: 'append' | 'replace'; candidate: PlanningTraceCandidate; } | { kind: 'delta'; phaseId: string; decisionId: string; index: number; delta: PlanningTraceDelta; }; /** Ordered incremental trace updates; decision and candidate indices are stable within a phase. */ export interface PlanningTraceUpdate { initialState?: PlanningTraceState; nonWorkingDates?: readonly Readonly<{ date: string; reason: PlanningReason }>[]; operations: readonly PlanningTraceOperation[]; } export type PlanningTraceProgressCallback = (totalMs: number, update: PlanningTraceUpdate) => void; export interface PlanningArtifactTimings { planningWorkMs: number; traceCaptureMs: number; traceMaterializationMs: number; traceTransferMs: number; } export type CounterfactualReasonCode = | 'non-working' | 'occupied' | 'unavailable' | 'expired' | 'type-incompatible' | 'rule-invalid'; export interface CounterfactualDateResult { date: string; status: 'eligible' | 'ineligible'; historicalStatus?: TraceCandidateStatus; historicalReason?: PlanningReason & { code: CounterfactualReasonCode }; reason?: PlanningReason & { code: CounterfactualReasonCode }; comparison?: PlanningObjectiveComparison; } export interface HistoricalCounterfactualReason { date: string; reason: PlanningReason & { code: CounterfactualReasonCode }; } 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 interface TraceCounterfactualSelection { decisionId: string; placement: LeavePlacement; } export type CounterfactualSelection = LeavePlacement | string | TraceCounterfactualSelection; export interface CounterfactualBaselineOptions { /** The placement released before testing alternatives; null means an additive decision. */ removeDate: string | null; /** Preserve the exact pre-decision state as the comparison's before vector. */ compareOriginalState: boolean; } export interface PlanningMetrics { unplacedAtRisk: number; holidayWeekValue: number; nonWorkingDayGain: number; longestWorkSequence: number; shortSequenceDeficit: number; supportingPlacements: number; preferenceSatisfaction: number[]; } export interface PlanOutcome { kind: 'Plan'; horizon: { start: string; end: string }; placements: LeavePlacement[]; timeOffPeriods: TimeOffPeriod[]; preferences: PreferenceResult[]; remainingBalances: RemainingBalance[]; metrics: PlanningMetrics; completedStages: string[]; lastCompletedStage: string; } export interface PlanningIncompleteOutcome { kind: 'PlanningIncomplete'; 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 NormalizedRule { id: string; name: string; type: 'time_window' | 'if_then'; enabled: boolean; force: RuleForce; placeDays: number; placeType: LeaveSelector; startDate?: string; endDate?: string; dayOfWeek?: number; placement?: PlacementMode; rewardDays?: number; rewardType?: LeaveType; } 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 { horizon?: unknown; balances?: unknown; rules?: unknown; identities?: unknown; labels?: unknown; deadlineMs?: unknown; progress?: unknown; traceProgress?: unknown; } interface RuntimeRecord { horizon?: unknown; start?: unknown; end?: unknown; balances?: unknown; rules?: unknown; identities?: unknown; labels?: unknown; deadlineMs?: unknown; progress?: unknown; traceProgress?: 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; } interface Requirement { rule: NormalizedRule; anchor: string; index: number; occurrenceDate?: string; patterns?: string[][]; candidates?: string[]; } const obligationRequirementCache = new WeakMap(); type MutableTraceCandidate = PlanningTraceCandidate & { candidates?: never }; type MutableTraceDecision = { id: string; action: PlanningTraceDecision['action']; dates: string[]; candidates: MutableTraceCandidate[]; deltas: PlanningTraceDelta[]; branch?: { parentDecisionId?: string; deltas: PlanningTraceDelta[] }; reason?: PlanningReason; }; type MutableTracePhase = { id: string; name: string; completed: boolean; status: PlanningTracePhase['status']; decisions: MutableTraceDecision[]; }; /** * Records observations while the core is producing a result. The recorder is * intentionally write-only from the search code; it never participates in a * candidate sort, deadline check, or state mutation. */ class TraceRecorder { private readonly phases: MutableTracePhase[] = []; private readonly nonWorkingDates: readonly Readonly<{ date: string; reason: PlanningReason }>[]; private phase: MutableTracePhase | undefined; private sequence = 0; private readonly evaluationLocations = new WeakMap< MutableTraceCandidate, { phase: MutableTracePhase; decision: MutableTraceDecision; index: number } >(); private pendingOperations: PlanningTraceOperation[] = []; private readonly pendingCandidateOperations = new Map< PlanningTraceCandidate, Extract >(); private initialStateSent = false; private previousPlacements = new Map(); private previousEarned = new Set(); private previousBalances: ReadonlyMap; constructor( private readonly initialState: PlanningTraceState, private readonly plannedPhases: readonly string[], private readonly workingDays: ReadonlySet, private readonly streaming: boolean, ) { this.nonWorkingDates = dateRange(initialState.horizon.start, initialState.horizon.end) .filter((date) => !workingDays.has(date)) .map((date) => ({ date, reason: reason('non-working', 'The date is a weekend or public holiday.'), })); this.previousBalances = new Map( initialState.balances.map((balance) => [balance.id, { ...balance }]), ); } startPhase(name: string): void { if (this.phase?.name === name) return; if (this.phase) { this.phase.completed = true; this.phase.status = 'completed'; this.recordPhase(this.phase); } this.phase = { id: `phase:${this.phases.length + 1}`, name, completed: false, status: 'interrupted', decisions: [], }; this.phases.push(this.phase); this.recordPhase(this.phase); } private recordPhase(phase: MutableTracePhase): void { if (!this.streaming) return; this.pendingOperations.push({ kind: 'phase', phase: { id: phase.id, name: phase.name, completed: phase.completed, status: phase.status, }, }); } private recordDecisionUpdate(phase: MutableTracePhase, decision: MutableTraceDecision): void { if (!this.streaming) return; this.pendingOperations.push({ kind: 'decision', phaseId: phase.id, decision: { id: decision.id, action: decision.action, dates: [...decision.dates], reason: decision.reason ? { ...decision.reason } : undefined, branch: decision.branch ? { parentDecisionId: decision.branch.parentDecisionId, deltas: decision.branch.deltas.map((delta) => ({ ...delta })), } : undefined, }, }); } private recordCandidateUpdate( phase: MutableTracePhase, decision: MutableTraceDecision, index: number, mode: 'append' | 'replace', candidate: PlanningTraceCandidate, ): void { if (!this.streaming) return; const snapshot = { ...candidate, dates: candidate.dates ? [...candidate.dates] : undefined, reason: candidate.reason ? { ...candidate.reason } : undefined, technicalComparison: candidate.technicalComparison?.map((item) => ({ ...item })), placement: candidate.placement ? { ...candidate.placement, ruleIds: [...candidate.placement.ruleIds] } : undefined, }; const pending = this.pendingCandidateOperations.get(candidate); if (pending) { pending.candidate = snapshot; return; } const operation: Extract = { kind: 'candidate', phaseId: phase.id, decisionId: decision.id, index, mode, candidate: snapshot, }; this.pendingOperations.push(operation); this.pendingCandidateOperations.set(candidate, operation); } private recordDelta(decision: MutableTraceDecision, delta: PlanningTraceDelta): void { const index = decision.deltas.push(delta) - 1; if (!this.streaming) return; this.pendingOperations.push({ kind: 'delta', phaseId: this.phase!.id, decisionId: decision.id, index, delta: { ...delta }, }); } private recordNewCandidate( decision: MutableTraceDecision, candidate: MutableTraceCandidate, ): number { const index = decision.candidates.push(candidate) - 1; this.recordCandidateUpdate(this.phase!, decision, index, 'append', candidate); return index; } drainUpdate(): PlanningTraceUpdate | undefined { if (!this.pendingOperations.length && this.initialStateSent) return undefined; const initialState = this.initialStateSent ? undefined : { horizon: { ...this.initialState.horizon }, balances: this.initialState.balances.map((balance) => ({ ...balance })), placements: this.initialState.placements.map((placement) => ({ ...placement, ruleIds: [...placement.ruleIds], })), earnedActivations: this.initialState.earnedActivations.map((activation) => ({ ...activation, dates: [...activation.dates], })), }; this.initialStateSent = true; const operations = this.pendingOperations.splice(0); this.pendingCandidateOperations.clear(); return { initialState, nonWorkingDates: initialState ? this.nonWorkingDates : undefined, operations, }; } private decision( action: MutableTraceDecision['action'], dates: readonly string[] = [], ): MutableTraceDecision { if (!this.phase) this.startPhase('validation'); const current = this.phase!; const decision: MutableTraceDecision = { id: `decision:${++this.sequence}`, action, dates: [...dates], candidates: [], deltas: [], branch: undefined, }; current.decisions.push(decision); this.recordDecisionUpdate(current, decision); return decision; } private latestDecision( action: MutableTraceDecision['action'] = 'choose', ): MutableTraceDecision { const current = this.phase; const latest = current?.decisions[current.decisions.length - 1]; return latest ?? this.decision(action); } private candidateDecision(dates: readonly string[] = []): MutableTraceDecision { const latest = this.latestDecision(); return latest.deltas.length || latest.branch ? this.decision('choose', dates) : latest; } private recordChosenCandidate(placement: LeavePlacement, decision: MutableTraceDecision): void { const alreadyRecorded = decision.candidates.some( (candidate) => candidate.status === 'chosen' && candidate.placement?.date === placement.date && candidate.placement.balanceId === placement.balanceId, ); if (!alreadyRecorded) this.recordNewCandidate(decision, { status: 'chosen', date: placement.date, placement, }); } recordCandidate(candidate: PlanningTraceCandidate, copy = true): MutableTraceCandidate { const decision = this.candidateDecision( candidate.dates ?? (candidate.date ? [candidate.date] : []), ); const mutable = copy ? { ...candidate, dates: candidate.dates ? [...candidate.dates] : undefined, reason: candidate.reason ? { ...candidate.reason } : undefined, technicalComparison: candidate.technicalComparison?.map((item) => ({ ...item, })), placement: candidate.placement ? { ...candidate.placement, ruleIds: [...candidate.placement.ruleIds] } : undefined, } : (candidate as MutableTraceCandidate); const index = this.recordNewCandidate(decision, mutable); this.evaluationLocations.set(mutable, { phase: this.phase!, decision, index }); return mutable; } recordDecision( action: MutableTraceDecision['action'], dates: readonly string[], candidate?: PlanningTraceCandidate, branch?: PlanningTraceDecision['branch'], ): MutableTraceCandidate | undefined { const decision = this.decision(action, dates); if (branch) { decision.branch = { parentDecisionId: branch.parentDecisionId, deltas: branch.deltas.map((delta) => ({ ...delta })), }; this.recordDecisionUpdate(this.phase!, decision); } if (!candidate) return undefined; const mutable = candidate as MutableTraceCandidate; const index = this.recordNewCandidate(decision, mutable); this.evaluationLocations.set(mutable, { phase: this.phase!, decision, index }); return mutable; } recordBranchTransition( dates: readonly string[], parentDecisionId: string | undefined, deltas: readonly PlanningTraceDelta[], candidate?: MutableTraceCandidate, ): string { const decision = candidate ? (this.evaluationLocations.get(candidate)?.decision ?? this.decision('choose', dates)) : this.decision('choose', dates); decision.branch = { parentDecisionId, deltas: deltas.map((delta) => ({ ...delta })), }; this.recordDecisionUpdate(this.phase!, decision); if (candidate) this.chooseEvaluation(candidate); return decision.id; } decisionIdFor(candidate: MutableTraceCandidate): string | undefined { return this.evaluationLocations.get(candidate)?.decision.id; } recordEvaluation(candidate: MutableTraceCandidate): void { const decision = this.candidateDecision( candidate.dates ?? (candidate.date ? [candidate.date] : []), ); const index = this.recordNewCandidate(decision, candidate); this.evaluationLocations.set(candidate, { phase: this.phase!, decision, index }); } updateEvaluation(candidate: MutableTraceCandidate): void { const location = this.evaluationLocations.get(candidate); if (location) this.recordCandidateUpdate( location.phase, location.decision, location.index, 'replace', candidate, ); } chooseEvaluation(candidate: MutableTraceCandidate): void { candidate.status = 'chosen'; this.updateEvaluation(candidate); } recordPlan( placements: readonly LeavePlacement[], balances: PlanningTraceState['balances'], earnedActivations: PlanningTraceState['earnedActivations'], acceptedCandidate?: MutableTraceCandidate, ): void { const current = new Map(placements.map((placement) => [placement.date, placement])); const removed = [...this.previousPlacements.values()].filter( (placement) => !current.has(placement.date), ); const added = placements.filter( (placement) => !this.previousPlacements.has(placement.date), ); const updated = [...current].filter(([date, placement]) => { const previous = this.previousPlacements.get(date); return previous && JSON.stringify(previous) !== JSON.stringify(placement); }); const balancesChanged = balances.some((balance) => { const previous = this.previousBalances.get(balance.id); return ( !previous || previous.remaining !== balance.remaining || previous.availableFrom !== balance.availableFrom || previous.expiryDate !== balance.expiryDate || previous.quantity !== balance.quantity || previous.type !== balance.type || previous.earned !== balance.earned || previous.sourceRuleId !== balance.sourceRuleId ); }); const earnedChanged = balances.some( (balance) => balance.earned && !this.previousEarned.has(balance.id), ); if ( !removed.length && !added.length && !updated.length && !balancesChanged && !earnedChanged ) return; const action = removed.length && added.length ? 'move' : added.length ? 'add' : removed.length ? 'remove' : updated.length ? 'share' : 'activate'; const dates = [ ...removed.map((placement) => placement.date), ...added.map((placement) => placement.date), ...updated.map(([date]) => date), ]; const decision = (acceptedCandidate && this.evaluationLocations.get(acceptedCandidate)?.decision) || this.decision(action, dates); decision.action = action; decision.dates = dates; this.recordDecisionUpdate(this.phase!, decision); const movedAdded = new Set(); const movedRemoved = new Set(); for (const previous of removed) { const placement = added.find( (candidate) => !movedAdded.has(candidate.date) && candidate.balanceId === previous.balanceId && candidate.type === previous.type, ); if (!placement) continue; movedAdded.add(placement.date); movedRemoved.add(previous.date); this.recordDelta(decision, { kind: 'placement-moved', fromDate: previous.date, toDate: placement.date, balanceId: placement.balanceId, previousPlacement: previous, placement, }); this.recordChosenCandidate(placement, decision); } for (const placement of removed) { if (movedRemoved.has(placement.date)) continue; this.recordDelta(decision, { kind: 'placement-removed', date: placement.date, balanceId: placement.balanceId, previousPlacement: placement, }); } for (const placement of added) { if (movedAdded.has(placement.date)) continue; this.recordDelta(decision, { kind: 'placement-added', date: placement.date, balanceId: placement.balanceId, placement, }); this.recordChosenCandidate(placement, decision); } for (const [date, placement] of updated) { const previous = this.previousPlacements.get(date)!; this.recordDelta(decision, { kind: 'placement-updated', date, balanceId: placement.balanceId, previousPlacement: previous, placement, }); this.recordChosenCandidate(placement, decision); } for (const balance of balances) { const previous = this.previousBalances.get(balance.id); if ( !previous || previous.remaining !== balance.remaining || previous.availableFrom !== balance.availableFrom || previous.expiryDate !== balance.expiryDate || previous.quantity !== balance.quantity || previous.type !== balance.type || previous.earned !== balance.earned || previous.sourceRuleId !== balance.sourceRuleId ) this.recordDelta(decision, { kind: 'balance-changed', balanceId: balance.id, remainingBefore: previous?.remaining, remainingAfter: balance.remaining, balance: { ...balance }, }); if (balance.earned && !this.previousEarned.has(balance.id)) { const activation = earnedActivations.find( (candidate) => candidate.id === balance.id, ); this.recordDelta(decision, { kind: 'earned-activated', balanceId: balance.id, activation: activation ? { ...activation, dates: [...activation.dates] } : undefined, }); } } this.previousPlacements = current; this.previousBalances = new Map(balances.map((balance) => [balance.id, { ...balance }])); this.previousEarned = new Set( balances.filter((balance) => balance.earned).map((balance) => balance.id), ); } finish( status: PlanningTrace['status'], completedStages: readonly string[], error?: PlanningReason, ): PlanningTrace { if (this.phase && status === 'ready') { this.phase.completed = true; this.phase.status = 'completed'; this.recordPhase(this.phase); } if (status === 'incomplete') { const existing = new Set(this.phases.map((phase) => phase.name)); for (const name of this.plannedPhases) { if (existing.has(name)) continue; const phase = { id: `phase:${this.phases.length + 1}`, name, completed: false, status: 'not-run', decisions: [], } satisfies MutableTracePhase; this.phases.push(phase); this.recordPhase(phase); } } return { status, initialState: this.initialState, nonWorkingDates: this.nonWorkingDates, phases: this.phases.map((phase) => ({ ...phase, decisions: phase.decisions.map((decision) => ({ ...decision, dates: [...decision.dates], candidates: decision.candidates.map((candidate) => ({ ...candidate, })), deltas: decision.deltas.map((delta) => ({ ...delta })), })), })), completedStages: [...completedStages], error, }; } } interface SearchContext { 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; trace?: TraceRecorder; traceTime: number; traceCaptureTime: number; traceTransferTime: number; traceReportedTime: number; observerTime: number; obligationSolutionBranches: WeakMap; traceBranchParentId?: string; traceBranchActive?: boolean; traceError?: PlanningReason; } interface ObligationBranchReference { candidate: MutableTraceCandidate; decisionId: string; stateAfter: State; } interface ObligationPreview { obligationState: State; state: State; preferences: PreferenceResult[]; metrics: PlanningMetrics; previewBranch?: ObligationBranchReference; } function now(): number { return typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now(); } function observeTrace(context: SearchContext, observe: (trace: TraceRecorder) => void): void { if (!context.trace) return; const started = now(); try { observe(context.trace); } catch (error) { context.traceError = reason( 'trace-error', error instanceof Error ? error.message : String(error), ); context.trace = undefined; } finally { const elapsed = now() - started; context.traceTime += elapsed; context.traceCaptureTime += elapsed; if ( context.trace && context.traceCaptureTime - context.traceReportedTime >= 25 && typeof context.request.traceProgress === 'function' ) { const updateStarted = now(); const update = context.trace.drainUpdate(); const updateTime = now() - updateStarted; context.traceTime += updateTime; context.traceCaptureTime += updateTime; if (update) { const transferStarted = now(); try { (context.request.traceProgress as PlanningTraceProgressCallback)( context.traceTime, update, ); } catch (error) { context.traceError = reason( 'trace-error', error instanceof Error ? error.message : String(error), ); context.trace = undefined; } finally { const transferTime = now() - transferStarted; context.traceTime += transferTime; context.traceTransferTime += transferTime; context.traceReportedTime = context.traceCaptureTime; } } else { context.traceReportedTime = context.traceCaptureTime; } } } } function observeCallback(context: SearchContext, callback: () => void): void { const started = now(); try { callback(); } finally { context.observerTime += now() - started; } } function recordTraceCandidate( context: SearchContext, candidate: PlanningTraceCandidate, ): MutableTraceCandidate | undefined { // These internal callsites pass fresh literals; retain the full snapshot copy for plan data. let recorded: MutableTraceCandidate | undefined; observeTrace(context, (trace) => { recorded = trace.recordCandidate(candidate, false); }); return recorded; } function recordTraceIneligible( context: SearchContext, date: string, code: string, message: string, ruleIds?: string[], ): void { recordTraceCandidate(context, { status: 'ineligible', date, reason: reason(code, message, ruleIds), }); } class DeadlineReached extends Error {} const DAY_MS = 86_400_000; const MAX_PLANNING_MS = 10_000; const CORE_DEADLINE_MARGIN_MS = 50; // 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 DATE_RE = /^\d{4}-\d{2}-\d{2}$/; 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']); const holidaysByYear = new Map>(); 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 && DATE_RE.test(dateOnly)) { const date = utcDate(dateOnly); return date.toISOString().slice(0, 10) === 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): RuntimePlanningRequest { if (typeof value !== 'object' || value === null) return {}; const source = value as RuntimeRecord; return { horizon: source.horizon, balances: source.balances, rules: source.rules, identities: source.identities, labels: source.labels, deadlineMs: source.deadlineMs, progress: source.progress, traceProgress: source.traceProgress, }; } function recordValue(value: unknown): RuntimeRecord | undefined { return typeof value === 'object' && value !== null ? (value as RuntimeRecord) : undefined; } function utcDate(key: string): Date { return new Date(`${key}T00:00:00Z`); } function addDays(key: string, days: number): string { return new Date(utcDate(key).getTime() + days * DAY_MS).toISOString().slice(0, 10); } function compareDate(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0; } function compareText(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0; } function dateRange(start: string, endExclusive: string): string[] { const result: string[] = []; for (let date = start; compareDate(date, endExclusive) < 0; date = addDays(date, 1)) result.push(date); return result; } function defaultHorizon(now = new Date()): { start: string; end: string } { const start = planningDateInParis(now); return { start, end: addPlanningYears(start, 1) }; } function holidaysForYear(year: number): Set { const cached = holidaysByYear.get(year); if (cached) return new Set(cached); const calendar = new Holidays('FR', { languages: ['en'] }); const holidays = new Set(); for (const holiday of calendar.getHolidays(year, 'en')) { if (holiday.type !== 'public') continue; const key = dateKey(holiday.date); if (key) holidays.add(key); } holidaysByYear.set(year, holidays); return new Set(holidays); } function holidaySet(horizon: { start: string; end: string }): Set { const first = Number(horizon.start.slice(0, 4)) - 1; const last = Number(horizon.end.slice(0, 4)) + 1; const result = new Set(); for (let year = first; year <= last; year++) { for (const holiday of holidaysForYear(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(); for (const holiday of [...context.holidays].sort(compareDate)) { if ( compareDate(holiday, context.horizon.start) < 0 || compareDate(holiday, context.horizon.end) >= 0 ) continue; const weekday = utcDate(holiday).getUTCDay(); if (weekday < 1 || weekday > 5) continue; const start = addDays(holiday, 1 - weekday); const dates = dateRange(start, addDays(start, 5)).filter((date) => isWorking(date, context.holidays), ); if ( !dates.length || dates.some( (date) => compareDate(date, context.horizon.start) < 0 || compareDate(date, context.horizon.end) >= 0, ) ) continue; const value = Math.min(weekday, 6 - weekday); const previous = candidates.get(start); if (previous) previous.value += value; else candidates.set(start, { start, dates, value }); } return [...candidates.values()].sort( (a, b) => b.value - a.value || compareDate(a.start, b.start), ); } function holidayWeekValue(state: State, context: SearchContext): number { return context.holidayWeeks.reduce( (value, candidate) => value + (candidate.dates.every((date) => state.placements.has(date)) ? candidate.value : 0), 0, ); } function reason(code: string, message: string, ruleIds?: string[]): PlanningReason { return ruleIds?.length ? { code, message, ruleIds } : { code, message }; } function cloneState(state: State): State { return { balances: state.balances.map((balance) => ({ ...balance })), placements: new Map( [...state.placements.entries()].map(([date, placement]) => [ date, { ...placement, ruleIds: new Set(placement.ruleIds), }, ]), ), earnedActivations: state.earnedActivations.map((activation) => ({ ...activation, dates: [...activation.dates], })), }; } function planningTraceState( state: State, horizon: { start: string; end: string }, ): PlanningTraceState { return { horizon, 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 planningTraceDeltas( before: State, after: State, dates: readonly string[], ): PlanningTraceDelta[] { const deltas: PlanningTraceDelta[] = []; const changedDates = new Set(dates); for (const date of changedDates) { const previous = before.placements.get(date); const placement = after.placements.get(date); if (!previous && placement) { deltas.push({ kind: 'placement-added', date, balanceId: placement.balanceId, placement: { ...placement, ruleIds: [...placement.ruleIds].sort() }, }); } else if (previous && !placement) { deltas.push({ kind: 'placement-removed', date, balanceId: previous.balanceId, previousPlacement: { ...previous, ruleIds: [...previous.ruleIds].sort(), }, }); } else if ( previous && placement && (JSON.stringify([...previous.ruleIds].sort()) !== JSON.stringify([...placement.ruleIds].sort()) || previous.balanceId !== placement.balanceId || previous.type !== placement.type || previous.supporting !== placement.supporting) ) { deltas.push({ kind: 'placement-updated', date, balanceId: placement.balanceId, previousPlacement: { ...previous, ruleIds: [...previous.ruleIds].sort(), }, placement: { ...placement, ruleIds: [...placement.ruleIds].sort() }, }); } } const previousBalances = new Map(before.balances.map((balance) => [balance.id, balance])); for (const balance of after.balances) { const previous = previousBalances.get(balance.id); if ( !previous || previous.remaining !== balance.remaining || previous.availableFrom !== balance.availableFrom || previous.expiryDate !== balance.expiryDate || previous.quantity !== balance.quantity || previous.type !== balance.type || previous.earned !== balance.earned || previous.sourceRuleId !== balance.sourceRuleId ) { deltas.push({ kind: 'balance-changed', balanceId: balance.id, remainingBefore: previous?.remaining, remainingAfter: balance.remaining, balance: { ...balance }, }); } } const previousActivations = new Set( before.earnedActivations.map((activation) => activation.id), ); for (const activation of after.earnedActivations) { if (previousActivations.has(activation.id)) continue; deltas.push({ kind: 'earned-activated', balanceId: activation.id, activation: { ...activation, dates: [...activation.dates] }, }); } return deltas; } function recordTraceBranchDecision( context: SearchContext, before: State, after: State, parentDecisionId: string | undefined, dates: readonly string[] | (() => readonly string[]), technicalComparison?: () => { values: NonNullable; firstDecisiveComparator?: string; }, ): ObligationBranchReference | undefined { let reference: ObligationBranchReference | undefined; observeTrace(context, (trace) => { const branchDates = typeof dates === 'function' ? dates() : dates; const comparison = technicalComparison?.(); const candidate: MutableTraceCandidate = { status: 'evaluated-rejected', dates: [...branchDates], technicalComparison: comparison?.values, firstDecisiveComparator: comparison?.firstDecisiveComparator, }; const recorded = trace.recordDecision('choose', branchDates, candidate, { parentDecisionId, deltas: planningTraceDeltas(before, after, branchDates), }); const decisionId = recorded && trace.decisionIdFor(recorded); if (recorded && decisionId) reference = { candidate: recorded, decisionId, stateAfter: after }; }); return reference; } function recordTraceIfThenEvaluation( context: SearchContext, dates: readonly string[] | (() => readonly string[]), technicalComparison: () => { values: NonNullable; firstDecisiveComparator?: string; }, ): MutableTraceCandidate | undefined { let evaluation: MutableTraceCandidate | undefined; observeTrace(context, (trace) => { const candidateDates = typeof dates === 'function' ? dates() : dates; const comparison = technicalComparison(); evaluation = trace.recordCandidate( { status: 'evaluated-rejected', dates: [...candidateDates], technicalComparison: comparison.values, firstDecisiveComparator: comparison.firstDecisiveComparator, }, false, ); }); return evaluation; } function recordTraceBranchMutation( context: SearchContext, before: State, after: State, parentDecisionId: string | undefined, candidate: MutableTraceCandidate | undefined, dates: readonly string[], ): string | undefined { let decisionId: string | undefined; observeTrace(context, (trace) => { decisionId = trace.recordBranchTransition( dates, parentDecisionId, planningTraceDeltas(before, after, dates), candidate, ); }); return decisionId; } function recordTraceAtRiskBranchMutation( context: SearchContext, after: State, parentDecisionId: string | undefined, candidate: MutableTraceCandidate | undefined, date: string, balanceId: string, remainingBefore: number, ): string | undefined { let decisionId: string | undefined; observeTrace(context, (trace) => { const placement = after.placements.get(date); const balance = after.balances.find((item) => item.id === balanceId); if (!placement || !balance) return; const deltas: PlanningTraceDelta[] = [ { kind: 'placement-added', date, balanceId, placement: { ...placement, ruleIds: [...placement.ruleIds].sort() }, }, ]; if (remainingBefore !== balance.remaining) deltas.push({ kind: 'balance-changed', balanceId, remainingBefore, remainingAfter: balance.remaining, balance: { ...balance }, }); decisionId = trace.recordBranchTransition([date], parentDecisionId, deltas, candidate); }); return decisionId; } function recordTraceState( context: SearchContext, state: State, acceptedCandidate?: MutableTraceCandidate, ): void { if (!context.trace) return; observeTrace(context, (trace) => { const snapshot = planningTraceState(state, context.horizon); if (acceptedCandidate) trace.chooseEvaluation(acceptedCandidate); trace.recordPlan( snapshot.placements, snapshot.balances, snapshot.earnedActivations, acceptedCandidate, ); }); } 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): 'time_window' | 'if_then' | null { const normalized = String(value ?? '').toLowerCase(); return normalized === 'time_window' || normalized === 'if_then' ? 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; } 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): { horizon?: { start: string; end: string }; balances: NormalizedBalance[]; rules: NormalizedRule[]; reasons: PlanningReason[]; } { const source = runtimeRequest(request); const rawHorizon = recordValue(source.horizon); const fallbackHorizon = defaultHorizon(); 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.')); } if (compareDate(end, addPlanningYears(start, 1)) > 0) { reasons.push( reason('horizon-length', 'The planning horizon may not exceed twelve months.'), ); } 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.'), ); } 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, 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') continue; const priorityWeight = futureRules.length - offset; const dates = rule.type === 'time_window' ? context.workingDays.filter( (date) => compareDate(date, rule.startDate!) >= 0 && compareDate(date, rule.endDate!) <= 0, ) : occurrences(rule, context).flatMap((occurrence) => patternsFor(rule, occurrence, context).flat(), ); 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 []; return dateRange(context.horizon.start, context.horizon.end).filter((date) => { return context.holidays.has(date) && utcDate(date).getUTCDay() === rule.dayOfWeek; }); } 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: NormalizedRule, holiday: string, context: SearchContext): string[][] { 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)); } return patterns .filter( (pattern) => pattern.length === count && pattern.every( (date) => compareDate(date, context.horizon.start) >= 0 && compareDate(date, context.horizon.end) < 0, ), ) .sort(compareDateLists); } 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) 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) => 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, anchor: start, index: index++, candidates: orderedCandidates, }); } else { for (const occurrence of occurrences(rule, context)) { requirements.push({ rule, anchor: occurrence, occurrenceDate: occurrence, index: index++, patterns: patternsFor(rule, occurrence, context).sort( (a, b) => patternConflictScore(rule.placeType, a, context) - patternConflictScore(rule.placeType, b, context) || compareDateLists(a, b), ), }); } } } // 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( (a, b) => Number(a.rule.type !== 'if_then') - Number(b.rule.type !== 'if_then') || compareDate(a.anchor, b.anchor) || a.index - b.index, ); if (force === 'obligation' && rules === context.rules) obligationRequirementCache.set(context, ordered); return ordered; } 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.rule.placeDays; 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 assignDate( state: State, date: string, selector: LeaveSelector, ruleId: string, context: SearchContext, selectedEvaluation?: (candidate: MutableTraceCandidate | undefined) => void, ): State | null { return ( assignDateVariants(state, date, selector, ruleId, context, true, selectedEvaluation)[0] ?? null ); } function assignDateVariants( state: State, date: string, selector: LeaveSelector, ruleId: string, context: SearchContext, firstOnly = false, selectedEvaluation?: (candidate: MutableTraceCandidate | undefined) => void, ): State[] { context.check(); const existing = state.placements.get(date); if (existing) { if (!selectorMatches(selector, existing.type)) { recordTraceCandidate(context, { status: 'ineligible', date, reason: reason( 'type-incompatible', 'The existing placement has an incompatible leave 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) ) { recordTraceCandidate(context, { status: 'ineligible', date, reason: reason('rule-invalid', 'The date does not satisfy the planning rule.', [ ruleId, ]), }); return []; } } const result = cloneState(state); if (ruleId) result.placements.get(date)!.ruleIds.add(ruleId); const evaluation = recordTraceCandidate(context, { status: 'evaluated-rejected', date, placement: { ...result.placements.get(date)!, ruleIds: [...result.placements.get(date)!.ruleIds], }, }); selectedEvaluation?.(evaluation); 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); }); if (!balances.length) recordTraceCandidate(context, { status: 'evaluated-rejected', date, reason: reason( 'balance-unavailable', 'No compatible leave balance can fund this candidate date.', ), }); if (firstOnly && balances.length) { const first = balances[0]; const selectionFacts = firstCompatibleBalanceSelectionFacts( selector, date, first, balances[1], context, ); const result = cloneState(state); result.balances.find((item) => item.id === first.id)!.remaining--; result.placements.set(date, { date, balanceId: first.id, type: first.type, ruleIds: ruleId ? new Set([ruleId]) : new Set(), supporting: false, }); for (const [index, balance] of balances.entries()) { const evaluation = recordTraceCandidate(context, { status: index === 0 ? 'evaluated-rejected' : 'not-examined', date, ...(index === 0 ? selectionFacts : {}), ...(index === 0 ? {} : { reason: reason( 'first-compatible-balance', 'This balance was not examined after the first compatible balance was selected.', ), }), placement: { date, balanceId: balance.id, type: balance.type, ruleIds: ruleId ? [ruleId] : [], supporting: false, }, }); if (index === 0) selectedEvaluation?.(evaluation); } return [result]; } return balances.map((balance) => { const result = cloneState(state); const target = result.balances.find((item) => item.id === balance.id)!; target.remaining--; result.placements.set(date, { date, balanceId: target.id, type: target.type, ruleIds: ruleId ? new Set([ruleId]) : new Set(), supporting: false, }); recordTraceCandidate(context, { status: 'evaluated-rejected', date, placement: { ...result.placements.get(date)!, ruleIds: [...result.placements.get(date)!.ruleIds], }, }); return result; }); } function assignDateToBalance( state: State, date: string, balanceId: string, context: SearchContext, ruleIds = new Set(), selectedEvaluation?: (candidate: MutableTraceCandidate | undefined) => void, selectionFacts?: Pick< PlanningTraceCandidate, 'technicalComparison' | 'firstDecisiveComparator' >, ): State | null { context.check(); if (state.placements.has(date)) { recordTraceCandidate(context, { status: 'ineligible', date, reason: reason('occupied', 'Another placement already uses this 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 ) { recordTraceCandidate(context, { status: 'evaluated-rejected', date, reason: reason( 'balance-unavailable', 'The selected leave balance cannot fund this date.', ), }); return null; } const result = cloneState(state); const target = result.balances.find((item) => item.id === balanceId)!; target.remaining--; result.placements.set(date, { date, balanceId, type: balance.type, ruleIds: new Set(ruleIds), supporting: false, }); const evaluation = recordTraceCandidate(context, { status: 'evaluated-rejected', date, ...selectionFacts, placement: { ...result.placements.get(date)!, ruleIds: [...result.placements.get(date)!.ruleIds], }, }); selectedEvaluation?.(evaluation); return result; } function assignDateToBalanceInPlace( state: State, date: string, balanceId: string, context: SearchContext, ruleIds = new Set(), selectedEvaluation?: (candidate: MutableTraceCandidate | undefined) => void, beforeMutation?: (state: State) => void, selectionFacts?: Pick< PlanningTraceCandidate, 'technicalComparison' | 'firstDecisiveComparator' >, ): boolean { context.check(); if (state.placements.has(date)) { recordTraceCandidate(context, { status: 'ineligible', date, reason: reason('occupied', 'Another placement already uses this date.'), }); return false; } const balance = state.balances.find((item) => item.id === balanceId); if ( !balance || balance.remaining <= 0 || compareDate(balance.availableFrom, date) > 0 || compareDate(date, balance.expiryDate) > 0 ) { recordTraceCandidate(context, { status: 'evaluated-rejected', date, reason: reason( 'balance-unavailable', 'The selected leave balance cannot fund this date.', ), }); return false; } beforeMutation?.(state); balance.remaining--; state.placements.set(date, { date, balanceId, type: balance.type, ruleIds: new Set(ruleIds), supporting: false, }); const evaluation = recordTraceCandidate(context, { status: 'evaluated-rejected', date, ...selectionFacts, placement: { ...state.placements.get(date)!, ruleIds: [...state.placements.get(date)!.ruleIds], }, }); selectedEvaluation?.(evaluation); return true; } function addEarnedBalance( state: State, rule: NormalizedRule, occurrence: string, dates: string[], context: SearchContext, ): State { if (!rule.rewardDays || !rule.rewardType) return state; const result = cloneState(state); const allDates = [...dates, occurrence].sort(compareDate); let start = allDates[0]; let end = allDates[allDates.length - 1]; // A generated pattern may share a period with placements already chosen for // another rule. Use the complete merged period so the entitlement cannot be // acquired before that shared time-off period ends. if (dates.length) { const mergedPeriod = periodData(state, context).find((period) => dates.some((date) => period.placementDates.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 acquisition = addDays(end, 1); const id = `earned:${rule.id}:${occurrence}`; if (!result.balances.some((balance) => balance.id === id)) { result.balances.push({ id, type: rule.rewardType, quantity: rule.rewardDays, remaining: rule.rewardDays, availableFrom: acquisition, expiryDate: addPlanningYears(acquisition, 1), earned: true, sourceRuleId: rule.id, }); result.earnedActivations.push({ id, ruleId: rule.id, occurrence, dates: [...dates], }); } 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)), ); 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 only after all placements in this branch are * known. 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 { if (!state.earnedActivations.length) return state; const result = cloneState(state); for (const activation of result.earnedActivations) { context.check(); const balance = result.balances.find((item) => item.id === activation.id); if (!balance) continue; const bounds = earnedPeriodBounds(result, activation, context); 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: NormalizedRule, context: SearchContext, ): State[] { 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 []; const result: State[] = []; for (const next of assignDateVariants(state, date, rule.placeType, rule.id, context)) { result.push(...assignDatesVariants(next, rest, rule, context)); } return result; } function satisfyObligations( requirements: Requirement[], state: State, context: SearchContext, index = 0, solutions?: State[], solutionLimit = OBLIGATION_SOLUTION_LIMIT, branchPath: readonly ObligationBranchReference[] = [], parentDecisionId?: string, ): State | null { context.check(); if (index >= requirements.length) { const finalized = finalizeEarnedState(state, context); if (!finalized) recordTraceCandidate(context, { status: 'pruned', reason: reason( 'earned-entitlement-order', 'Generated leave is unavailable until its producing time-off period ends.', ), }); if (!finalized) return null; if (solutions) { const allDates = [ ...new Set([...state.placements.keys(), ...finalized.placements.keys()]), ]; const finalization = finalized === state ? undefined : recordTraceBranchDecision( context, state, finalized, parentDecisionId, allDates, ); const path = finalization ? [...branchPath, finalization] : [...branchPath]; if (path.length) context.obligationSolutionBranches.set(finalized, path); solutions.push(finalized); } return solutions ? null : finalized; } const requirement = requirements[index]; if (requirement.patterns) { for (const pattern of requirement.patterns) { for (const assigned of assignDatesVariants(state, pattern, requirement.rule, context)) { const candidate = addEarnedBalance( assigned, requirement.rule, requirement.occurrenceDate!, pattern, context, ); const branch = recordTraceBranchDecision( context, state, candidate, parentDecisionId, pattern, ); const complete = satisfyObligations( requirements, candidate, context, index + 1, solutions, solutionLimit, branch ? [...branchPath, branch] : branchPath, branch?.decisionId ?? parentDecisionId, ); if (complete && !solutions) return complete; if (solutions && solutionLimit > 0 && solutions.length >= solutionLimit) { recordTraceCandidate(context, { status: 'pruned', reason: reason( 'obligation-solution-limit', 'Further obligation branches were not examined after the solution limit.', ), }); return null; } } } return null; } const candidates = requirement.candidates ?? []; const needed = requirement.rule.placeDays; const choose = (position: number, picked: string[], current: State): State | null => { context.check(); if (picked.length === needed) { const branch = recordTraceBranchDecision( context, state, current, parentDecisionId, picked, ); const complete = satisfyObligations( requirements, current, context, index + 1, solutions, solutionLimit, branch ? [...branchPath, branch] : branchPath, branch?.decisionId ?? parentDecisionId, ); if (complete && !solutions) return complete; return null; } if (candidates.length - position < needed - picked.length) { recordTraceCandidate(context, { status: 'pruned', dates: picked, reason: reason( 'obligation-capacity-pruned', 'Too few candidate dates remain to complete this obligation branch.', [requirement.rule.id], ), }); return null; } for (let i = position; i < candidates.length; i++) { for (const next of assignDateVariants( current, candidates[i], requirement.rule.placeType, requirement.rule.id, context, )) { const complete = choose(i + 1, [...picked, candidates[i]], next); if (complete && !solutions) return complete; if (solutions && solutionLimit > 0 && solutions.length >= solutionLimit) { recordTraceCandidate(context, { status: 'pruned', reason: reason( 'obligation-solution-limit', 'Further obligation branches were not examined after the solution limit.', ), }); return null; } } } return null; }; return choose(0, [], state); } function chooseObligationBranches(state: State, context: SearchContext): void { for (const branch of context.obligationSolutionBranches.get(state) ?? []) { observeTrace(context, (trace) => trace.chooseEvaluation(branch.candidate)); recordTraceState(context, branch.stateAfter, branch.candidate); } } function placeAtRisk(state: State, context: SearchContext): State { // At-risk placement is a greedy pass over one state. Clone it once, then // mutate that private copy; cloning the whole placement map for every day is // quadratic for a loaded year and does not change the observable result. const canMutate = state.earnedActivations.length === 0; let result = canMutate ? cloneState(state) : state; const balances = result.balances .filter( (balance) => compareDate(balance.expiryDate, context.horizon.start) >= 0 && compareDate(balance.expiryDate, context.horizon.end) <= 0, ) .sort((a, b) => compareDate(a.expiryDate, b.expiryDate) || compareText(a.id, b.id)); for (const balance of balances) { const availableDates = [...context.workingDays].sort( (a, b) => preferenceDateConflictScore(balance.type, a, context) - preferenceDateConflictScore(balance.type, b, context) || compareDate(a, b), ); while (true) { context.check(); const current = result.balances.find((item) => item.id === balance.id)!; if (current.remaining <= 0) break; let nextState: State | null = null; let nextEvaluation: MutableTraceCandidate | undefined; for (const [candidateIndex, candidate] of availableDates.entries()) { context.check(); if ( !result.placements.has(candidate) && compareDate(current.availableFrom, candidate) <= 0 && compareDate(candidate, current.expiryDate) <= 0 ) { const nextDate = availableDates .slice(candidateIndex + 1) .find( (date) => !result.placements.has(date) && compareDate(current.availableFrom, date) <= 0 && compareDate(date, current.expiryDate) <= 0, ); const selectionFacts = nextDate ? atRiskDateSelectionFacts(balance.type, candidate, nextDate, context) : undefined; let remainingBeforeTransition = current.remaining; let evaluation: MutableTraceCandidate | undefined; const assigned = canMutate ? assignDateToBalanceInPlace( result, candidate, current.id, context, new Set(), (candidateTrace) => (evaluation = candidateTrace), (before) => (remainingBeforeTransition = before.balances.find((item) => item.id === current.id) ?.remaining ?? remainingBeforeTransition), selectionFacts, ) ? result : null : assignDateToBalance( result, candidate, current.id, context, new Set(), (candidateTrace) => (evaluation = candidateTrace), selectionFacts, ); const feasible = assigned ? finalizeEarnedState(assigned, context) : null; if (feasible) { nextState = feasible; nextEvaluation = evaluation; if (context.traceBranchActive) { const parent = canMutate ? recordTraceAtRiskBranchMutation( context, feasible, context.traceBranchParentId, evaluation, candidate, current.id, remainingBeforeTransition, ) : recordTraceBranchMutation( context, result, feasible, context.traceBranchParentId, evaluation, [candidate], ); context.traceBranchParentId = parent ?? context.traceBranchParentId; } break; } } } if (!nextState) break; result = nextState; if (!context.traceBranchActive) recordTraceState(context, result, nextEvaluation); } } return result; } function preferenceTimeWindow( state: State, rule: NormalizedRule, context: SearchContext, ): { state: State; count: number } { 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!; if (compareDate(start, end) > 0) return { state, count: 0 }; let result = state; let count = 0; const shareableDates = new Set( [...state.placements.values()] .filter((placement) => selectorMatches(rule.placeType, placement.type)) .map((placement) => placement.date), ); const candidates = context.workingDays .filter((item) => compareDate(item, start) >= 0 && compareDate(item, end) <= 0) .sort((a, b) => { return ( Number(!shareableDates.has(a)) - Number(!shareableDates.has(b)) || preferenceDateConflictScore(rule.placeType, a, context) - preferenceDateConflictScore(rule.placeType, b, context) || compareDate(a, b) ); }); for (const date of candidates) { context.check(); if (count >= rule.placeDays) break; const before = result; let evaluation: MutableTraceCandidate | undefined; const next = assignDate( result, date, rule.placeType, rule.id, context, (candidate) => (evaluation = candidate), ); // A later preference can extend the generating period of an earned // entitlement. Reject that candidate immediately instead of allowing an // optional preference to turn the final plan into a conflict. const feasible = next ? finalizeEarnedState(next, context) : null; if (feasible) { if (context.traceBranchActive) { const parent = recordTraceBranchMutation( context, before, feasible, context.traceBranchParentId, evaluation, [date], ); context.traceBranchParentId = parent ?? context.traceBranchParentId; } else recordTraceState(context, feasible, evaluation); result = feasible; count++; } } return { state: result, count }; } function preferenceIfThen( state: State, rule: NormalizedRule, context: SearchContext, ): { state: State; count: number } { const matchingOccurrences = occurrences(rule, context); let bestState = state; let bestCount = 0; let bestFutureScore = Number.POSITIVE_INFINITY; let bestMetrics: PlanningMetrics | undefined; let bestBranch: ObligationBranchReference | undefined; let bestEvaluation: MutableTraceCandidate | undefined; const search = (index: number, current: State, count: number, futureScore: number): void => { context.check(); const maximumCount = count + matchingOccurrences.length - index; if (maximumCount < bestCount) { const comparator = `complete-occurrences:${rule.id}`; recordTraceCandidate(context, { status: 'pruned', reason: reason( 'preference-branch-bound', 'This branch cannot complete as many preference occurrences as the current best.', [rule.id], ), technicalComparison: [ { name: comparator, direction: 'maximize', before: bestCount, after: maximumCount, }, ], firstDecisiveComparator: comparator, }); return; } if (index >= matchingOccurrences.length) { const finalized = finalizeEarnedState(current, context); if (!finalized) { recordTraceCandidate(context, { status: 'pruned', reason: reason( 'earned-entitlement-order', 'Generated leave is unavailable until its producing time-off period ends.', [rule.id], ), }); return; } const metrics = qualityMetrics(finalized, context, []); const betterTie = futureScore < bestFutureScore || (futureScore === bestFutureScore && bestMetrics !== undefined && (compareMetrics(metrics, bestMetrics) < 0 || (compareMetrics(metrics, bestMetrics) === 0 && compareText(placementTieKey(finalized), placementTieKey(bestState)) < 0))); const better = count > bestCount || (count === bestCount && betterTie); let branch: ObligationBranchReference | undefined; let evaluation: MutableTraceCandidate | undefined; if (context.trace) { const beforeCount = bestCount; const beforeFutureScore = bestFutureScore; const beforeMetrics = bestMetrics; const beforeState = bestState; const candidateDates = () => [...finalized.placements.values()] .filter( (placement) => placement.ruleIds.has(rule.id) && !state.placements.get(placement.date)?.ruleIds.has(rule.id), ) .map((placement) => placement.date); const comparison = () => preferenceIfThenComparisonFacts( rule.id, count, futureScore, metrics, beforeCount, beforeFutureScore, beforeMetrics, finalized, beforeState, ); if (context.traceBranchActive) branch = recordTraceBranchDecision( context, state, finalized, context.traceBranchParentId, candidateDates, comparison, ); else evaluation = recordTraceIfThenEvaluation(context, candidateDates, comparison); } if (better) { bestCount = count; bestState = finalized; bestFutureScore = futureScore; bestMetrics = metrics; bestBranch = branch; bestEvaluation = evaluation; } 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) { const variants = assignDatesVariants(current, pattern, rule, context); for (const variant of variants) { search( index + 1, addEarnedBalance(variant, rule, occurrence, pattern, context), count + 1, futureScore + patternConflictScore(rule.placeType, pattern, context), ); } } // 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. search(index + 1, current, count, futureScore); }; search(0, state, 0, 0); if (bestBranch) { observeTrace(context, (trace) => trace.chooseEvaluation(bestBranch!.candidate)); context.traceBranchParentId = bestBranch.decisionId; } else if (!context.traceBranchActive) { recordTraceState(context, bestState, bestEvaluation); } return { state: bestState, count: bestCount }; } function preferenceIfThenComparisonFacts( ruleId: string, count: number, futureScore: number, metrics: PlanningMetrics, bestCount: number, bestFutureScore: number, bestMetrics: PlanningMetrics | undefined, state: State, bestState: State, ): { values: NonNullable; firstDecisiveComparator?: string; } { const values: NonNullable[number][] = []; const countName = `complete-occurrences:${ruleId}`; const countEqual = count === bestCount; values.push({ name: countName, direction: 'maximize', before: bestCount, after: count }); const scoreName = `future-pattern-conflict-score:${ruleId}`; const scoreBefore = Number.isFinite(bestFutureScore) ? bestFutureScore : '∞'; values.push({ name: scoreName, direction: 'minimize', before: scoreBefore, after: futureScore, }); let firstDecisiveComparator = !countEqual ? countName : undefined; if (countEqual && futureScore !== bestFutureScore) firstDecisiveComparator = scoreName; if (futureScore === bestFutureScore && bestMetrics) { const metricComparators = [ { name: 'unplaced-at-risk', direction: 'minimize' as const, before: bestMetrics.unplacedAtRisk, after: metrics.unplacedAtRisk, }, { name: 'holiday-week-value', direction: 'maximize' as const, before: bestMetrics.holidayWeekValue, after: metrics.holidayWeekValue, }, { name: 'non-working-day-gain', direction: 'maximize' as const, before: bestMetrics.nonWorkingDayGain, after: metrics.nonWorkingDayGain, }, { name: 'longest-work-sequence', direction: 'minimize' as const, before: bestMetrics.longestWorkSequence, after: metrics.longestWorkSequence, }, { name: 'short-sequence-deficit', direction: 'minimize' as const, before: bestMetrics.shortSequenceDeficit, after: metrics.shortSequenceDeficit, }, { name: 'supporting-placements', direction: 'minimize' as const, before: bestMetrics.supportingPlacements, after: metrics.supportingPlacements, }, ]; for (const comparator of metricComparators) { values.push(comparator); if (comparator.before !== comparator.after) { firstDecisiveComparator ??= comparator.name; break; } } if (metricComparators.every((comparator) => comparator.before === comparator.after)) { const before = placementTieKey(bestState); const after = placementTieKey(state); values.push({ name: 'placement-date', direction: 'minimize', before, after }); if (before !== after) firstDecisiveComparator ??= 'placement-date'; } } return { values, firstDecisiveComparator }; } 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 atRiskDateSelectionFacts( selector: LeaveSelector, selectedDate: string, nextDate: string, context: SearchContext, ): Pick { const selectedScore = preferenceDateConflictScore(selector, selectedDate, context); const nextScore = preferenceDateConflictScore(selector, nextDate, context); const technicalComparison: NonNullable< PlanningTraceCandidate['technicalComparison'] >[number][] = [ { name: 'preference-date-conflict', direction: 'minimize', before: selectedScore, after: nextScore, }, ]; if (selectedScore !== nextScore) return { technicalComparison, firstDecisiveComparator: 'preference-date-conflict' }; technicalComparison.push({ name: 'placement-date', direction: 'minimize', before: selectedDate, after: nextDate, }); return { technicalComparison, firstDecisiveComparator: 'placement-date' }; } function firstCompatibleBalanceSelectionFacts( selector: LeaveSelector, date: string, first: InternalBalance, second: InternalBalance | undefined, context: SearchContext, ): Pick { if (!second) return {}; const values: NonNullable[number][] = []; let firstDecisiveComparator: string | undefined; const add = ( name: string, direction: 'minimize' | 'maximize', before: number | string, after: number | string, ): boolean => { values.push({ name, direction, before, after }); if (before !== after) firstDecisiveComparator ??= name; return before === after; }; if (selector === 'any') { const sharedTypes = context.sharedTypesByDate.get(date) ?? new Set(); let equal = add( 'shared-placement-type', 'maximize', Number(sharedTypes.has(first.type)), Number(sharedTypes.has(second.type)), ); const protectedTypes = context.protectedTypesByDate.get(date) ?? new Set(); if (equal && protectedTypes.size) equal = add( 'protected-preference-type', 'maximize', Number(protectedTypes.has(first.type)), Number(protectedTypes.has(second.type)), ); if ( equal && context.reservedTypes.has(first.type) !== context.reservedTypes.has(second.type) ) add( 'reserved-type', 'minimize', Number(context.reservedTypes.has(first.type)), Number(context.reservedTypes.has(second.type)), ); } if (!firstDecisiveComparator) { const equal = add('balance-expiry', 'minimize', first.expiryDate, second.expiryDate); if (equal) add('balance-id', 'minimize', first.id, second.id); } return { technicalComparison: values, firstDecisiveComparator }; } function patternConflictScore( selector: LeaveSelector, pattern: string[], context: SearchContext, ): number { return pattern.reduce( (score, date) => score + preferenceDateConflictScore(selector, date, context), 0, ); } function preferenceReason( rule: NormalizedRule, 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) => 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 && index < currentRuleIndex; }), ), ); const consumedByAtRisk = matchingBalances.some( (balance) => balance.remaining === 0 && isAtRisk(balance, context) && [...state.placements.values()].some( (placement) => placement.balanceId === balance.id && placement.ruleIds.size === 0, ), ); 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); const sourceBalance = placement ? state.balances.find((balance) => balance.id === placement.balanceId) : undefined; return ( placement && !selectorMatches(rule.placeType, placement.type) && ((sourceBalance ? isAtRisk(sourceBalance, context) : false) || [...placement.ruleIds].some((id) => { const index = context.rules.findIndex((item) => item.id === id); return index >= 0 && 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 || consumedByAtRisk) && !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, ): { state: State; preferences: PreferenceResult[] } { let result = state; const preferences: PreferenceResult[] = []; for (let preferenceIndex = 0; preferenceIndex < preferenceRules.length; preferenceIndex++) { result = applyPreferenceStep( result, preferenceRules, preferenceIndex, preferences, context, ); } return { state: result, preferences }; } function applyPreferenceStep( state: State, preferenceRules: NormalizedRule[], preferenceIndex: number, preferences: PreferenceResult[], context: SearchContext, ): State { let result = state; const applied = applyPreferenceRule( result, preferenceRules[preferenceIndex], preferenceRules, preferenceIndex, context, ); result = applied.state; preferences[preferenceIndex] = applied.preference; // A lower-priority rule can be the producer for an entitlement needed by // an earlier rule. Replay incomplete preferences left-to-right until no // satisfaction improves, preserving lexicographic priority across a chain. let remainingImprovements = preferences .slice(0, preferenceIndex + 1) .reduce((total, preference) => total + preference.requested, 0); while (remainingImprovements > 0) { context.check(); let improved = false; for (let retryIndex = 0; retryIndex <= preferenceIndex; retryIndex++) { context.check(); const previous = preferences[retryIndex]; if (previous.satisfied >= previous.requested) continue; const retried = applyPreferenceRule( result, preferenceRules[retryIndex], preferenceRules, retryIndex, context, ); if (retried.preference.satisfied <= previous.satisfied) continue; remainingImprovements -= retried.preference.satisfied - previous.satisfied; result = retried.state; preferences[retryIndex] = retried.preference; improved = true; } if (!improved) break; } return result; } function applyPreferenceRule( state: State, rule: NormalizedRule, preferenceRules: NormalizedRule[], preferenceIndex: number, context: SearchContext, ): { state: State; preference: PreferenceResult } { context.check(); context.reservedTypes = new Set( preferenceRules .slice(preferenceIndex + 1) .map((item) => item.placeType) .filter((selector): selector is LeaveType => selector !== 'any'), ); context.protectedTypesByDate = futurePreferenceProtection( preferenceRules, preferenceIndex + 1, context, ); const applied = rule.type === 'time_window' ? preferenceTimeWindow(state, rule, context) : preferenceIfThen(state, rule, context); const requested = rule.type === 'time_window' ? rule.placeDays : occurrences(rule, context).length; return { state: applied.state, preference: { ruleId: rule.id, requested, satisfied: applied.count, completeOccurrences: rule.type === 'if_then' ? applied.count : 0, reason: preferenceReason(rule, applied.count, context, applied.state), }, }; } function placementTieKey(state: State): string { return [...state.placements.values()] .sort((a, b) => compareDate(a.date, b.date) || compareText(a.balanceId, b.balanceId)) .map((placement) => `${placement.date}:${placement.balanceId}`) .join('|'); } function expiryTieKey(state: State): string { return [...state.placements.values()] .sort((a, b) => compareDate(a.date, b.date) || compareText(a.balanceId, b.balanceId)) .map((placement) => { const balance = state.balances.find((item) => item.id === placement.balanceId); return `${balance?.expiryDate ?? ''}:${placement.balanceId}`; }) .join('|'); } function comparePreferenceVectors(a: readonly number[], b: readonly number[]): number { for (let index = 0; index < Math.max(a.length, b.length); index++) { const left = a[index] ?? 0; const right = b[index] ?? 0; if (left !== right) return right - left; } return 0; } /** Compare complete previews so obligation DFS does not commit to its first feasible branch. */ function compareObligationPreviews( a: { state: State; preferences: PreferenceResult[]; metrics: PlanningMetrics }, b: { state: State; preferences: PreferenceResult[]; metrics: PlanningMetrics }, ): number { if (a.metrics.unplacedAtRisk !== b.metrics.unplacedAtRisk) return a.metrics.unplacedAtRisk - b.metrics.unplacedAtRisk; const preferenceComparison = comparePreferenceVectors( a.metrics.preferenceSatisfaction, b.metrics.preferenceSatisfaction, ); if (preferenceComparison) return preferenceComparison; const metrics = compareMetrics( { ...a.metrics, unplacedAtRisk: 0 }, { ...b.metrics, unplacedAtRisk: 0 }, ); if (metrics) return metrics; const expiry = compareText(expiryTieKey(a.state), expiryTieKey(b.state)); if (expiry) return expiry; return compareText(placementTieKey(a.state), placementTieKey(b.state)); } function obligationPreviewComparisonFacts( a: { state: State; preferences: PreferenceResult[]; metrics: PlanningMetrics }, b: { state: State; preferences: PreferenceResult[]; metrics: PlanningMetrics }, ): { values: NonNullable; firstDecisiveComparator?: string; } { const values: NonNullable[number][] = []; const add = ( name: string, direction: 'minimize' | 'maximize', before: number | string, after: number | string, ) => { values.push({ name, direction, before, after }); return before === after; }; let equal = add( 'unplaced-at-risk', 'minimize', b.metrics.unplacedAtRisk, a.metrics.unplacedAtRisk, ); if (!equal) return { values, firstDecisiveComparator: 'unplaced-at-risk' }; for (let index = 0; index < a.metrics.preferenceSatisfaction.length; index++) { const before = b.metrics.preferenceSatisfaction[index] ?? 0; const after = a.metrics.preferenceSatisfaction[index] ?? 0; const ruleId = a.preferences[index]?.ruleId ?? b.preferences[index]?.ruleId ?? String(index); equal = add(`preference:${ruleId}`, 'maximize', before, after); if (!equal) return { values, firstDecisiveComparator: `preference:${ruleId}` }; } const metrics = [ { name: 'holiday-week-value', direction: 'maximize' as const, before: b.metrics.holidayWeekValue, after: a.metrics.holidayWeekValue, }, { name: 'non-working-day-gain', direction: 'maximize' as const, before: b.metrics.nonWorkingDayGain, after: a.metrics.nonWorkingDayGain, }, { name: 'longest-work-sequence', direction: 'minimize' as const, before: b.metrics.longestWorkSequence, after: a.metrics.longestWorkSequence, }, { name: 'short-sequence-deficit', direction: 'minimize' as const, before: b.metrics.shortSequenceDeficit, after: a.metrics.shortSequenceDeficit, }, { name: 'supporting-placements', direction: 'minimize' as const, before: b.metrics.supportingPlacements, after: a.metrics.supportingPlacements, }, ]; for (const metric of metrics) { equal = add(metric.name, metric.direction, metric.before, metric.after); if (!equal) return { values, firstDecisiveComparator: metric.name }; } const beforeExpiry = expiryTieKey(b.state); const afterExpiry = expiryTieKey(a.state); equal = add('balance-expiry', 'minimize', beforeExpiry, afterExpiry); if (!equal) return { values, firstDecisiveComparator: 'balance-expiry' }; const beforeDates = placementTieKey(b.state); const afterDates = placementTieKey(a.state); equal = add('placement-date', 'minimize', beforeDates, afterDates); return { values, firstDecisiveComparator: equal ? undefined : 'placement-date' }; } type QualityTraceDetails = Pick< PlanningTraceCandidate, 'technicalComparison' | 'firstDecisiveComparator' | 'reason' >; type QualityTraceComparator = NonNullable[number]; function addQualityComparator( values: QualityTraceComparator[], name: string, direction: QualityTraceComparator['direction'], before: number | string, after: number | string, ): boolean { values.push({ name, direction, before, after }); return before === after; } function movableProtectionTraceDetails( baseline: PlanningMetrics, candidate: PlanningMetrics, preferences: readonly PreferenceResult[], ): QualityTraceDetails { const values: QualityTraceComparator[] = []; const reject = (name: string, message: string): QualityTraceDetails => ({ technicalComparison: values, firstDecisiveComparator: name, reason: reason('quality-protected-objective', message), }); const atRiskEqual = addQualityComparator( values, 'protected:unplaced-at-risk', 'minimize', baseline.unplacedAtRisk, candidate.unplacedAtRisk, ); if (!atRiskEqual) return reject( 'protected:unplaced-at-risk', 'This move changes the amount of unplaced at-risk leave protected by the quality pass.', ); addQualityComparator( values, 'protected:holiday-week-value', 'maximize', baseline.holidayWeekValue, candidate.holidayWeekValue, ); if (candidate.holidayWeekValue < baseline.holidayWeekValue) return reject( 'protected:holiday-week-value', 'This move reduces public-holiday week value protected by the quality pass.', ); if (candidate.preferenceSatisfaction.length !== baseline.preferenceSatisfaction.length) { const name = 'protected:preference-count'; addQualityComparator( values, name, 'maximize', baseline.preferenceSatisfaction.length, candidate.preferenceSatisfaction.length, ); return reject( name, 'This move changes the preference result protected by the quality pass.', ); } for (let index = 0; index < baseline.preferenceSatisfaction.length; index++) { const name = `protected:preference:${preferences[index]?.ruleId ?? index}`; const before = baseline.preferenceSatisfaction[index]; const after = candidate.preferenceSatisfaction[index]; if (!addQualityComparator(values, name, 'maximize', before, after)) return reject( name, 'This move changes a preference result protected by the quality pass.', ); } return { technicalComparison: values }; } function movableIncumbentTraceDetails( stage: 'non-working-day-gain' | 'longest-work-sequence' | 'short-sequence-deficit' | 'dates', incumbent: PlanningMetrics, candidate: PlanningMetrics, incumbentDate: string, candidateDate: string, ): QualityTraceDetails { const values: QualityTraceComparator[] = []; const add = ( name: string, direction: QualityTraceComparator['direction'], before: number | string, after: number | string, ) => addQualityComparator(values, name, direction, before, after); const finishIfDifferent = (name: string, equal: boolean): QualityTraceDetails | undefined => equal ? undefined : { technicalComparison: values, firstDecisiveComparator: name }; let equal = add( 'holiday-week-value', 'maximize', incumbent.holidayWeekValue, candidate.holidayWeekValue, ); let decisive = finishIfDifferent('holiday-week-value', equal); if (decisive) return decisive; if (stage === 'non-working-day-gain') { equal = add( 'non-working-day-gain', 'maximize', incumbent.nonWorkingDayGain, candidate.nonWorkingDayGain, ); return finishIfDifferent('non-working-day-gain', equal) ?? { technicalComparison: values }; } equal = add( 'non-working-day-gain', 'maximize', incumbent.nonWorkingDayGain, candidate.nonWorkingDayGain, ); decisive = finishIfDifferent('non-working-day-gain', equal); if (decisive) return decisive; if (stage === 'longest-work-sequence') { equal = add( 'longest-work-sequence', 'minimize', incumbent.longestWorkSequence, candidate.longestWorkSequence, ); return finishIfDifferent('longest-work-sequence', equal) ?? { technicalComparison: values }; } equal = add( 'longest-work-sequence', 'minimize', incumbent.longestWorkSequence, candidate.longestWorkSequence, ); decisive = finishIfDifferent('longest-work-sequence', equal); if (decisive) return decisive; if (stage === 'short-sequence-deficit') { equal = add( 'short-sequence-deficit', 'minimize', incumbent.shortSequenceDeficit, candidate.shortSequenceDeficit, ); return ( finishIfDifferent('short-sequence-deficit', equal) ?? { technicalComparison: values } ); } equal = add( 'short-sequence-deficit', 'minimize', incumbent.shortSequenceDeficit, candidate.shortSequenceDeficit, ); decisive = finishIfDifferent('short-sequence-deficit', equal); if (decisive) return decisive; if (stage === 'dates') { const dateEqual = add('placement-date', 'minimize', incumbentDate, candidateDate); return finishIfDifferent('placement-date', dateEqual) ?? { technicalComparison: values }; } return { technicalComparison: values }; } function recordTraceDecision(context: SearchContext, dates: readonly string[]): void { observeTrace(context, (trace) => trace.recordDecision('choose', dates)); } function updateTraceEvaluationReason( context: SearchContext, evaluation: MutableTraceCandidate | undefined, evaluationReason: PlanningReason, ): void { if (!evaluation) return; observeTrace(context, (trace) => { evaluation.reason = evaluationReason; trace.updateEvaluation(evaluation); }); } function recordTraceEvaluation( context: SearchContext, beforeState: State, afterState: State, preferences: readonly PreferenceResult[], beforeMetrics: PlanningMetrics, afterMetrics: PlanningMetrics, dates: readonly string[], sourceDate?: string, qualityDetails?: () => QualityTraceDetails, ): MutableTraceCandidate | undefined { let evaluation: MutableTraceCandidate | undefined; observeTrace(context, (trace) => { const date = dates[dates.length - 1]; const placement = date ? afterState.placements.get(date) : undefined; const details = qualityDetails?.(); evaluation = { status: 'evaluated-rejected', sourceDate, date, dates: [...dates], reason: details?.reason, comparison: objectiveComparison( objectiveVector(beforeMetrics, preferences, beforeState, context), objectiveVector(afterMetrics, preferences, afterState, context), ), technicalComparison: details?.technicalComparison, firstDecisiveComparator: details?.firstDecisiveComparator, placement: placement ? { ...placement, ruleIds: [...placement.ruleIds].sort() } : undefined, }; trace.recordEvaluation(evaluation); }); return context.trace ? evaluation : undefined; } function holidayWeekProtectionTraceDetails( baseline: PlanningMetrics, candidate: PlanningMetrics, preferences: readonly PreferenceResult[], ): QualityTraceDetails { const values: QualityTraceComparator[] = []; const reject = (name: string, message: string): QualityTraceDetails => ({ technicalComparison: values, firstDecisiveComparator: name, reason: reason('quality-protected-objective', message), }); addQualityComparator( values, 'protected:unplaced-at-risk', 'minimize', baseline.unplacedAtRisk, candidate.unplacedAtRisk, ); if (candidate.unplacedAtRisk > baseline.unplacedAtRisk) return reject( 'protected:unplaced-at-risk', 'This completion leaves more at-risk leave unplaced.', ); for ( let index = 0; index < Math.max(baseline.preferenceSatisfaction.length, candidate.preferenceSatisfaction.length); index++ ) { const name = `protected:preference:${preferences[index]?.ruleId ?? index}`; const before = baseline.preferenceSatisfaction[index] ?? 0; const after = candidate.preferenceSatisfaction[index] ?? 0; if (!addQualityComparator(values, name, 'maximize', before, after)) return reject(name, 'This completion changes a higher-priority preference result.'); } addQualityComparator( values, 'protected:holiday-week-value', 'maximize', baseline.holidayWeekValue, candidate.holidayWeekValue, ); if (candidate.holidayWeekValue <= baseline.holidayWeekValue) return reject( 'protected:holiday-week-value', 'This completion does not increase public-holiday week value.', ); return { technicalComparison: values }; } function qualityMetricTraceDetails( incumbent: PlanningMetrics, candidate: PlanningMetrics, ): QualityTraceDetails { const values: QualityTraceComparator[] = []; const comparators = [ ['unplaced-at-risk', 'minimize', incumbent.unplacedAtRisk, candidate.unplacedAtRisk], ['holiday-week-value', 'maximize', incumbent.holidayWeekValue, candidate.holidayWeekValue], [ 'non-working-day-gain', 'maximize', incumbent.nonWorkingDayGain, candidate.nonWorkingDayGain, ], [ 'longest-work-sequence', 'minimize', incumbent.longestWorkSequence, candidate.longestWorkSequence, ], [ 'short-sequence-deficit', 'minimize', incumbent.shortSequenceDeficit, candidate.shortSequenceDeficit, ], [ 'supporting-placements', 'minimize', incumbent.supportingPlacements, candidate.supportingPlacements, ], ] as const; for (const [name, direction, before, after] of comparators) { if (!addQualityComparator(values, name, direction, before, after)) return { technicalComparison: values, firstDecisiveComparator: name }; } return { technicalComparison: values }; } function qualityMetricTieTraceDetails( incumbent: PlanningMetrics, candidate: PlanningMetrics, incumbentState: State, candidateState: State, ): QualityTraceDetails { const comparison = qualityMetricTraceDetails(incumbent, candidate); if (comparison.firstDecisiveComparator) return comparison; const values = [...(comparison.technicalComparison ?? [])]; const equal = addQualityComparator( values, 'placement-date', 'minimize', placementTieKey(incumbentState), placementTieKey(candidateState), ); return { technicalComparison: values, firstDecisiveComparator: equal ? undefined : 'placement-date', }; } function optimizeMovablePlacements( state: State, context: SearchContext, preferences: PreferenceResult[], stage: 'non-working-day-gain' | 'longest-work-sequence' | 'short-sequence-deficit' | 'dates', ): State { let result = state; // Small plans are common and can be searched exhaustively. Keep the fixed // sparse calendar candidates for loaded years so quality work stays within // the mobile budget without making ordinary scenarios depend on sampling. // ponytail: loaded plans sample non-working-adjacent and weekly dates; upgrade // to interval branch-and-bound if a mobile benchmark shows a missed quality tie. const exhaustive = result.placements.size <= 24; const movableWindowDates = new Set( [...result.placements.values()] .filter((placement) => isMovableTimeWindow(placement, context)) .flatMap((placement) => [...placement.ruleIds] .map((id) => context.rules.find((rule) => rule.id === id)) .filter( ( rule, ): rule is NormalizedRule & { type: 'time_window'; startDate: string; endDate: string; } => rule?.type === 'time_window', ) .flatMap((rule) => context.workingDays.filter( (date) => compareDate(date, rule.startDate) >= 0 && compareDate(date, rule.endDate) <= 0, ), ), ), ); const candidateDates = context.workingDays.filter((date, index) => { if (exhaustive) return true; const touchesNonWorking = !isWorking(addDays(date, -1), context.holidays) || !isWorking(addDays(date, 1), context.holidays); return touchesNonWorking || index % 7 === 0; }); if (!exhaustive) { const sampled = new Set(candidateDates); for (const date of context.workingDays) { if (sampled.has(date)) continue; recordTraceCandidate(context, { status: 'not-examined', date, reason: reason( 'loaded-plan-sampling', 'This date was not examined because of loaded-plan quality sampling.', ), }); } } for (const original of [...result.placements.values()] .filter( (placement) => placement.ruleIds.size === 0 || isMovableTimeWindow(placement, context), ) .sort((a, b) => compareDate(a.date, b.date) || compareText(a.balanceId, b.balanceId))) { const current = result.placements.get(original.date); if (!current || (current.ruleIds.size > 0 && !isMovableTimeWindow(current, context))) continue; const balance = result.balances.find((item) => item.id === current.balanceId); if ( !balance || (!current.ruleIds.size && !isAtRisk(balance, context) && !current.supporting) ) continue; recordTraceDecision(context, [original.date]); const baseline = qualityMetrics(result, context, preferences); const without = cloneState(result); without.placements.delete(original.date); without.balances.find((item) => item.id === current.balanceId)!.remaining++; let best = result; let bestMetrics = baseline; let bestDate = original.date; let bestEvaluation: MutableTraceCandidate | undefined; const placementCandidates = current.ruleIds.size > 0 ? context.workingDays.filter((date) => movableWindowDates.has(date)) : candidateDates; for (const date of placementCandidates) { if (date === original.date) continue; if (!compatibleWithRules(date, current.type, current.ruleIds, context)) { recordTraceIneligible( context, date, 'rule-invalid', 'The date does not preserve the placement rules.', [...current.ruleIds], ); continue; } if ( !assignDateToBalanceInPlace( without, date, current.balanceId, context, current.ruleIds, ) ) continue; try { const feasibleCandidate = finalizeEarnedState(without, context); if (!feasibleCandidate) continue; feasibleCandidate.placements.get(date)!.supporting = current.supporting; const metrics = qualityMetrics(feasibleCandidate, context, preferences); if (metrics.unplacedAtRisk !== baseline.unplacedAtRisk) { recordTraceEvaluation( context, result, feasibleCandidate, preferences, baseline, metrics, [date], current.date, () => movableProtectionTraceDetails(baseline, metrics, preferences), ); continue; } if (metrics.holidayWeekValue < baseline.holidayWeekValue) { recordTraceEvaluation( context, result, feasibleCandidate, preferences, baseline, metrics, [date], current.date, () => movableProtectionTraceDetails(baseline, metrics, preferences), ); continue; } if ( metrics.preferenceSatisfaction.length !== baseline.preferenceSatisfaction.length || metrics.preferenceSatisfaction.some( (value, index) => value !== baseline.preferenceSatisfaction[index], ) ) { recordTraceEvaluation( context, result, feasibleCandidate, preferences, baseline, metrics, [date], current.date, () => movableProtectionTraceDetails(baseline, metrics, preferences), ); continue; } const better = metrics.holidayWeekValue > bestMetrics.holidayWeekValue || (metrics.holidayWeekValue === bestMetrics.holidayWeekValue && (stage === 'non-working-day-gain' ? metrics.nonWorkingDayGain > bestMetrics.nonWorkingDayGain : stage === 'longest-work-sequence' ? metrics.nonWorkingDayGain === bestMetrics.nonWorkingDayGain && metrics.longestWorkSequence < bestMetrics.longestWorkSequence : stage === 'short-sequence-deficit' ? metrics.nonWorkingDayGain === bestMetrics.nonWorkingDayGain && metrics.longestWorkSequence === bestMetrics.longestWorkSequence && metrics.shortSequenceDeficit < bestMetrics.shortSequenceDeficit : metrics.nonWorkingDayGain === bestMetrics.nonWorkingDayGain && metrics.longestWorkSequence === bestMetrics.longestWorkSequence && metrics.shortSequenceDeficit === bestMetrics.shortSequenceDeficit && date < bestDate)); const evaluation = recordTraceEvaluation( context, result, feasibleCandidate, preferences, baseline, metrics, [date], current.date, () => { const protectedDetails = movableProtectionTraceDetails( baseline, metrics, preferences, ); const incumbentDetails = movableIncumbentTraceDetails( stage, bestMetrics, metrics, bestDate, date, ); return { technicalComparison: [ ...(protectedDetails.technicalComparison ?? []), ...(incumbentDetails.technicalComparison ?? []), ], firstDecisiveComparator: protectedDetails.reason?.code === 'quality-protected-objective' ? protectedDetails.firstDecisiveComparator : incumbentDetails.firstDecisiveComparator, reason: better ? undefined : reason( 'quality-no-improvement', `This candidate did not improve the current best result in the ${stage} pass.`, ), }; }, ); if (better) { if (bestEvaluation) updateTraceEvaluationReason( context, bestEvaluation, reason( 'quality-outperformed', 'A later candidate became the best result in this pass.', ), ); best = cloneState(feasibleCandidate); bestMetrics = metrics; bestDate = date; bestEvaluation = evaluation; } } finally { without.placements.delete(date); without.balances.find((item) => item.id === current.balanceId)!.remaining++; } } if (best !== result) recordTraceState(context, best, bestEvaluation); result = best; } return result; } // ponytail: regroup only flexible placements and time-window rules; add wider // rule reshaping only if a real plan needs coordinated if-then moves. function completeHolidayWeek( state: State, candidate: HolidayWeekCandidate, context: SearchContext, protectedDates: Set, ): State | null { const targetDates = new Set(candidate.dates); const missing = candidate.dates.filter((date) => !state.placements.has(date)); if (!missing.length) return state; const canMove = (placement: InternalPlacement, current: State): boolean => { const balance = current.balances.find((item) => item.id === placement.balanceId); return ( !placement.supporting && !targetDates.has(placement.date) && !protectedDates.has(placement.date) && !!balance && (placement.ruleIds.size ? isMovableTimeWindow(placement, context) : isAtRisk(balance, context)) ); }; const search = (index: number, current: State): State | null => { context.check(); if (index >= missing.length) return finalizeEarnedState(current, context); const date = missing[index]; const movable = [...current.placements.values()] .filter((placement) => canMove(placement, current)) .sort( (a, b) => compareDate( current.balances.find((item) => item.id === a.balanceId)!.expiryDate, current.balances.find((item) => item.id === b.balanceId)!.expiryDate, ) || compareText(a.balanceId, b.balanceId) || compareDate(a.date, b.date), ); for (const source of movable) { if (!compatibleWithRules(date, source.type, source.ruleIds, context)) { recordTraceIneligible( context, date, 'rule-invalid', 'The date does not preserve the placement rules.', [...source.ruleIds], ); continue; } const moved = cloneState(current); moved.placements.delete(source.date); const released = moved.balances.find((item) => item.id === source.balanceId); if (!released) continue; released.remaining++; const reassigned = assignDateToBalance( moved, date, source.balanceId, context, source.ruleIds, ); if (!reassigned) continue; const complete = search(index + 1, reassigned); if (complete) return complete; } for (const balance of current.balances .filter((item) => item.remaining > 0) .sort((a, b) => compareDate(a.expiryDate, b.expiryDate) || compareText(a.id, b.id))) { const assigned = assignDateToBalance(current, date, balance.id, context); if (!assigned) continue; assigned.placements.get(date)!.supporting = !isAtRisk(balance, context); const complete = search(index + 1, assigned); if (complete) return complete; } return null; }; return search(0, state); } function optimizeHolidayWeeks( state: State, context: SearchContext, preferences: PreferenceResult[], ): State { let result = state; const candidates = context.holidayWeeks; while (true) { context.check(); if (candidates.length) recordTraceDecision(context, []); const baseline = qualityMetrics(result, context, preferences); const completed = candidates.filter((candidate) => candidate.dates.every((date) => result.placements.has(date)), ); let best: State | null = null; let bestMetrics = baseline; let bestEvaluation: MutableTraceCandidate | undefined; for (const candidate of candidates) { context.check(); const protectedDates = new Set( completed .filter((week) => week.value >= candidate.value) .flatMap((week) => week.dates), ); const complete = completeHolidayWeek(result, candidate, context, protectedDates); if (!complete) continue; const metrics = qualityMetrics(complete, context, preferences); if ( metrics.unplacedAtRisk > baseline.unplacedAtRisk || comparePreferenceVectors( metrics.preferenceSatisfaction, baseline.preferenceSatisfaction, ) !== 0 || metrics.holidayWeekValue <= baseline.holidayWeekValue ) { recordTraceEvaluation( context, result, complete, preferences, baseline, metrics, candidate.dates, undefined, () => holidayWeekProtectionTraceDetails(baseline, metrics, preferences), ); continue; } const better = !best || compareMetrics(metrics, bestMetrics) < 0 || (compareMetrics(metrics, bestMetrics) === 0 && placementTieKey(complete) < placementTieKey(best)); const evaluation = recordTraceEvaluation( context, result, complete, preferences, baseline, metrics, candidate.dates, undefined, () => { const protectedDetails = holidayWeekProtectionTraceDetails( baseline, metrics, preferences, ); const incumbentDetails = best ? qualityMetricTieTraceDetails(bestMetrics, metrics, best, complete) : undefined; return { technicalComparison: [ ...(protectedDetails.technicalComparison ?? []), ...(incumbentDetails?.technicalComparison ?? []), ], firstDecisiveComparator: protectedDetails.reason ? protectedDetails.firstDecisiveComparator : incumbentDetails?.firstDecisiveComparator, reason: better ? undefined : reason( 'quality-no-improvement', 'This completion did not outrank the current best on its compared objectives or date tie-break.', ), }; }, ); if (better) { if (bestEvaluation) updateTraceEvaluationReason( context, bestEvaluation, reason( 'quality-outperformed', 'A later holiday-week completion became the best result in this pass.', ), ); best = complete; bestMetrics = metrics; bestEvaluation = evaluation; } } if (!best) return result; recordTraceState(context, best, bestEvaluation); result = best; } } function sameHigherQuality(a: PlanningMetrics, b: PlanningMetrics): boolean { return ( a.unplacedAtRisk === b.unplacedAtRisk && a.holidayWeekValue === b.holidayWeekValue && a.nonWorkingDayGain === b.nonWorkingDayGain && a.longestWorkSequence === b.longestWorkSequence && a.shortSequenceDeficit === b.shortSequenceDeficit && a.preferenceSatisfaction.length === b.preferenceSatisfaction.length && a.preferenceSatisfaction.every((value, index) => value === b.preferenceSatisfaction[index]) ); } function expiryProtectionTraceDetails( baseline: PlanningMetrics, candidate: PlanningMetrics, preferences: readonly PreferenceResult[], ): QualityTraceDetails { const values: QualityTraceComparator[] = []; const reject = (name: string): QualityTraceDetails => ({ technicalComparison: values, firstDecisiveComparator: name, reason: reason( 'quality-protected-objective', `This replacement changes ${name.slice('protected:'.length).replaceAll('-', ' ')} protected by the expiry pass.`, ), }); for (const [name, direction, before, after] of [ [ 'protected:unplaced-at-risk', 'minimize', baseline.unplacedAtRisk, candidate.unplacedAtRisk, ], [ 'protected:holiday-week-value', 'maximize', baseline.holidayWeekValue, candidate.holidayWeekValue, ], [ 'protected:non-working-day-gain', 'maximize', baseline.nonWorkingDayGain, candidate.nonWorkingDayGain, ], [ 'protected:longest-work-sequence', 'minimize', baseline.longestWorkSequence, candidate.longestWorkSequence, ], [ 'protected:short-sequence-deficit', 'minimize', baseline.shortSequenceDeficit, candidate.shortSequenceDeficit, ], ] as const) { if (!addQualityComparator(values, name, direction, before, after)) return reject(name); } if (baseline.preferenceSatisfaction.length !== candidate.preferenceSatisfaction.length) { const name = 'protected:preference-count'; addQualityComparator( values, name, 'maximize', baseline.preferenceSatisfaction.length, candidate.preferenceSatisfaction.length, ); return reject(name); } for (let index = 0; index < baseline.preferenceSatisfaction.length; index++) { const name = `protected:preference:${preferences[index]?.ruleId ?? index}`; if ( !addQualityComparator( values, name, 'maximize', baseline.preferenceSatisfaction[index], candidate.preferenceSatisfaction[index], ) ) return reject(name); } return { technicalComparison: values }; } function optimizeExpiryPlacements( state: State, context: SearchContext, preferences: PreferenceResult[], ): State { let result = state; for (const original of [...result.placements.values()] .filter( (placement) => placement.ruleIds.size === 0 || isMovableTimeWindow(placement, context), ) .sort((a, b) => compareDate(a.date, b.date) || compareText(a.balanceId, b.balanceId))) { context.check(); const current = result.placements.get(original.date); if (!current) continue; const source = result.balances.find((balance) => balance.id === current.balanceId); if (!source) continue; recordTraceDecision(context, [original.date]); const baseline = qualityMetrics(result, context, preferences); const without = cloneState(result); without.placements.delete(current.date); const released = without.balances.find((balance) => balance.id === source.id); if (released) released.remaining++; let best = result; let bestExpiry = expiryTieKey(result); let bestEvaluation: MutableTraceCandidate | undefined; for (const replacement of without.balances .filter( (balance) => balance.id !== source.id && balance.type === current.type && balance.remaining > 0 && compareDate(balance.expiryDate, source.expiryDate) < 0 && compareDate(balance.availableFrom, current.date) <= 0 && compareDate(current.date, balance.expiryDate) <= 0, ) .sort((a, b) => compareDate(a.expiryDate, b.expiryDate) || compareText(a.id, b.id))) { const candidate = assignDateToBalance( without, current.date, replacement.id, context, current.ruleIds, ); if (!candidate) continue; const feasibleCandidate = finalizeEarnedState(candidate, context); if (!feasibleCandidate) continue; feasibleCandidate.placements.get(current.date)!.supporting = current.supporting; const metrics = qualityMetrics(feasibleCandidate, context, preferences); const expiry = expiryTieKey(feasibleCandidate); const preservesQuality = sameHigherQuality(metrics, baseline); const better = preservesQuality && expiry < bestExpiry; const evaluation = recordTraceEvaluation( context, result, feasibleCandidate, preferences, baseline, metrics, [current.date], current.date, () => { const protectedDetails = expiryProtectionTraceDetails( baseline, metrics, preferences, ); const values = [...(protectedDetails.technicalComparison ?? [])]; let firstDecisiveComparator = protectedDetails.firstDecisiveComparator; let candidateReason = protectedDetails.reason; if (!candidateReason) { const equalExpiry = addQualityComparator( values, 'balance-expiry', 'minimize', bestExpiry, expiry, ); if (!equalExpiry) firstDecisiveComparator = 'balance-expiry'; if (!better) candidateReason = reason( 'quality-no-improvement', 'This replacement does not use an earlier-expiring balance than the current best.', ); } return { technicalComparison: values, firstDecisiveComparator, reason: candidateReason, }; }, ); if (better) { if (bestEvaluation) updateTraceEvaluationReason( context, bestEvaluation, reason( 'quality-outperformed', 'A later replacement became the best earlier-expiry choice in this pass.', ), ); best = feasibleCandidate; bestExpiry = expiry; bestEvaluation = evaluation; } } if (best !== result) recordTraceState(context, best, bestEvaluation); result = best; } return result; } function isAtRisk(balance: InternalBalance, context: SearchContext): boolean { return ( compareDate(balance.expiryDate, context.horizon.start) >= 0 && compareDate(balance.expiryDate, context.horizon.end) <= 0 ); } function isMovableTimeWindow(placement: InternalPlacement, context: SearchContext): boolean { if (!placement.ruleIds.size) return false; return [...placement.ruleIds].every((id) => { const rule = context.rules.find((item) => item.id === id); return rule?.enabled && rule.type === 'time_window'; }); } function compatibleWithRules( date: string, type: LeaveType, ruleIds: Set, context: SearchContext, ): boolean { if (!isWorking(date, context.holidays)) return false; for (const id of ruleIds) { const rule = context.rules.find((item) => item.id === id); if ( !rule || rule.type !== 'time_window' || !selectorMatches(rule.placeType, type) || compareDate(date, rule.startDate!) < 0 || compareDate(date, rule.endDate!) > 0 ) return false; } return true; } function periodData(state: State, context: SearchContext): TimeOffPeriod[] { const dates = [...state.placements.keys()].sort(compareDate); if (!dates.length) return []; const groups: string[][] = []; let group = [dates[0]]; for (let i = 1; i < dates.length; i++) { context.check(); const previousIndex = context.calendarIndex.get(dates[i - 1])!; const currentIndex = context.calendarIndex.get(dates[i])!; const workingInGap = context.workingPrefix[currentIndex] - context.workingPrefix[previousIndex + 1]; if (workingInGap === 0) group.push(dates[i]); else { groups.push(group); group = [dates[i]]; } } groups.push(group); return groups.map((placementDates) => { context.check(); let startIndex = context.calendarIndex.get(placementDates[0])!; let endIndex = context.calendarIndex.get(placementDates[placementDates.length - 1])!; while (startIndex > 0 && !context.workingCalendar.has(context.calendarDays[startIndex - 1])) startIndex--; while ( endIndex + 1 < context.calendarDays.length && !context.workingCalendar.has(context.calendarDays[endIndex + 1]) ) endIndex++; const start = context.calendarDays[startIndex]; const end = context.calendarDays[endIndex]; const allDates = context.calendarDays.slice(startIndex, endIndex + 1); const nonWorkingDates = allDates.filter((date) => !context.workingCalendar.has(date)); return { start, end, duration: allDates.length, placementDates: [...placementDates], nonWorkingDates, nonWorkingDayGain: nonWorkingDates.length, }; }); } function qualityMetrics( state: State, context: SearchContext, preferences: readonly PreferenceResult[], ): PlanningMetrics { const periods = periodData(state, context); const placed = new Set(state.placements.keys()); const sequences: number[] = []; let current = 0; let hasPlacement = false; for (const date of context.workingDays) { context.check(); if (placed.has(date)) { sequences.push(current); current = 0; hasPlacement = true; } else current++; } if (context.workingDays.length) sequences.push(current); const internal = hasPlacement ? sequences.slice(1, -1) : []; const shortSequenceDeficit = internal.reduce( (sum, length) => sum + (length > 0 && length < 5 ? 5 - length : 0), 0, ); const atRiskBalances = state.balances.filter( (balance) => compareDate(balance.expiryDate, context.horizon.start) >= 0 && compareDate(balance.expiryDate, context.horizon.end) <= 0, ); const atRisk = atRiskBalances .filter((balance) => balance.quantity > 0) .reduce( (sum, balance) => sum + Math.max( 0, balance.quantity - (state.balances.find((item) => item.id === balance.id)?.remaining ?? 0), ), 0, ); const totalRisk = atRiskBalances.reduce((sum, balance) => sum + balance.quantity, 0); const unplacedAtRisk = totalRisk - atRisk; return { unplacedAtRisk, holidayWeekValue: holidayWeekValue(state, context), nonWorkingDayGain: periods.reduce((sum, period) => sum + period.nonWorkingDayGain, 0), longestWorkSequence: sequences.length ? Math.max(...sequences) : 0, shortSequenceDeficit, supportingPlacements: [...state.placements.values()].filter( (placement) => placement.supporting, ).length, preferenceSatisfaction: preferences.map((preference) => preference.satisfied), }; } function remainingBalances(state: State, context: SearchContext): 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'; const code = classification === 'unavoidable-expiry' ? 'unavoidable-expiry' : classification; return { id: balance.id, type: balance.type, quantity: balance.remaining, availableFrom: balance.availableFrom, expiryDate: balance.expiryDate, classification, reason: reason( code, classification === 'unavoidable-expiry' ? 'This balance expired before the planning horizon.' : classification === 'at-risk' ? 'This balance expires within the planning horizon.' : 'This balance remains available after the planning horizon.', ), 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.rule.placeDays) unsatisfied++; continue; } let complete = false; for (const pattern of requirement.patterns) { if ( pattern.every((date) => state.placements.get(date)?.ruleIds.has(requirement.rule.id), ) ) { complete = true; break; } } if (!complete) unsatisfied++; } return unsatisfied; } function objectiveVector( metrics: PlanningMetrics, preferences: readonly PreferenceResult[], state: State, context: SearchContext, ): PlanningObjectiveVector { const objectives: PlanningObjective[] = [ { id: 'obligation-validity', direction: 'minimize', value: unsatisfiedObligationCount(state, context), label: 'Unsatisfied obligations', }, { id: 'unplaced-at-risk', direction: 'minimize', value: metrics.unplacedAtRisk, label: 'Unplaced at-risk leave', }, ...preferences.map((preference) => ({ id: `preference:${preference.ruleId}` as const, direction: 'maximize' as const, value: preference.satisfied, label: `Preference ${preference.ruleId}`, })), { id: 'holiday-week-value', direction: 'maximize', value: metrics.holidayWeekValue, label: 'Public-holiday week value', }, { id: 'non-working-day-gain', direction: 'maximize', value: metrics.nonWorkingDayGain, label: 'Non-working-day gain', }, { id: 'longest-work-sequence', direction: 'minimize', value: metrics.longestWorkSequence, label: 'Longest work sequence', }, { id: 'short-sequence-deficit', direction: 'minimize', value: metrics.shortSequenceDeficit, label: 'Short work-sequence deficit', }, { id: 'supporting-placements', direction: 'minimize', value: metrics.supportingPlacements, label: 'Supporting placements', }, { id: 'balance-expiry', direction: 'minimize', value: expiryTieKey(state), label: 'Balance expiry tie-break', }, { id: 'placement-date', direction: 'minimize', value: placementTieKey(state), label: 'Placement date tie-break', }, ]; return { objectives }; } function displayLabel(value: unknown, fallback: string): string { return typeof value === 'string' && value.trim() ? value : fallback; } 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(metrics, preferences, state, context); return { kind: 'PlanExplanation', placements: placements.map((placement) => { const balance = rawBalances .map(recordValue) .find((candidate) => candidate?.id === placement.balanceId); const normalizedBalance = state.balances.find( (candidate) => candidate.id === placement.balanceId, ); const period = periods.find((candidate) => candidate.placementDates.includes(placement.date), ); const sourceRules = placement.ruleIds.map((id) => { const rule = context.rules.find((candidate) => candidate.id === id); const rawRule = rawRules.map(recordValue).find((candidate) => candidate?.id === id); const label = displayLabel( labels?.[id] ?? identities?.[id] ?? rawRule?.name ?? rule?.name, id, ); return { id, label }; }); const atRisk = normalizedBalance ? compareDate(normalizedBalance.expiryDate, context.horizon.start) >= 0 && compareDate(normalizedBalance.expiryDate, context.horizon.end) <= 0 : false; const withoutPlacement = cloneState(state); withoutPlacement.placements.delete(placement.date); const released = withoutPlacement.balances.find( (candidate) => candidate.id === placement.balanceId, ); if (released) released.remaining++; const withoutPreferences = preferenceResultsForState(withoutPlacement, context); const withoutMetrics = qualityMetrics(withoutPlacement, context, withoutPreferences); const comparison = objectiveComparison( objectiveVector(withoutMetrics, withoutPreferences, 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); const plan: PlanOutcome = { kind: 'Plan', horizon: context.horizon, placements, timeOffPeriods: periods, preferences, remainingBalances: remaining, metrics, completedStages: [...stages], lastCompletedStage: stages[stages.length - 1] ?? 'validation', }; recordTraceState(context, state); return plan; } function markProgress(context: SearchContext, stage: string): void { if (context.trace && stage !== 'complete') { observeTrace(context, (trace) => { trace.startPhase(stage); trace.recordDecision(stage === 'validation' ? 'validate' : 'choose', []); }); } if (typeof context.request.progress === 'function') observeCallback(context, () => (context.request.progress as (stage: string) => void)(stage), ); } function planningStages(context: SearchContext): string[] { return [ 'validation', 'obligations', 'at-risk', ...context.rules .filter((rule) => rule.enabled && rule.force === 'preference') .map((rule) => `preference:${rule.id}`), 'holiday-week-value', 'non-working-day-gain', 'longest-work-sequence', 'short-sequence-deficit', 'supporting-placements', '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.enabled && (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.patterns?.[0]?.length ?? requirement.rule.placeDays; 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.candidates.length === 0), ) ) return { kind: 'PlanningConflict', conflictingRuleIds: ruleIds, reasons: [ reason( 'horizon-bottleneck', 'The planning horizon contains no eligible dates for at least one obligation.', ruleIds, ), ], }; if (generatedCapacityBottleneck) return { kind: 'PlanningConflict', conflictingRuleIds: ruleIds, reasons: [ reason( 'earned-entitlement-bottleneck', 'An earned leave entitlement is not available early enough to satisfy every obligation.', ruleIds, ), ], }; return { kind: 'PlanningConflict', conflictingRuleIds: ruleIds, reasons: [ reason( 'joint-date-conflict', 'The obligation date patterns cannot be satisfied together.', ruleIds, ), ], }; } function supportingProtectionTraceDetails( baseline: PlanningMetrics, candidate: PlanningMetrics, preferences: readonly PreferenceResult[], ): QualityTraceDetails { const values: QualityTraceComparator[] = []; const reject = (name: string, message: string): QualityTraceDetails => ({ technicalComparison: values, firstDecisiveComparator: name, reason: reason('quality-protected-objective', message), }); const same = ( name: string, direction: QualityTraceComparator['direction'], a: number, b: number, ) => addQualityComparator(values, name, direction, a, b); if ( !same( 'protected:unplaced-at-risk', 'minimize', baseline.unplacedAtRisk, candidate.unplacedAtRisk, ) ) return reject( 'protected:unplaced-at-risk', 'This supporting placement changes the amount of unplaced at-risk leave.', ); if (baseline.preferenceSatisfaction.length !== candidate.preferenceSatisfaction.length) { const name = 'protected:preference-count'; same( name, 'maximize', baseline.preferenceSatisfaction.length, candidate.preferenceSatisfaction.length, ); return reject(name, 'This supporting placement changes a preference result.'); } for (let index = 0; index < baseline.preferenceSatisfaction.length; index++) { const name = `protected:preference:${preferences[index]?.ruleId ?? index}`; if ( !same( name, 'maximize', baseline.preferenceSatisfaction[index], candidate.preferenceSatisfaction[index], ) ) return reject(name, 'This supporting placement changes a preference result.'); } for (const [name, direction, before, after, isAllowed] of [ [ 'protected:holiday-week-value', 'maximize', baseline.holidayWeekValue, candidate.holidayWeekValue, candidate.holidayWeekValue >= baseline.holidayWeekValue, ], [ 'protected:non-working-day-gain', 'maximize', baseline.nonWorkingDayGain, candidate.nonWorkingDayGain, candidate.nonWorkingDayGain >= baseline.nonWorkingDayGain, ], [ 'protected:longest-work-sequence', 'minimize', baseline.longestWorkSequence, candidate.longestWorkSequence, candidate.longestWorkSequence <= baseline.longestWorkSequence, ], [ 'protected:short-sequence-deficit', 'minimize', baseline.shortSequenceDeficit, candidate.shortSequenceDeficit, candidate.shortSequenceDeficit <= baseline.shortSequenceDeficit, ], ] as const) { same(name, direction, before, after); if (!isAllowed) return reject( name, `This supporting placement worsens ${name.slice('protected:'.length).replaceAll('-', ' ')}.`, ); } return { technicalComparison: values }; } function supportingRemovalTraceDetails( baseline: PlanningMetrics, candidate: PlanningMetrics, ): QualityTraceDetails { const values: QualityTraceComparator[] = []; const reject = (name: string, message: string): QualityTraceDetails => ({ technicalComparison: values, firstDecisiveComparator: name, reason: reason('quality-protected-objective', message), }); if ( !addQualityComparator( values, 'protected:unplaced-at-risk', 'minimize', baseline.unplacedAtRisk, candidate.unplacedAtRisk, ) ) return reject( 'protected:unplaced-at-risk', 'Removing this supporting placement changes unplaced at-risk leave.', ); for (const [name, direction, before, after, isAllowed] of [ [ 'protected:holiday-week-value', 'maximize', baseline.holidayWeekValue, candidate.holidayWeekValue, candidate.holidayWeekValue >= baseline.holidayWeekValue, ], [ 'protected:non-working-day-gain', 'maximize', baseline.nonWorkingDayGain, candidate.nonWorkingDayGain, candidate.nonWorkingDayGain >= baseline.nonWorkingDayGain, ], [ 'protected:longest-work-sequence', 'minimize', baseline.longestWorkSequence, candidate.longestWorkSequence, candidate.longestWorkSequence <= baseline.longestWorkSequence, ], [ 'protected:short-sequence-deficit', 'minimize', baseline.shortSequenceDeficit, candidate.shortSequenceDeficit, candidate.shortSequenceDeficit <= baseline.shortSequenceDeficit, ], ] as const) { addQualityComparator(values, name, direction, before, after); if (!isAllowed) return reject( name, `Removing this supporting placement worsens ${name.slice('protected:'.length).replaceAll('-', ' ')}.`, ); } return { technicalComparison: values }; } function supportingPlacements( state: State, context: SearchContext, preferences: PreferenceResult[], ): State { let result = state; while (true) { context.check(); const before = qualityMetrics(result, context, preferences); let best: State | null = null; let bestMetrics: PlanningMetrics | null = null; let bestEvaluation: MutableTraceCandidate | undefined; for (const balance of result.balances .filter( (item) => compareDate(item.expiryDate, context.horizon.end) > 0 && item.remaining > 0, ) .sort((a, b) => compareDate(a.expiryDate, b.expiryDate) || compareText(a.id, b.id))) { for (const date of context.workingDays) { context.check(); if ( result.placements.has(date) || compareDate(balance.availableFrom, date) > 0 || compareDate(date, balance.expiryDate) > 0 ) continue; const candidate = assignDateToBalance(result, date, balance.id, context); if (!candidate) continue; candidate.placements.get(date)!.supporting = true; const feasibleCandidate = finalizeEarnedState(candidate, context); if (!feasibleCandidate) continue; const metrics = qualityMetrics(feasibleCandidate, context, preferences); const preservesHigherMetrics = metrics.unplacedAtRisk === before.unplacedAtRisk && metrics.preferenceSatisfaction.length === before.preferenceSatisfaction.length && metrics.preferenceSatisfaction.every( (value, index) => value === before.preferenceSatisfaction[index], ) && metrics.holidayWeekValue >= before.holidayWeekValue && metrics.nonWorkingDayGain >= before.nonWorkingDayGain && metrics.longestWorkSequence <= before.longestWorkSequence && metrics.shortSequenceDeficit <= before.shortSequenceDeficit; if (!preservesHigherMetrics) { recordTraceEvaluation( context, result, feasibleCandidate, preferences, before, metrics, [date], undefined, () => supportingProtectionTraceDetails(before, metrics, preferences), ); continue; } const useful = metrics.holidayWeekValue > before.holidayWeekValue || metrics.nonWorkingDayGain > before.nonWorkingDayGain || metrics.longestWorkSequence < before.longestWorkSequence || metrics.shortSequenceDeficit < before.shortSequenceDeficit; if (!useful) { recordTraceEvaluation( context, result, feasibleCandidate, preferences, before, metrics, [date], undefined, () => ({ ...supportingProtectionTraceDetails(before, metrics, preferences), reason: reason( 'quality-no-improvement', 'This supporting placement does not improve a protected quality objective.', ), }), ); continue; } const better = !bestMetrics || compareMetrics(metrics, bestMetrics) < 0; const evaluation = recordTraceEvaluation( context, result, feasibleCandidate, preferences, before, metrics, [date], undefined, () => { const protectedDetails = supportingProtectionTraceDetails( before, metrics, preferences, ); const incumbentDetails = bestMetrics ? qualityMetricTraceDetails(bestMetrics, metrics) : undefined; return { technicalComparison: [ ...(protectedDetails.technicalComparison ?? []), ...(incumbentDetails?.technicalComparison ?? []), ], firstDecisiveComparator: incumbentDetails?.firstDecisiveComparator, reason: better ? undefined : reason( 'quality-no-improvement', 'This candidate did not outrank the current best supporting placement.', ), }; }, ); if (better) { if (bestEvaluation) updateTraceEvaluationReason( context, bestEvaluation, reason( 'quality-outperformed', 'A later candidate became the best supporting placement.', ), ); best = feasibleCandidate; bestMetrics = metrics; bestEvaluation = evaluation; } } } if (!best) return result; recordTraceState(context, best, bestEvaluation); result = best; } } function pruneRedundantSupportingPlacements( state: State, context: SearchContext, preferences: PreferenceResult[], ): State { let result = state; let removed = true; while (removed) { context.check(); removed = false; const baseline = qualityMetrics(result, context, preferences); for (const placement of [...result.placements.values()].filter((item) => item.supporting)) { context.check(); recordTraceDecision(context, [placement.date]); const candidate = cloneState(result); candidate.placements.delete(placement.date); const balance = candidate.balances.find((item) => item.id === placement.balanceId); if (balance) balance.remaining++; const metrics = qualityMetrics(candidate, context, preferences); const removesWithoutQualityLoss = metrics.unplacedAtRisk === baseline.unplacedAtRisk && metrics.holidayWeekValue >= baseline.holidayWeekValue && metrics.nonWorkingDayGain >= baseline.nonWorkingDayGain && metrics.longestWorkSequence <= baseline.longestWorkSequence && metrics.shortSequenceDeficit <= baseline.shortSequenceDeficit; const evaluation = recordTraceEvaluation( context, result, candidate, preferences, baseline, metrics, [placement.date], placement.date, () => { const details = supportingRemovalTraceDetails(baseline, metrics); return { ...details, reason: removesWithoutQualityLoss ? undefined : details.reason, }; }, ); if (removesWithoutQualityLoss) { recordTraceState(context, candidate, evaluation); result = candidate; removed = true; break; } } } return result; } function compareMetrics(a: PlanningMetrics, b: PlanningMetrics): number { if (a.unplacedAtRisk !== b.unplacedAtRisk) return a.unplacedAtRisk - b.unplacedAtRisk; if (a.holidayWeekValue !== b.holidayWeekValue) return b.holidayWeekValue - a.holidayWeekValue; if (a.nonWorkingDayGain !== b.nonWorkingDayGain) return b.nonWorkingDayGain - a.nonWorkingDayGain; if (a.longestWorkSequence !== b.longestWorkSequence) return a.longestWorkSequence - b.longestWorkSequence; if (a.shortSequenceDeficit !== b.shortSequenceDeficit) return a.shortSequenceDeficit - b.shortSequenceDeficit; return a.supportingPlacements - b.supportingPlacements; } export type PlanningCheckpoint = (plan: PlanOutcome, state?: PlanningTraceState) => void; export interface PlanningArtifacts { explanation?: PlanningExplanation; trace?: PlanningTrace; finalState?: PlanningTraceState; 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: PlanningTraceState | undefined, ) => void; /** @internal Worker-only synchronous core. Application code uses the public calculation context. */ export function calculatePlanningInternal( request: unknown, checkpointCallback?: PlanningCheckpoint, artifactsCallback?: PlanningArtifactsCallback, outcomeCallback?: PlanningOutcomeCallback, explanationCallback?: PlanningExplanationCallback, ): PlanningOutcome { const source = runtimeRequest(request); const normalized = normalizeRequest(source); if (normalized.reasons.length) { const outcome = invalidInputFor(source)!; outcomeCallback?.(outcome); artifactsCallback?.({}); return outcome; } const horizon = normalized.horizon!; const holidays = holidaySet(horizon); const workingDays = dateRange(horizon.start, horizon.end).filter((date) => isWorking(date, holidays), ); 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 started = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now(); const configuredLimit = typeof source.deadlineMs === 'number' ? source.deadlineMs : MAX_PLANNING_MS; const deadline = Math.min(Math.max(0, configuredLimit), MAX_PLANNING_MS); // Leave a small scheduling margin so the synchronous SSR/test fallback also // observes the public ten-second wall-clock ceiling; browser workers have a // separate watchdog in planInWorker. const coreDeadline = deadline === MAX_PLANNING_MS ? deadline - CORE_DEADLINE_MARGIN_MS : deadline; const context = { horizon, holidays, holidayWeeks: [], calendarDays, calendarIndex, workingCalendar, workingPrefix, workingDays, initialBalances: normalized.balances, rules: normalized.rules, deadline: coreDeadline, started, request: source, nodes: 0, check: () => undefined, reservedTypes: new Set(), sharedTypesByDate: new Map(), protectedTypesByDate: new Map(), preferenceDemandByDate: new Map(), anyPreferenceDemandByDate: new Map(), traceTime: 0, traceCaptureTime: 0, traceTransferTime: 0, traceReportedTime: 0, observerTime: 0, obligationSolutionBranches: new WeakMap(), } as SearchContext; const traceStarted = now(); const traceRecorder = artifactsCallback ? new TraceRecorder( { horizon, balances: normalized.balances.map((balance) => ({ ...balance, remaining: balance.quantity, })), placements: [], earnedActivations: [], }, planningStages(context), new Set(workingDays), typeof context.request.traceProgress === 'function', ) : undefined; if (traceRecorder) { const elapsed = now() - traceStarted; context.traceTime += elapsed; context.traceCaptureTime += elapsed; } context.trace = traceRecorder; context.holidayWeeks = holidayWeekCandidates(context); context.check = () => { context.nodes++; if (context.deadline <= 1 || context.nodes % 16 === 0) { if ( now() - context.started - context.traceTime - 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; const finish = (outcome: PlanningOutcome, explanationState?: State): PlanningOutcome => { const planningFinishedAt = now(); const planningExcludedTime = context.traceTime + context.observerTime; outcomeCallback?.(outcome); const materializationStarted = now(); // 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: PlanningTraceState | 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 = planningTraceState(explanationState, context.horizon); } catch (error) { explanationError ??= reason( 'explanation-error', error instanceof Error ? error.message : String(error), ); } let explanationDeliveryTime: number; const explanationDeliveryStarted = now(); try { explanationCallback?.(explanation, explanationError, finalState); } catch (error) { explanationError ??= reason( 'explanation-error', error instanceof Error ? error.message : String(error), ); } finally { explanationDeliveryTime = now() - explanationDeliveryStarted; } let trace: PlanningTrace | undefined; try { trace = traceRecorder?.finish( context.traceError ? 'failed' : outcome.kind === 'PlanningIncomplete' ? 'incomplete' : 'ready', outcome.kind === 'Plan' ? outcome.completedStages : outcome.kind === 'PlanningIncomplete' ? outcome.completedStages : stages, context.traceError ?? (outcome.kind === 'PlanningIncomplete' ? outcome.reasons[0] : undefined), ); } catch (error) { const traceError = reason( 'trace-error', error instanceof Error ? error.message : String(error), ); trace = { status: 'failed', initialState: { horizon, balances: normalized.balances.map((balance) => ({ ...balance, remaining: balance.quantity, })), placements: [], earnedActivations: [], }, phases: [], completedStages: [], nonWorkingDates: dateRange(horizon.start, horizon.end) .filter((date) => !workingDays.includes(date)) .map((date) => ({ date, reason: reason('non-working', 'The date is a weekend or public holiday.'), })), error: traceError, }; } let finalTraceDeliveryTime = 0; if ( context.trace === traceRecorder && typeof context.request.traceProgress === 'function' ) { const update = traceRecorder?.drainUpdate(); if (update) { const deliveryStarted = now(); try { (context.request.traceProgress as PlanningTraceProgressCallback)( context.traceTime, update, ); } catch (error) { const traceError = reason( 'trace-error', error instanceof Error ? error.message : String(error), ); trace = trace ? { ...trace, status: 'failed', error: traceError } : undefined; context.traceError = traceError; } finally { finalTraceDeliveryTime = now() - deliveryStarted; context.traceTime += finalTraceDeliveryTime; context.traceTransferTime += finalTraceDeliveryTime; } } } const traceMaterializationMs = Math.max( 0, now() - materializationStarted - explanationDeliveryTime - finalTraceDeliveryTime, ); artifactsCallback?.({ explanation, trace, finalState, explanationError, timings: { planningWorkMs: Math.max(0, planningFinishedAt - started - planningExcludedTime), traceCaptureMs: context.traceCaptureTime, traceMaterializationMs, traceTransferMs: context.traceTransferTime, }, }); return outcome; }; const publishCheckpoint = (): void => { checkpoint = makePlan(state, context, preferences, stages); checkpointState = cloneState(state); if (checkpointCallback) { let checkpointSnapshot: PlanningTraceState; const captureStarted = now(); try { checkpointSnapshot = planningTraceState(checkpointState, context.horizon); } finally { const captureTime = now() - captureStarted; context.traceTime += captureTime; context.traceCaptureTime += captureTime; } observeCallback(context, () => checkpointCallback(checkpoint!, checkpointSnapshot)); } }; try { markProgress(context, 'validation'); // At-risk quantity is the first objective, but equivalent placements can // still preserve later preferences by sharing their compatible dates. context.protectedTypesByDate = futurePreferenceProtection(normalized.rules, 0, context); markProgress(context, 'obligations'); const obligationRequirements = buildRequirements(normalized.rules, context, 'obligation'); context.reservedTypes = new Set( obligationRequirements .map((requirement) => requirement.rule.placeType) .filter((selector): selector is LeaveType => selector !== 'any'), ); for (const requirement of obligationRequirements) { if ( !requirement.candidates || 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 hasEarnedObligation = obligationRequirements.some( (requirement) => (requirement.rule.rewardDays ?? 0) > 0, ); const obligationSolutionLimit = hasEarnedObligation || obligationBranchEstimate(obligationRequirements) <= EXACT_OBLIGATION_BRANCH_LIMIT ? 0 : OBLIGATION_SOLUTION_LIMIT; satisfyObligations( obligationRequirements, 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 bestPreview: ObligationPreview | null = null; for (const obligationSolution of obligationSolutions) { context.check(); const obligationPath = context.obligationSolutionBranches.get(obligationSolution) ?? []; const previousBranchActive = context.traceBranchActive; const previousBranchParent = context.traceBranchParentId; context.traceBranchActive = true; context.traceBranchParentId = obligationPath.at(-1)?.decisionId; try { const atRiskState = placeAtRisk(cloneState(obligationSolution), context); const preview = applyPreferenceRules(atRiskState, 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) : undefined; const branch: ObligationBranchReference | undefined = compared !== undefined ? recordTraceBranchDecision( context, previewState, previewState, context.traceBranchParentId, [], () => obligationPreviewComparisonFacts(candidate, bestPreview!), ) : undefined; const previewCandidate: ObligationPreview = { ...candidate, previewBranch: branch, }; if (!bestPreview || compared! < 0) bestPreview = previewCandidate; } finally { context.traceBranchActive = previousBranchActive; context.traceBranchParentId = previousBranchParent; } } if (!bestPreview) { stages.push('obligations'); return finish(obligationConflict(obligationRequirements, context)); } state = bestPreview.obligationState; chooseObligationBranches(state, context); if (bestPreview.previewBranch) observeTrace(context, (trace) => trace.chooseEvaluation(bestPreview!.previewBranch!.candidate), ); stages.push('obligations'); publishCheckpoint(); markProgress(context, 'at-risk'); context.protectedTypesByDate = futurePreferenceProtection(normalized.rules, 0, context); context.reservedTypes = new Set( obligationRequirements .map((requirement) => requirement.rule.placeType) .filter((selector): selector is LeaveType => selector !== 'any'), ); state = placeAtRisk(state, context); stages.push('at-risk'); publishCheckpoint(); for (let preferenceIndex = 0; preferenceIndex < preferenceRules.length; preferenceIndex++) { const rule = preferenceRules[preferenceIndex]; markProgress(context, `preference:${rule.id}`); state = applyPreferenceStep( state, preferenceRules, preferenceIndex, preferences, context, ); stages.push(`preference:${rule.id}`); publishCheckpoint(); } markProgress(context, 'holiday-week-value'); context.check(); state = optimizeHolidayWeeks(state, context, preferences); stages.push('holiday-week-value'); publishCheckpoint(); markProgress(context, 'non-working-day-gain'); context.check(); state = optimizeMovablePlacements(state, context, preferences, 'non-working-day-gain'); stages.push('non-working-day-gain'); publishCheckpoint(); markProgress(context, 'longest-work-sequence'); context.check(); state = optimizeMovablePlacements(state, context, preferences, 'longest-work-sequence'); stages.push('longest-work-sequence'); publishCheckpoint(); markProgress(context, 'short-sequence-deficit'); context.check(); state = optimizeMovablePlacements(state, context, preferences, 'short-sequence-deficit'); stages.push('short-sequence-deficit'); publishCheckpoint(); markProgress(context, 'supporting-placements'); state = supportingPlacements(state, context, preferences); state = pruneRedundantSupportingPlacements(state, context, preferences); stages.push('supporting-placements'); publishCheckpoint(); markProgress(context, 'expiry'); context.check(); state = optimizeExpiryPlacements(state, context, preferences); stages.push('expiry'); publishCheckpoint(); markProgress(context, 'dates'); context.check(); state = optimizeMovablePlacements(state, context, preferences, 'dates'); 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'); return finish(checkpoint!, state); } catch (error) { const interruption = 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: PlanningTraceState, interruption: PlanningReason, ): PlanningArtifacts { try { const source = runtimeRequest(request); 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), ), 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(), traceTime: 0, traceCaptureTime: 0, traceTransferTime: 0, traceReportedTime: 0, observerTime: 0, obligationSolutionBranches: new WeakMap(), } 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: planningTraceState(state, horizon), }; } 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), ), 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(), traceTime: 0, traceCaptureTime: 0, traceTransferTime: 0, traceReportedTime: 0, observerTime: 0, obligationSolutionBranches: new WeakMap(), } as SearchContext; context.holidayWeeks = holidayWeekCandidates(context); return context; } function stateFromPlanningTrace(state: PlanningTraceState): 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], })), }; } /** Explain basic date eligibility against a historical source state without validating future obligations. */ export function historicalCounterfactualReasons( request: unknown, sourceState: PlanningTraceState, selected: LeavePlacement, options?: CounterfactualBaselineOptions, ): readonly HistoricalCounterfactualReason[] { const source = runtimeRequest(request); const normalized = normalizeRequest(source); const horizon = normalized.horizon ?? sourceState.horizon; const context = counterfactualContext(source, normalized, horizon); const baseline = stateFromPlanningTrace(sourceState); const removeDate = options ? options.removeDate : selected.date; const removed = removeDate ? baseline.placements.get(removeDate) : undefined; if (removeDate) baseline.placements.delete(removeDate); const balance = baseline.balances.find((candidate) => candidate.id === selected.balanceId); if (balance && removed?.balanceId === selected.balanceId) balance.remaining++; const results: HistoricalCounterfactualReason[] = []; 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 (baseline.placements.has(date)) ineligible = counterfactualReason( 'occupied', 'Another placement already uses this date.', ); else if (!balance || balance.remaining <= 0) ineligible = counterfactualReason( 'unavailable', 'The selected leave entitlement is unavailable.', ); else if (compareDate(balance.availableFrom, date) > 0) ineligible = counterfactualReason( 'unavailable', 'The selected entitlement is not available yet.', ); else if (compareDate(date, balance.expiryDate) > 0) ineligible = counterfactualReason( 'expired', 'The selected entitlement has expired by this date.', ); else if (balance.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, reason: ineligible }); } return results; } 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 = after.objectives.findIndex( (objective, index) => !sameObjectiveValue( objective.value, before.objectives[index]?.value ?? objective.value, ), ); 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 === '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 || baseline.placements.get(candidateDate)?.ruleIds.has(rule.id) === true, ), ), ); 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.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; return count(previous) !== rule.placeDays || count(state) === rule.placeDays; } return occurrences(rule, context).every((occurrence) => { const patterns = patternsFor(rule, occurrence, context); const complete = (candidate: State) => patterns.some((pattern) => pattern.every( (date) => candidate.placements.get(date)?.ruleIds.has(rule.id) === true, ), ); return !complete(previous) || complete(state); }); }); } function preferenceResultsForState(state: State, context: SearchContext): PreferenceResult[] { return context.rules .filter((rule) => rule.enabled && rule.force === 'preference') .map((rule) => { const requested = rule.type === 'time_window' ? rule.placeDays : occurrences(rule, context).length; const satisfied = rule.type === 'time_window' ? [...state.placements.values()].filter((placement) => placement.ruleIds.has(rule.id), ).length : occurrences(rule, context).filter((occurrence) => patternsFor(rule, occurrence, context).some((pattern) => pattern.every( (date) => state.placements.get(date)?.ruleIds.has(rule.id) === true, ), ), ).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?: PlanningTraceState, options?: CounterfactualBaselineOptions, ): CounterfactualAnalysis { const source = runtimeRequest(request); const normalized = normalizeRequest(source); const horizon = normalized.horizon ?? plan.horizon; const context = counterfactualContext(source, normalized, horizon); const finalState: State = finalStateSnapshot ? stateFromPlanningTrace(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 = options ? options.removeDate : selected.date; const removed = removeDate ? baseline.placements.get(removeDate) : undefined; if (removeDate) baseline.placements.delete(removeDate); const released = baseline.balances.find((balance) => balance.id === selected.balanceId); if (released && removed?.balanceId === selected.balanceId) released.remaining++; const beforeState = options?.compareOriginalState ? comparisonState : baseline; const beforePreferences = preferenceResultsForState(beforeState, context); const beforeMetrics = qualityMetrics(beforeState, context, beforePreferences); const before = objectiveVector(beforeMetrics, beforePreferences, 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 (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 afterPreferences = preferenceResultsForState(finalized, context); const afterMetrics = qualityMetrics(finalized, context, afterPreferences); const after = objectiveVector(afterMetrics, afterPreferences, 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, }; }