Implement plan explanations, original decision traces, calendar replay, and worker counterfactual analysis. Preserve incomplete checkpoints and conflict reasons, with accessible navigation and localized comparison details. Keep planning outcomes independent of tracing and bound worker delivery with receiver acknowledgements. Retain complete candidate history while releasing temporary buffers and benchmark references. Validated with 201 tests, app/spec type checks, lint, production build, baseline outcome comparisons, and repeated loaded browser measurements. Document performance and remaining limits.
6519 lines
196 KiB
TypeScript
6519 lines
196 KiB
TypeScript
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<LeaveBalanceInput>;
|
||
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<LeaveBalanceInput>[];
|
||
rules: readonly Readonly<PlanningRule>[];
|
||
identities: Readonly<Record<string, string>>;
|
||
labels: Readonly<Record<string, string>>;
|
||
}
|
||
|
||
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<PlanningTracePhase, 'id' | 'name' | 'completed' | 'status'>;
|
||
}
|
||
| {
|
||
kind: 'decision';
|
||
phaseId: string;
|
||
decision: Pick<PlanningTraceDecision, 'id' | 'action' | 'dates' | 'reason' | 'branch'>;
|
||
}
|
||
| {
|
||
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<string>;
|
||
supporting: boolean;
|
||
}
|
||
|
||
interface EarnedActivation {
|
||
id: string;
|
||
ruleId: string;
|
||
occurrence: string;
|
||
dates: string[];
|
||
}
|
||
|
||
interface State {
|
||
balances: InternalBalance[];
|
||
placements: Map<string, InternalPlacement>;
|
||
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<SearchContext, Requirement[]>();
|
||
|
||
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<PlanningTraceOperation, { kind: 'candidate' }>
|
||
>();
|
||
private initialStateSent = false;
|
||
private previousPlacements = new Map<string, LeavePlacement>();
|
||
private previousEarned = new Set<string>();
|
||
private previousBalances: ReadonlyMap<string, PlanningTraceState['balances'][number]>;
|
||
|
||
constructor(
|
||
private readonly initialState: PlanningTraceState,
|
||
private readonly plannedPhases: readonly string[],
|
||
private readonly workingDays: ReadonlySet<string>,
|
||
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<PlanningTraceOperation, { kind: 'candidate' }> = {
|
||
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<string>();
|
||
const movedRemoved = new Set<string>();
|
||
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<string>;
|
||
holidayWeeks: HolidayWeekCandidate[];
|
||
calendarDays: string[];
|
||
calendarIndex: Map<string, number>;
|
||
workingCalendar: Set<string>;
|
||
workingPrefix: number[];
|
||
workingDays: string[];
|
||
initialBalances: NormalizedBalance[];
|
||
rules: NormalizedRule[];
|
||
deadline: number;
|
||
started: number;
|
||
request: RuntimePlanningRequest;
|
||
nodes: number;
|
||
check: () => void;
|
||
reservedTypes: Set<LeaveType>;
|
||
sharedTypesByDate: Map<string, Set<LeaveType>>;
|
||
protectedTypesByDate: Map<string, Set<LeaveType>>;
|
||
preferenceDemandByDate: Map<string, number>;
|
||
anyPreferenceDemandByDate: Map<string, number>;
|
||
trace?: TraceRecorder;
|
||
traceTime: number;
|
||
traceCaptureTime: number;
|
||
traceTransferTime: number;
|
||
traceReportedTime: number;
|
||
observerTime: number;
|
||
obligationSolutionBranches: WeakMap<State, ObligationBranchReference[]>;
|
||
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<LeaveType>(['cp', 'rtt', 'other']);
|
||
const VALID_SELECTORS = new Set<LeaveSelector>(['cp', 'rtt', 'other', 'any']);
|
||
const VALID_MODES = new Set<PlacementMode>(['before', 'after', 'around', 'auto']);
|
||
const holidaysByYear = new Map<number, Set<string>>();
|
||
|
||
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<string> {
|
||
const cached = holidaysByYear.get(year);
|
||
if (cached) return new Set(cached);
|
||
const calendar = new Holidays('FR', { languages: ['en'] });
|
||
const holidays = new Set<string>();
|
||
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<string> {
|
||
const first = Number(horizon.start.slice(0, 4)) - 1;
|
||
const last = Number(horizon.end.slice(0, 4)) + 1;
|
||
const result = new Set<string>();
|
||
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<string>): boolean {
|
||
return !isWeekend(key) && !holidays.has(key);
|
||
}
|
||
|
||
interface HolidayWeekCandidate {
|
||
start: string;
|
||
dates: string[];
|
||
value: number;
|
||
}
|
||
|
||
function holidayWeekCandidates(context: SearchContext): HolidayWeekCandidate[] {
|
||
const candidates = new Map<string, HolidayWeekCandidate>();
|
||
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<PlanningTraceCandidate['technicalComparison']>;
|
||
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<PlanningTraceCandidate['technicalComparison']>;
|
||
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<string>();
|
||
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<string>();
|
||
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<string, Set<LeaveType>> {
|
||
const protectedTypes = new Map<string, Set<LeaveType>>();
|
||
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<LeaveType>();
|
||
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<LeaveType>();
|
||
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<LeaveType>();
|
||
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<string>(),
|
||
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<string>(),
|
||
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<PlanningTraceCandidate['technicalComparison']>;
|
||
firstDecisiveComparator?: string;
|
||
} {
|
||
const values: NonNullable<PlanningTraceCandidate['technicalComparison']>[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<PlanningTraceCandidate, 'technicalComparison' | 'firstDecisiveComparator'> {
|
||
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<PlanningTraceCandidate, 'technicalComparison' | 'firstDecisiveComparator'> {
|
||
if (!second) return {};
|
||
const values: NonNullable<PlanningTraceCandidate['technicalComparison']>[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<LeaveType>();
|
||
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<LeaveType>();
|
||
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<PlanningTraceCandidate['technicalComparison']>;
|
||
firstDecisiveComparator?: string;
|
||
} {
|
||
const values: NonNullable<PlanningTraceCandidate['technicalComparison']>[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<PlanningTraceCandidate['technicalComparison']>[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<string>,
|
||
): 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<string>,
|
||
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<string, number>();
|
||
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<string, unknown> | undefined;
|
||
const identities = recordValue(source.identities) as Record<string, unknown> | 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<LeaveBalanceInput> | 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<string, unknown> | 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<LeaveType>();
|
||
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<LeaveType>(),
|
||
sharedTypesByDate: new Map<string, Set<LeaveType>>(),
|
||
protectedTypesByDate: new Map<string, Set<LeaveType>>(),
|
||
preferenceDemandByDate: new Map<string, number>(),
|
||
anyPreferenceDemandByDate: new Map<string, number>(),
|
||
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<typeof normalizeRequest>,
|
||
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<LeaveType>(),
|
||
sharedTypesByDate: new Map<string, Set<LeaveType>>(),
|
||
protectedTypesByDate: new Map<string, Set<LeaveType>>(),
|
||
preferenceDemandByDate: new Map<string, number>(),
|
||
anyPreferenceDemandByDate: new Map<string, number>(),
|
||
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,
|
||
};
|
||
}
|