Finalizing a candidate never moves or retypes a placement, so preference results and calendar optimizations already have their final values before it, and leave-balance optimizations do too when nothing can be earned. Reject a candidate before finalizing when the first differing such objective of the protected prefix is worse than the baseline. Leave-placement objectives and earning contexts stop the walk. The frozen-engine check now also compares cancelled asynchronous runs, including checkpoints and retained incumbents. The loaded year drops from 48.5 s to 44.3 s; outcomes, explanations and final states are unchanged. Refs #284 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5174 lines
159 KiB
TypeScript
5174 lines
159 KiB
TypeScript
import {
|
||
DeclaredNonWorkingDay,
|
||
parseDeclaredDays,
|
||
frenchPublicHolidaysForYear,
|
||
} from './declared-non-working-days';
|
||
import {
|
||
addDays,
|
||
compareDate,
|
||
dateRange,
|
||
evaluateStateOptimization,
|
||
expiryTieKey,
|
||
isAtRisk,
|
||
optimizationBalanceObservations,
|
||
optimizationObservations,
|
||
periodData,
|
||
derivePlacements,
|
||
notePlacementAdded,
|
||
notePlacementRemoved,
|
||
placementTieKey,
|
||
utcDate,
|
||
workSequenceDates,
|
||
} from './plan-observations';
|
||
import { addPlanningYears, isPlanningDate, planningDateInParis } from './planning-date';
|
||
import {
|
||
compareObjectiveLists,
|
||
compareObjectiveValues,
|
||
evaluateOptimization,
|
||
firstDifferentObjective,
|
||
OPTIMIZATION_PRESETS,
|
||
validateOptimizationConfiguration,
|
||
type OptimizationConfiguration,
|
||
type OptimizationObservations,
|
||
} from './planning-objectives';
|
||
export type { DeclaredNonWorkingDay } from './declared-non-working-days';
|
||
|
||
/** Date-only values are serialized as YYYY-MM-DD at the planning boundary. */
|
||
export type PlanningDate = string | Date;
|
||
export type LeaveType = 'cp' | 'rtt' | 'other';
|
||
export type LeaveSelector = LeaveType | 'any';
|
||
export type RuleForce = 'obligation' | 'preference';
|
||
export type PlacementMode = 'before' | 'after' | 'around' | 'auto';
|
||
|
||
export interface PlanningHorizon {
|
||
start: PlanningDate;
|
||
end: PlanningDate;
|
||
}
|
||
|
||
export interface LeaveBalanceInput {
|
||
id: string;
|
||
type: LeaveType | string;
|
||
quantity: number;
|
||
availableFrom?: PlanningDate;
|
||
expiryDate: PlanningDate;
|
||
name?: string;
|
||
label?: string;
|
||
}
|
||
|
||
export interface TimeWindowPlanningRule {
|
||
id: string;
|
||
name?: string;
|
||
type: 'time_window' | 'TIME_WINDOW' | string;
|
||
enabled?: boolean;
|
||
force?: RuleForce | string;
|
||
startDate: PlanningDate;
|
||
endDate: PlanningDate;
|
||
placeDays: number;
|
||
placeType: LeaveSelector | string;
|
||
}
|
||
|
||
export interface IfThenPlanningRule {
|
||
id: string;
|
||
name?: string;
|
||
type: 'if_then' | 'IF_THEN' | string;
|
||
enabled?: boolean;
|
||
force?: RuleForce | string;
|
||
dayOfWeek: number;
|
||
placeDays: number;
|
||
placeType: LeaveSelector | string;
|
||
placement: PlacementMode | string;
|
||
rewardDays: number;
|
||
rewardType?: LeaveType | string;
|
||
}
|
||
|
||
export interface OptimizationPlanningRule {
|
||
id: string;
|
||
name?: string;
|
||
type: 'optimization';
|
||
enabled?: boolean;
|
||
force?: 'preference';
|
||
configuration: OptimizationConfiguration;
|
||
}
|
||
|
||
export type PlanningRule = TimeWindowPlanningRule | IfThenPlanningRule | OptimizationPlanningRule;
|
||
|
||
export interface PlanningRequest {
|
||
horizon: PlanningHorizon;
|
||
balances: readonly LeaveBalanceInput[];
|
||
rules: readonly PlanningRule[];
|
||
declaredDays?: readonly DeclaredNonWorkingDay[];
|
||
/** Optional caller-owned display identities copied into a calculation snapshot. */
|
||
identities?: unknown;
|
||
/** Optional caller-owned human-readable labels copied into a calculation snapshot. */
|
||
labels?: unknown;
|
||
/** Opt-in planning-work budget in milliseconds; omitted means run until completion or cancellation. */
|
||
deadlineMs?: number;
|
||
progress?: (stage: string) => void;
|
||
}
|
||
|
||
export interface PlanningReason {
|
||
code: string;
|
||
message: string;
|
||
ruleIds?: string[];
|
||
}
|
||
|
||
export interface InvalidInputOutcome {
|
||
kind: 'InvalidInput';
|
||
calculationDate?: string;
|
||
reasons: PlanningReason[];
|
||
}
|
||
|
||
export interface PlanningConflictOutcome {
|
||
kind: 'PlanningConflict';
|
||
calculationDate?: string;
|
||
reasons: PlanningReason[];
|
||
conflictingRuleIds: string[];
|
||
}
|
||
|
||
export type RemainingClassification = 'unavoidable-expiry' | 'at-risk' | 'deferred';
|
||
|
||
export interface RemainingBalance {
|
||
id: string;
|
||
type: LeaveType;
|
||
quantity: number;
|
||
availableFrom: string;
|
||
expiryDate: string;
|
||
classification: RemainingClassification;
|
||
reason: PlanningReason;
|
||
earned: boolean;
|
||
sourceRuleId?: string;
|
||
}
|
||
|
||
export interface LeavePlacement {
|
||
date: string;
|
||
balanceId: string;
|
||
type: LeaveType;
|
||
ruleIds: string[];
|
||
supporting: boolean;
|
||
}
|
||
|
||
export interface TimeOffPeriod {
|
||
start: string;
|
||
end: string;
|
||
duration: number;
|
||
placementDates: string[];
|
||
declaredDates?: string[];
|
||
nonWorkingDates: string[];
|
||
nonWorkingDayGain: number;
|
||
}
|
||
|
||
export interface TimeWindowContribution {
|
||
ruleId: string;
|
||
label: string;
|
||
requested: number;
|
||
declaredDays: readonly DeclaredNonWorkingDay[];
|
||
placementDates: readonly string[];
|
||
}
|
||
|
||
export interface IfThenContribution extends TimeWindowContribution {
|
||
occurrence: string;
|
||
earnedEntitlement?: Readonly<{
|
||
id: string;
|
||
type: LeaveType;
|
||
quantity: number;
|
||
availableFrom: string;
|
||
expiryDate: string;
|
||
added: boolean;
|
||
}>;
|
||
}
|
||
|
||
export interface PreferenceResult {
|
||
ruleId: string;
|
||
requested: number;
|
||
satisfied: number;
|
||
completeOccurrences: number;
|
||
reason?: PlanningReason;
|
||
}
|
||
|
||
export type PlanningObjectiveId =
|
||
| 'obligation-validity'
|
||
| 'unplaced-at-risk'
|
||
| `preference:${string}`
|
||
| `optimization:${string}`
|
||
| 'holiday-week-value'
|
||
| 'non-working-day-gain'
|
||
| 'longest-work-sequence'
|
||
| 'short-sequence-deficit'
|
||
| 'supporting-placements'
|
||
| 'balance-expiry'
|
||
| 'placement-date';
|
||
|
||
export interface PlanningObjective {
|
||
id: PlanningObjectiveId;
|
||
direction: 'minimize' | 'maximize';
|
||
value: number | string;
|
||
label?: string;
|
||
}
|
||
|
||
/** The real ordered objective values. It deliberately has no scalar score. */
|
||
export interface PlanningObjectiveVector {
|
||
objectives: readonly PlanningObjective[];
|
||
}
|
||
|
||
export interface PlanningObjectiveDelta {
|
||
id: PlanningObjectiveId;
|
||
direction: 'minimize' | 'maximize';
|
||
value: number | string | null;
|
||
label?: string;
|
||
}
|
||
|
||
export interface PlanningObjectiveComparison {
|
||
before: PlanningObjectiveVector;
|
||
after: PlanningObjectiveVector;
|
||
delta: readonly PlanningObjectiveDelta[];
|
||
firstDecisiveObjective?: PlanningObjectiveId;
|
||
tieBreak?: 'balance-expiry' | 'placement-date';
|
||
}
|
||
|
||
export interface PlacementContribution {
|
||
kind: 'entitlement' | 'rule' | 'at-risk' | 'supporting' | 'time-off-period' | 'metric';
|
||
code: string;
|
||
label: string;
|
||
ruleId?: string;
|
||
value?: number;
|
||
dates?: readonly string[];
|
||
}
|
||
|
||
export interface PlacementExplanation {
|
||
placement: LeavePlacement;
|
||
balance?: Readonly<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';
|
||
calculationDate?: string;
|
||
declaredDays?: readonly DeclaredNonWorkingDay[];
|
||
timeOffPeriods?: readonly TimeOffPeriod[];
|
||
timeWindowContributions?: readonly TimeWindowContribution[];
|
||
ifThenContributions?: readonly IfThenContribution[];
|
||
placements: readonly PlacementExplanation[];
|
||
/** Present when the explained plan is the incumbent of an interrupted calculation. */
|
||
incomplete?: IncompleteExplanation;
|
||
}
|
||
|
||
export interface ConflictExplanation {
|
||
kind: 'ConflictExplanation';
|
||
calculationDate?: string;
|
||
conflictingRules: readonly Readonly<{ id: string; label: string }>[];
|
||
bottleneck: PlanningReason;
|
||
}
|
||
|
||
export interface IncompleteExplanation {
|
||
kind: 'IncompleteExplanation';
|
||
calculationDate?: string;
|
||
reason: PlanningReason;
|
||
completedStages: readonly string[];
|
||
uncompletedStages: readonly string[];
|
||
}
|
||
|
||
export type PlanningExplanation = PlanExplanation | ConflictExplanation | IncompleteExplanation;
|
||
|
||
export interface PlanningSnapshot {
|
||
calculationDate?: string;
|
||
declaredDays?: readonly DeclaredNonWorkingDay[];
|
||
horizon: Readonly<{ start: string; end: string }>;
|
||
balances: readonly Readonly<LeaveBalanceInput>[];
|
||
rules: readonly Readonly<PlanningRule>[];
|
||
identities: Readonly<Record<string, string>>;
|
||
labels: Readonly<Record<string, string>>;
|
||
}
|
||
|
||
export interface PlanningStateSnapshot {
|
||
calculationDate?: string;
|
||
declaredDays?: readonly DeclaredNonWorkingDay[];
|
||
horizon: Readonly<{ start: string; end: string }>;
|
||
balances: readonly Readonly<{
|
||
id: string;
|
||
type: LeaveType;
|
||
quantity: number;
|
||
remaining: number;
|
||
availableFrom: string;
|
||
expiryDate: string;
|
||
earned: boolean;
|
||
sourceRuleId?: string;
|
||
}>[];
|
||
placements: readonly LeavePlacement[];
|
||
earnedActivations: readonly Readonly<{
|
||
id: string;
|
||
ruleId: string;
|
||
occurrence: string;
|
||
dates: readonly string[];
|
||
}>[];
|
||
}
|
||
|
||
export interface PlanningArtifactTimings {
|
||
planningWorkMs: number;
|
||
}
|
||
|
||
export type CounterfactualReasonCode =
|
||
| 'non-working'
|
||
| 'declared-day'
|
||
| 'occupied'
|
||
| 'unavailable'
|
||
| 'expired'
|
||
| 'type-incompatible'
|
||
| 'rule-invalid';
|
||
|
||
export interface CounterfactualDateResult {
|
||
date: string;
|
||
status: 'eligible' | 'ineligible';
|
||
reason?: PlanningReason & { code: CounterfactualReasonCode };
|
||
comparison?: PlanningObjectiveComparison;
|
||
}
|
||
|
||
export interface CounterfactualAnalysis {
|
||
kind: 'Counterfactual';
|
||
calculationId: string;
|
||
placement: LeavePlacement;
|
||
hypothetical: true;
|
||
baseline: PlanningObjectiveVector;
|
||
results: readonly CounterfactualDateResult[];
|
||
}
|
||
|
||
export interface StaleCounterfactualAnalysis {
|
||
kind: 'CounterfactualStale';
|
||
calculationId: string;
|
||
placement: LeavePlacement;
|
||
hypothetical: true;
|
||
results: readonly [];
|
||
}
|
||
|
||
export type CounterfactualSelection = LeavePlacement | string;
|
||
|
||
export interface PlanningMetrics {
|
||
unplacedAtRisk: number;
|
||
holidayWeekValue: number;
|
||
nonWorkingDayGain: number;
|
||
longestWorkSequence: number;
|
||
averageWorkSequence: number | null;
|
||
averageTimeOffPeriod: number | null;
|
||
longestTimeOffPeriod: number | null;
|
||
shortSequenceDeficit: number;
|
||
supportingPlacements: number;
|
||
preferenceSatisfaction: number[];
|
||
}
|
||
|
||
export interface PlanOutcome {
|
||
kind: 'Plan';
|
||
calculationDate?: string;
|
||
declaredDays?: readonly DeclaredNonWorkingDay[];
|
||
timeWindowContributions?: readonly TimeWindowContribution[];
|
||
ifThenContributions?: readonly IfThenContribution[];
|
||
horizon: { start: string; end: string };
|
||
placements: LeavePlacement[];
|
||
timeOffPeriods: TimeOffPeriod[];
|
||
preferences: PreferenceResult[];
|
||
remainingBalances: RemainingBalance[];
|
||
metrics: PlanningMetrics;
|
||
completedStages: string[];
|
||
lastCompletedStage: string;
|
||
}
|
||
|
||
export interface PlanningIncompleteOutcome {
|
||
kind: 'PlanningIncomplete';
|
||
calculationDate?: string;
|
||
incumbent?: PlanOutcome;
|
||
completedStages: string[];
|
||
lastCompletedStage?: string;
|
||
reasons: PlanningReason[];
|
||
}
|
||
|
||
export type PlanningOutcome =
|
||
| InvalidInputOutcome
|
||
| PlanningConflictOutcome
|
||
| PlanningIncompleteOutcome
|
||
| PlanOutcome;
|
||
|
||
interface NormalizedBalance {
|
||
id: string;
|
||
type: LeaveType;
|
||
quantity: number;
|
||
availableFrom: string;
|
||
expiryDate: string;
|
||
earned: boolean;
|
||
sourceRuleId?: string;
|
||
}
|
||
|
||
interface NormalizedBaseRule {
|
||
id: string;
|
||
name: string;
|
||
enabled: boolean;
|
||
force: RuleForce;
|
||
}
|
||
|
||
interface NormalizedCalendarRule extends NormalizedBaseRule {
|
||
type: 'time_window' | 'if_then';
|
||
placeDays: number;
|
||
placeType: LeaveSelector;
|
||
startDate?: string;
|
||
endDate?: string;
|
||
dayOfWeek?: number;
|
||
placement?: PlacementMode;
|
||
rewardDays?: number;
|
||
rewardType?: LeaveType;
|
||
}
|
||
|
||
interface NormalizedOptimizationRule extends NormalizedBaseRule {
|
||
type: 'optimization';
|
||
force: 'preference';
|
||
configuration: OptimizationConfiguration;
|
||
}
|
||
|
||
type NormalizedRule = NormalizedCalendarRule | NormalizedOptimizationRule;
|
||
|
||
interface InternalBalance extends NormalizedBalance {
|
||
remaining: number;
|
||
}
|
||
|
||
interface InternalPlacement {
|
||
date: string;
|
||
balanceId: string;
|
||
type: LeaveType;
|
||
ruleIds: Set<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 {
|
||
calculationDate: string;
|
||
horizon?: unknown;
|
||
balances?: unknown;
|
||
declaredDays?: unknown;
|
||
rules?: unknown;
|
||
identities?: unknown;
|
||
labels?: unknown;
|
||
deadlineMs?: unknown;
|
||
progress?: unknown;
|
||
}
|
||
|
||
interface RuntimeRecord {
|
||
calculationDate?: unknown;
|
||
horizon?: unknown;
|
||
start?: unknown;
|
||
end?: unknown;
|
||
balances?: unknown;
|
||
declaredDays?: unknown;
|
||
rules?: unknown;
|
||
identities?: unknown;
|
||
labels?: unknown;
|
||
deadlineMs?: unknown;
|
||
progress?: unknown;
|
||
id?: unknown;
|
||
name?: unknown;
|
||
type?: unknown;
|
||
quantity?: unknown;
|
||
label?: unknown;
|
||
availableFrom?: unknown;
|
||
expiryDate?: unknown;
|
||
enabled?: unknown;
|
||
force?: unknown;
|
||
startDate?: unknown;
|
||
endDate?: unknown;
|
||
placeDays?: unknown;
|
||
placeType?: unknown;
|
||
dayOfWeek?: unknown;
|
||
placement?: unknown;
|
||
rewardDays?: unknown;
|
||
rewardType?: unknown;
|
||
configuration?: unknown;
|
||
}
|
||
|
||
interface Requirement {
|
||
rule: NormalizedCalendarRule;
|
||
requiredPlacements: number;
|
||
anchor: string;
|
||
index: number;
|
||
occurrenceDate?: string;
|
||
optional?: boolean;
|
||
patterns?: string[][];
|
||
candidates?: string[];
|
||
}
|
||
|
||
const obligationRequirementCache = new WeakMap<SearchContext, Requirement[]>();
|
||
const occurrenceCache = new WeakMap<SearchContext, Map<string, string[]>>();
|
||
const calendarPatternCache = new WeakMap<SearchContext, Map<string, string[][]>>();
|
||
const preferencePatternCache = new WeakMap<SearchContext, Map<string, string[][][]>>();
|
||
const declaredWindowCache = new WeakMap<SearchContext, Map<string, DeclaredNonWorkingDay[]>>();
|
||
|
||
interface SearchContext {
|
||
declaredDays: readonly DeclaredNonWorkingDay[];
|
||
declaredDates: ReadonlySet<string>;
|
||
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>;
|
||
observerTime: number;
|
||
completedPreferenceStates: Map<number, string>;
|
||
completedGenericState?: string;
|
||
/** Supplied metrics were evaluated after reconciling earned entitlement dates. */
|
||
retain?: (
|
||
state: State,
|
||
finalizedMetrics?: PlanningMetrics,
|
||
vector?: PlanningObjectiveVector,
|
||
) => void;
|
||
}
|
||
|
||
type PlanningCalendar = Pick<
|
||
SearchContext,
|
||
| 'holidays'
|
||
| 'workingDays'
|
||
| 'calendarDays'
|
||
| 'calendarIndex'
|
||
| 'workingCalendar'
|
||
| 'workingPrefix'
|
||
>;
|
||
|
||
interface ObligationPreview {
|
||
obligationState: State;
|
||
state: State;
|
||
preferences: PreferenceResult[];
|
||
metrics: PlanningMetrics;
|
||
}
|
||
|
||
function now(): number {
|
||
return typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();
|
||
}
|
||
|
||
function observeCallback(context: SearchContext, callback: () => void): void {
|
||
const started = now();
|
||
try {
|
||
callback();
|
||
} finally {
|
||
context.observerTime += now() - started;
|
||
}
|
||
}
|
||
|
||
class DeadlineReached extends Error {}
|
||
class UserCancelled extends Error {}
|
||
|
||
// A true value asks the asynchronous driver to pause immediately at a phase boundary.
|
||
type PlanningSteps<T> = Generator<boolean | undefined, T, void>;
|
||
|
||
// ponytail: cap the number of feasible obligation branches previewed for a
|
||
// loaded year; replace with bounded branch-and-bound if mobile evidence shows
|
||
// that this ceiling misses a better lexicographic assignment.
|
||
const OBLIGATION_SOLUTION_LIMIT = 4096;
|
||
const EXACT_OBLIGATION_BRANCH_LIMIT = 10_000;
|
||
const WINDOW_OBLIGATION_PREVIEW_LIMIT = 8;
|
||
const VALID_TYPES = new Set<LeaveType>(['cp', 'rtt', 'other']);
|
||
const VALID_SELECTORS = new Set<LeaveSelector>(['cp', 'rtt', 'other', 'any']);
|
||
const VALID_MODES = new Set<PlacementMode>(['before', 'after', 'around', 'auto']);
|
||
|
||
function dateKey(value: unknown): string | null {
|
||
if (typeof value === 'string') {
|
||
// date-holidays returns date-only strings with a time suffix. Treat the
|
||
// leading calendar date as data, rather than letting the host timezone
|
||
// shift it across a midnight boundary.
|
||
const dateOnly = value.match(/^(\d{4}-\d{2}-\d{2})(?:$|\s|T)/)?.[1];
|
||
if (dateOnly) return isPlanningDate(dateOnly) ? dateOnly : null;
|
||
const parsed = new Date(value);
|
||
return Number.isNaN(parsed.getTime()) ? null : planningDateInParis(parsed);
|
||
}
|
||
if (value instanceof Date && !Number.isNaN(value.getTime())) return planningDateInParis(value);
|
||
return null;
|
||
}
|
||
|
||
function runtimeRequest(value: unknown, calculationDate?: string): RuntimePlanningRequest {
|
||
const source = recordValue(value) ?? {};
|
||
return {
|
||
calculationDate:
|
||
calculationDate ??
|
||
(isPlanningDate(source.calculationDate)
|
||
? source.calculationDate
|
||
: planningDateInParis(new Date())),
|
||
horizon: source.horizon,
|
||
balances: source.balances,
|
||
declaredDays: source.declaredDays,
|
||
rules: source.rules,
|
||
identities: source.identities,
|
||
labels: source.labels,
|
||
deadlineMs: source.deadlineMs,
|
||
progress: source.progress,
|
||
};
|
||
}
|
||
|
||
function recordValue(value: unknown): RuntimeRecord | undefined {
|
||
return typeof value === 'object' && value !== null ? (value as RuntimeRecord) : undefined;
|
||
}
|
||
|
||
function compareText(a: string, b: string): number {
|
||
return a < b ? -1 : a > b ? 1 : 0;
|
||
}
|
||
|
||
function defaultHorizon(start: string): { start: string; end: string } {
|
||
return { start, end: addPlanningYears(start, 1) };
|
||
}
|
||
|
||
function holidaySet(horizon: { start: string; end: string }): Set<string> {
|
||
const first = Number(addDays(horizon.start, -400).slice(0, 4));
|
||
const last = Number(addDays(horizon.end, 400).slice(0, 4));
|
||
const result = new Set<string>();
|
||
for (let year = first; year <= last; year++) {
|
||
for (const holiday of frenchPublicHolidaysForYear(year)) result.add(holiday);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function isWeekend(key: string): boolean {
|
||
const day = utcDate(key).getUTCDay();
|
||
return day === 0 || day === 6;
|
||
}
|
||
|
||
function isWorking(key: string, holidays: Set<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>();
|
||
const inHorizon = (date: string) => date >= context.horizon.start && date < context.horizon.end;
|
||
for (const holiday of [...context.holidays].sort(compareDate)) {
|
||
const weekday = utcDate(holiday).getUTCDay();
|
||
if (weekday < 1 || weekday > 5) continue;
|
||
const start = addDays(holiday, 1 - weekday);
|
||
const weekdays = dateRange(start, addDays(start, 5));
|
||
const dates = weekdays.filter((date) => isWorking(date, context.holidays));
|
||
if (
|
||
!dates.length ||
|
||
!dates.some(inHorizon) ||
|
||
(!inHorizon(holiday) && !dates.some((date) => context.declaredDates.has(date))) ||
|
||
dates.some((date) => !inHorizon(date) && !context.declaredDates.has(date))
|
||
)
|
||
continue;
|
||
const value = weekdays.reduce(
|
||
(sum, date, index) =>
|
||
sum + (context.holidays.has(date) ? Math.min(index + 1, 5 - index) : 0),
|
||
0,
|
||
);
|
||
candidates.set(start, { start, dates, value });
|
||
}
|
||
return [...candidates.values()].sort(
|
||
(a, b) => b.value - a.value || compareDate(a.start, b.start),
|
||
);
|
||
}
|
||
|
||
function reason(code: string, message: string, ruleIds?: string[]): PlanningReason {
|
||
return ruleIds?.length ? { code, message, ruleIds } : { code, message };
|
||
}
|
||
|
||
function cloneState(state: State, placements?: State['placements']): State {
|
||
const clonedPlacements =
|
||
placements ??
|
||
new Map(
|
||
[...state.placements.entries()].map(([date, placement]) => [
|
||
date,
|
||
{
|
||
...placement,
|
||
ruleIds: new Set(placement.ruleIds),
|
||
},
|
||
]),
|
||
);
|
||
derivePlacements(state.placements, clonedPlacements);
|
||
return {
|
||
balances: state.balances.map((balance) => ({ ...balance })),
|
||
placements: clonedPlacements,
|
||
earnedActivations: state.earnedActivations.map((activation) => ({
|
||
...activation,
|
||
dates: [...activation.dates],
|
||
})),
|
||
};
|
||
}
|
||
|
||
function planningStateSnapshot(state: State, context: SearchContext): PlanningStateSnapshot {
|
||
return {
|
||
calculationDate: context.request.calculationDate,
|
||
horizon: context.horizon,
|
||
...(context.declaredDays.length ? { declaredDays: context.declaredDays } : {}),
|
||
balances: state.balances.map((balance) => ({ ...balance })),
|
||
placements: [...state.placements.values()]
|
||
.sort((a, b) => compareDate(a.date, b.date) || compareText(a.balanceId, b.balanceId))
|
||
.map((placement) => ({
|
||
...placement,
|
||
ruleIds: [...placement.ruleIds].sort(),
|
||
})),
|
||
earnedActivations: state.earnedActivations.map((activation) => ({
|
||
...activation,
|
||
dates: [...activation.dates],
|
||
})),
|
||
};
|
||
}
|
||
|
||
function normalizeType(value: unknown): LeaveType | null {
|
||
const normalized = String(value ?? '').toLowerCase();
|
||
return VALID_TYPES.has(normalized as LeaveType) ? (normalized as LeaveType) : null;
|
||
}
|
||
|
||
function normalizeSelector(value: unknown): LeaveSelector | null {
|
||
const normalized = String(value ?? '').toLowerCase();
|
||
return VALID_SELECTORS.has(normalized as LeaveSelector) ? (normalized as LeaveSelector) : null;
|
||
}
|
||
|
||
function normalizeForce(value: unknown): RuleForce | null {
|
||
if (value === undefined || value === '') return 'preference';
|
||
const normalized = String(value).toLowerCase();
|
||
if (normalized === 'obligation' || normalized === 'preference') return normalized;
|
||
return null;
|
||
}
|
||
|
||
function normalizeRuleType(value: unknown): NormalizedRule['type'] | null {
|
||
const normalized = String(value ?? '').toLowerCase();
|
||
return normalized === 'time_window' || normalized === 'if_then' || normalized === 'optimization'
|
||
? normalized
|
||
: null;
|
||
}
|
||
|
||
function normalizeMode(value: unknown): PlacementMode | null {
|
||
const normalized = String(value ?? '').toLowerCase();
|
||
return VALID_MODES.has(normalized as PlacementMode) ? (normalized as PlacementMode) : null;
|
||
}
|
||
|
||
function compareDateLists(a: readonly string[], b: readonly string[]): number {
|
||
for (let index = 0; index < Math.min(a.length, b.length); index++) {
|
||
const compared = compareDate(a[index], b[index]);
|
||
if (compared) return compared;
|
||
}
|
||
return a.length - b.length;
|
||
}
|
||
|
||
function asInteger(value: unknown): number | null {
|
||
return typeof value === 'number' && Number.isInteger(value) ? value : null;
|
||
}
|
||
|
||
function normalizeBalances(
|
||
request: RuntimePlanningRequest,
|
||
horizon: { start: string; end: string },
|
||
): {
|
||
balances: NormalizedBalance[];
|
||
reasons: PlanningReason[];
|
||
} {
|
||
const rawInput = request.balances;
|
||
const reasons: PlanningReason[] = [];
|
||
if (!Array.isArray(rawInput)) {
|
||
return {
|
||
balances: [],
|
||
reasons: [reason('balances', 'Leave balances must be an array.')],
|
||
};
|
||
}
|
||
const raw = rawInput;
|
||
const ids = new Set<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;
|
||
}
|
||
if (type === 'optimization') {
|
||
const validated = validateOptimizationConfiguration(value?.configuration);
|
||
if (forceValue !== 'preference')
|
||
reasons.push(
|
||
reason('rule-force', 'Optimization rules support preference mode only.', [id]),
|
||
);
|
||
if (!validated.valid) {
|
||
reasons.push(reason(validated.code, validated.message, [id]));
|
||
continue;
|
||
}
|
||
rules.push({
|
||
id,
|
||
name: typeof value?.name === 'string' ? value.name : id,
|
||
type,
|
||
enabled: true,
|
||
force: 'preference',
|
||
configuration: structuredClone(validated.configuration),
|
||
});
|
||
continue;
|
||
}
|
||
const placeDays = asInteger(value?.placeDays);
|
||
const placeType = normalizeSelector(value?.placeType);
|
||
if (placeDays === null || placeDays < 0) {
|
||
reasons.push(
|
||
reason(
|
||
'rule-quantity',
|
||
`Planning rule "${id}" needs a non-negative integer quantity.`,
|
||
),
|
||
);
|
||
continue;
|
||
}
|
||
if (!placeType)
|
||
reasons.push(
|
||
reason('rule-selector', `Planning rule "${id}" has an invalid leave selector.`),
|
||
);
|
||
const base = {
|
||
id,
|
||
name: typeof value?.name === 'string' ? value.name : id,
|
||
type,
|
||
enabled: value?.enabled !== false,
|
||
force,
|
||
placeDays,
|
||
placeType: placeType ?? 'any',
|
||
};
|
||
if (type === 'time_window') {
|
||
const startDate = dateKey(value?.startDate);
|
||
const endDate = dateKey(value?.endDate);
|
||
if (!startDate || !endDate || compareDate(startDate, endDate) > 0) {
|
||
reasons.push(
|
||
reason(
|
||
'rule-window',
|
||
`TIME_WINDOW rule "${id}" needs an inclusive, ordered date window.`,
|
||
),
|
||
);
|
||
continue;
|
||
}
|
||
rules.push({ ...base, startDate, endDate, type: 'time_window' });
|
||
continue;
|
||
}
|
||
const dayOfWeek = asInteger(value?.dayOfWeek);
|
||
const mode = normalizeMode(value?.placement);
|
||
const rewardDays = asInteger(value?.rewardDays);
|
||
const rawRewardType = value?.rewardType;
|
||
const rewardType = normalizeType(rawRewardType);
|
||
if (dayOfWeek === null || dayOfWeek < 0 || dayOfWeek > 6) {
|
||
reasons.push(
|
||
reason('rule-weekday', `IF_THEN rule "${id}" needs a weekday from 0 to 6.`),
|
||
);
|
||
}
|
||
if (!mode)
|
||
reasons.push(
|
||
reason('rule-mode', `IF_THEN rule "${id}" has an unsupported placement mode.`),
|
||
);
|
||
if (rewardDays === null || rewardDays < 0) {
|
||
reasons.push(
|
||
reason(
|
||
'earned-quantity',
|
||
`IF_THEN rule "${id}" needs a non-negative earned quantity.`,
|
||
),
|
||
);
|
||
}
|
||
if ((rewardDays ?? 0) > 0 && !rewardType) {
|
||
reasons.push(
|
||
reason(
|
||
'earned-type',
|
||
`IF_THEN rule "${id}" needs CP, RTT, or Other as its earned type.`,
|
||
),
|
||
);
|
||
} else if (rawRewardType !== undefined && !rewardType) {
|
||
reasons.push(
|
||
reason(
|
||
'earned-type',
|
||
`IF_THEN rule "${id}" needs CP, RTT, or Other as its earned type.`,
|
||
),
|
||
);
|
||
}
|
||
if (
|
||
placeType &&
|
||
mode &&
|
||
dayOfWeek !== null &&
|
||
rewardDays !== null &&
|
||
(rewardDays === 0 ? rawRewardType === undefined || rewardType : rewardType)
|
||
) {
|
||
rules.push({
|
||
...base,
|
||
type: 'if_then',
|
||
dayOfWeek,
|
||
placement: mode,
|
||
rewardDays,
|
||
rewardType: rewardType ?? undefined,
|
||
});
|
||
}
|
||
}
|
||
return { rules, reasons };
|
||
}
|
||
|
||
function normalizeRequest(request: unknown): {
|
||
declaredDays: DeclaredNonWorkingDay[];
|
||
horizon?: { start: string; end: string };
|
||
balances: NormalizedBalance[];
|
||
rules: NormalizedRule[];
|
||
reasons: PlanningReason[];
|
||
} {
|
||
const source = runtimeRequest(request);
|
||
const rawHorizon = recordValue(source.horizon);
|
||
const fallbackHorizon = defaultHorizon(source.calculationDate);
|
||
const start = dateKey(rawHorizon?.start) ?? fallbackHorizon.start;
|
||
const end = dateKey(rawHorizon?.end) ?? fallbackHorizon.end;
|
||
const reasons: PlanningReason[] = [];
|
||
if (!rawHorizon) {
|
||
reasons.push(reason('horizon', 'The planning horizon is required.'));
|
||
} else if (!dateKey(rawHorizon.start)) {
|
||
reasons.push(
|
||
reason('horizon-start', 'The planning horizon start must be a valid calendar date.'),
|
||
);
|
||
}
|
||
if (rawHorizon && !dateKey(rawHorizon.end)) {
|
||
reasons.push(
|
||
reason('horizon-end', 'The planning horizon end must be a valid calendar date.'),
|
||
);
|
||
}
|
||
if (rawHorizon && compareDate(start, end) >= 0) {
|
||
reasons.push(reason('horizon-order', 'The planning horizon must have start before end.'));
|
||
}
|
||
const configuredDeadline = source.deadlineMs;
|
||
if (
|
||
configuredDeadline !== undefined &&
|
||
(typeof configuredDeadline !== 'number' ||
|
||
!Number.isFinite(configuredDeadline) ||
|
||
configuredDeadline < 0)
|
||
) {
|
||
reasons.push(
|
||
reason('deadline', 'The planning deadline must be a finite non-negative number.'),
|
||
);
|
||
}
|
||
let declaredDays: DeclaredNonWorkingDay[] = [];
|
||
try {
|
||
declaredDays = parseDeclaredDays(
|
||
source.declaredDays === undefined ? [] : source.declaredDays,
|
||
);
|
||
} catch (error) {
|
||
reasons.push(
|
||
reason('declared-days', error instanceof Error ? error.message : String(error)),
|
||
);
|
||
}
|
||
const balanceResult = normalizeBalances(source, { start, end });
|
||
const ruleResult = normalizeRules(source);
|
||
reasons.push(...balanceResult.reasons, ...ruleResult.reasons);
|
||
return {
|
||
horizon: reasons.some((item) => item.code === 'horizon' || item.code.startsWith('horizon-'))
|
||
? undefined
|
||
: { start, end },
|
||
balances: balanceResult.balances,
|
||
declaredDays,
|
||
rules: ruleResult.rules,
|
||
reasons,
|
||
};
|
||
}
|
||
|
||
export function invalidInputFor(request: unknown): InvalidInputOutcome | undefined {
|
||
const normalized = normalizeRequest(request);
|
||
if (!normalized.reasons.length) return undefined;
|
||
return {
|
||
kind: 'InvalidInput',
|
||
reasons: normalized.reasons,
|
||
};
|
||
}
|
||
|
||
function futurePreferenceProtection(
|
||
rules: NormalizedRule[],
|
||
startIndex: number,
|
||
context: SearchContext,
|
||
): Map<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' || rule.type === 'optimization') continue;
|
||
if (
|
||
rule.type === 'time_window' &&
|
||
declaredWindowDays(rule, context).length >= rule.placeDays
|
||
)
|
||
continue;
|
||
const priorityWeight = futureRules.length - offset;
|
||
const dates =
|
||
rule.type === 'time_window'
|
||
? context.workingDays.filter(
|
||
(date) =>
|
||
!context.declaredDates.has(date) &&
|
||
compareDate(date, rule.startDate!) >= 0 &&
|
||
compareDate(date, rule.endDate!) <= 0,
|
||
)
|
||
: occurrences(rule, context).flatMap((occurrence) => {
|
||
const patterns = patternsFor(rule, occurrence, context);
|
||
return patterns.some((pattern) =>
|
||
pattern.every((date) =>
|
||
declaredPatternDay(date, rule.placeType, context),
|
||
),
|
||
)
|
||
? []
|
||
: patterns.flat().filter((date) => !context.declaredDates.has(date));
|
||
});
|
||
for (const date of dates) {
|
||
context.preferenceDemandByDate.set(
|
||
date,
|
||
(context.preferenceDemandByDate.get(date) ?? 0) + priorityWeight,
|
||
);
|
||
if (rule.placeType === 'any') {
|
||
context.anyPreferenceDemandByDate.set(
|
||
date,
|
||
(context.anyPreferenceDemandByDate.get(date) ?? 0) + priorityWeight,
|
||
);
|
||
continue;
|
||
}
|
||
const types = protectedTypes.get(date) ?? new Set<LeaveType>();
|
||
types.add(rule.placeType);
|
||
protectedTypes.set(date, types);
|
||
}
|
||
}
|
||
return protectedTypes;
|
||
}
|
||
|
||
function occurrences(rule: NormalizedRule, context: SearchContext): string[] {
|
||
if (rule.type !== 'if_then') return [];
|
||
let cached = occurrenceCache.get(context);
|
||
if (!cached) {
|
||
cached = new Map();
|
||
occurrenceCache.set(context, cached);
|
||
}
|
||
let dates = cached.get(rule.id);
|
||
if (!dates) {
|
||
dates = [...context.holidays]
|
||
.filter(
|
||
(date) =>
|
||
date >= context.horizon.start &&
|
||
date < context.horizon.end &&
|
||
utcDate(date).getUTCDay() === rule.dayOfWeek,
|
||
)
|
||
.sort(compareDate);
|
||
cached.set(rule.id, dates);
|
||
}
|
||
return dates;
|
||
}
|
||
|
||
function workingNeighbors(
|
||
date: string,
|
||
direction: 1 | -1,
|
||
count: number,
|
||
context: SearchContext,
|
||
): string[] {
|
||
const result: string[] = [];
|
||
for (
|
||
let current = addDays(date, direction);
|
||
result.length < count;
|
||
current = addDays(current, direction)
|
||
) {
|
||
context.check?.();
|
||
if (isWorking(current, context.holidays)) result.push(current);
|
||
// A one-year horizon makes an unbounded malformed search unnecessary.
|
||
if (
|
||
compareDate(current, addDays(context.horizon.start, -400)) < 0 ||
|
||
compareDate(current, addDays(context.horizon.end, 400)) > 0
|
||
)
|
||
break;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function patternsFor(
|
||
rule: NormalizedCalendarRule,
|
||
holiday: string,
|
||
context: SearchContext,
|
||
): string[][] {
|
||
let cached = calendarPatternCache.get(context);
|
||
if (!cached) {
|
||
cached = new Map();
|
||
calendarPatternCache.set(context, cached);
|
||
}
|
||
const key = `${rule.id}:${holiday}`;
|
||
const existing = cached.get(key);
|
||
if (existing) return existing;
|
||
const count = rule.placeDays;
|
||
const make = (before: number, after: number) =>
|
||
[
|
||
...workingNeighbors(holiday, -1, before, context),
|
||
...workingNeighbors(holiday, 1, after, context).reverse(),
|
||
].sort(compareDate);
|
||
const patterns: string[][] = [];
|
||
if (rule.placement === 'before') patterns.push(make(count, 0));
|
||
else if (rule.placement === 'after') patterns.push(make(0, count));
|
||
else if (rule.placement === 'around') {
|
||
for (let before = 1; before < count; before++) patterns.push(make(before, count - before));
|
||
} else {
|
||
for (let before = 0; before <= count; before++) patterns.push(make(before, count - before));
|
||
}
|
||
const eligible = patterns
|
||
.filter(
|
||
(pattern) =>
|
||
pattern.length === count &&
|
||
pattern.every(
|
||
(date) =>
|
||
compareDate(date, context.horizon.start) >= 0 &&
|
||
compareDate(date, context.horizon.end) < 0,
|
||
),
|
||
)
|
||
.sort(compareDateLists);
|
||
cached.set(key, eligible);
|
||
return eligible;
|
||
}
|
||
|
||
function buildRequirements(
|
||
rules: NormalizedRule[],
|
||
context: SearchContext,
|
||
force: RuleForce,
|
||
): Requirement[] {
|
||
if (force === 'obligation' && rules === context.rules) {
|
||
const cached = obligationRequirementCache.get(context);
|
||
if (cached) return cached;
|
||
}
|
||
const requirements: Requirement[] = [];
|
||
let index = 0;
|
||
for (const rule of rules) {
|
||
if (!rule.enabled || rule.force !== force || rule.type === 'optimization') continue;
|
||
if (rule.type === 'time_window') {
|
||
const start =
|
||
compareDate(rule.startDate!, context.horizon.start) < 0
|
||
? context.horizon.start
|
||
: rule.startDate!;
|
||
const end =
|
||
compareDate(rule.endDate!, context.horizon.end) >= 0
|
||
? addDays(context.horizon.end, -1)
|
||
: rule.endDate!;
|
||
const candidates =
|
||
compareDate(start, end) <= 0
|
||
? context.workingDays.filter(
|
||
(date) =>
|
||
!context.declaredDates.has(date) &&
|
||
compareDate(date, start) >= 0 &&
|
||
compareDate(date, end) <= 0,
|
||
)
|
||
: [];
|
||
const orderedCandidates = candidates.sort(
|
||
(a, b) =>
|
||
preferenceDateConflictScore(rule.placeType, a, context) -
|
||
preferenceDateConflictScore(rule.placeType, b, context) ||
|
||
compareDate(a, b),
|
||
);
|
||
requirements.push({
|
||
rule,
|
||
requiredPlacements: rule.placeDays - declaredWindowDays(rule, context).length,
|
||
anchor: start,
|
||
index: index++,
|
||
candidates: orderedCandidates,
|
||
});
|
||
} else {
|
||
for (const occurrence of occurrences(rule, context)) {
|
||
const patterns = patternsFor(rule, occurrence, context).sort(
|
||
(a, b) =>
|
||
patternConflictScore(rule.placeType, a, context) -
|
||
patternConflictScore(rule.placeType, b, context) ||
|
||
compareDateLists(a, b),
|
||
);
|
||
requirements.push({
|
||
rule,
|
||
requiredPlacements: Math.min(
|
||
rule.placeDays,
|
||
...patterns.map(
|
||
(pattern) =>
|
||
pattern.filter(
|
||
(date) => !declaredPatternDay(date, rule.placeType, context),
|
||
).length,
|
||
),
|
||
),
|
||
anchor: occurrence,
|
||
occurrenceDate: occurrence,
|
||
index: index++,
|
||
patterns,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
// IF_THEN occurrences are entitlement producers. Resolve them before a
|
||
// TIME_WINDOW consumer even when the window starts earlier; availability
|
||
// checks still prevent an earned balance from funding an earlier date, while
|
||
// this ordering keeps valid later chains visible to the joint search.
|
||
const ordered = requirements.sort(compareRequirements);
|
||
if (force === 'obligation' && rules === context.rules)
|
||
obligationRequirementCache.set(context, ordered);
|
||
return ordered;
|
||
}
|
||
|
||
function compareRequirements(a: Requirement, b: Requirement): number {
|
||
return (
|
||
Number(a.rule.type !== 'if_then') - Number(b.rule.type !== 'if_then') ||
|
||
compareDate(a.anchor, b.anchor) ||
|
||
a.index - b.index
|
||
);
|
||
}
|
||
|
||
function obligationBranchEstimate(requirements: Requirement[]): number {
|
||
let estimate = 1;
|
||
for (const requirement of requirements) {
|
||
let branches = requirement.patterns?.length ?? 1;
|
||
if (!requirement.patterns) {
|
||
const candidates = requirement.candidates?.length ?? 0;
|
||
const needed = requirement.requiredPlacements;
|
||
branches = 1;
|
||
for (let index = 1; index <= needed; index++) {
|
||
if (index > candidates) {
|
||
branches = 1;
|
||
break;
|
||
}
|
||
branches = Math.min(
|
||
EXACT_OBLIGATION_BRANCH_LIMIT + 1,
|
||
(branches * (candidates - needed + index)) / index,
|
||
);
|
||
}
|
||
}
|
||
estimate = Math.min(EXACT_OBLIGATION_BRANCH_LIMIT + 1, estimate * branches);
|
||
if (estimate > EXACT_OBLIGATION_BRANCH_LIMIT) return estimate;
|
||
}
|
||
return estimate;
|
||
}
|
||
|
||
function selectorMatches(selector: LeaveSelector, type: LeaveType): boolean {
|
||
return selector === 'any' || selector === type;
|
||
}
|
||
|
||
function declaredWindowDays(rule: NormalizedRule, context: SearchContext) {
|
||
if (rule.type !== 'time_window' || !context.declaredDays.length) return [];
|
||
let byRule = declaredWindowCache.get(context);
|
||
if (!byRule) {
|
||
byRule = new Map();
|
||
declaredWindowCache.set(context, byRule);
|
||
}
|
||
let days = byRule.get(rule.id);
|
||
if (!days) {
|
||
days = context.declaredDays
|
||
.filter(
|
||
(day) =>
|
||
selectorMatches(rule.placeType, day.type) &&
|
||
day.date >= context.horizon.start &&
|
||
day.date < context.horizon.end &&
|
||
day.date >= rule.startDate! &&
|
||
day.date <= rule.endDate!,
|
||
)
|
||
.sort((a, b) => compareDate(a.date, b.date))
|
||
.slice(0, rule.placeDays);
|
||
byRule.set(rule.id, days);
|
||
}
|
||
return days;
|
||
}
|
||
|
||
function declaredPatternDay(date: string, selector: LeaveSelector, context: SearchContext) {
|
||
if (!context.declaredDates.has(date)) return undefined;
|
||
return context.declaredDays.find(
|
||
(day) => day.date === date && selectorMatches(selector, day.type),
|
||
);
|
||
}
|
||
|
||
function patternDateSatisfied(
|
||
date: string,
|
||
rule: NormalizedCalendarRule,
|
||
state: State,
|
||
context: SearchContext,
|
||
): boolean {
|
||
return Boolean(
|
||
declaredPatternDay(date, rule.placeType, context) ||
|
||
(state.placements.has(date) &&
|
||
selectorMatches(rule.placeType, state.placements.get(date)!.type)),
|
||
);
|
||
}
|
||
|
||
function selectedOccurrencePattern(
|
||
rule: NormalizedCalendarRule,
|
||
occurrence: string,
|
||
state: State,
|
||
context: SearchContext,
|
||
): string[] | undefined {
|
||
const activation = state.earnedActivations.find(
|
||
(item) => item.ruleId === rule.id && item.occurrence === occurrence,
|
||
);
|
||
return activation?.dates.every((date) => patternDateSatisfied(date, rule, state, context))
|
||
? activation.dates
|
||
: patternsFor(rule, occurrence, context).find((pattern) =>
|
||
pattern.every((date) => patternDateSatisfied(date, rule, state, context)),
|
||
);
|
||
}
|
||
|
||
function assignDateVariants(
|
||
state: State,
|
||
date: string,
|
||
selector: LeaveSelector,
|
||
ruleId: string,
|
||
context: SearchContext,
|
||
): State[] {
|
||
context.check();
|
||
if (context.declaredDates.has(date)) return [];
|
||
const existing = state.placements.get(date);
|
||
if (existing) {
|
||
if (!selectorMatches(selector, existing.type)) {
|
||
return [];
|
||
}
|
||
if (ruleId) {
|
||
const rule = context.rules.find((item) => item.id === ruleId);
|
||
if (
|
||
rule?.type === 'time_window' &&
|
||
(compareDate(date, rule.startDate!) < 0 || compareDate(date, rule.endDate!) > 0)
|
||
) {
|
||
return [];
|
||
}
|
||
}
|
||
const result = cloneState(state);
|
||
if (ruleId) result.placements.get(date)!.ruleIds.add(ruleId);
|
||
return [result];
|
||
}
|
||
const balances = state.balances
|
||
.filter(
|
||
(balance) =>
|
||
balance.remaining > 0 &&
|
||
selectorMatches(selector, balance.type) &&
|
||
compareDate(balance.availableFrom, date) <= 0 &&
|
||
compareDate(date, balance.expiryDate) <= 0,
|
||
)
|
||
.sort((a, b) => {
|
||
if (selector === 'any') {
|
||
const sharedTypes = context.sharedTypesByDate.get(date) ?? new Set<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);
|
||
});
|
||
return balances.map((balance) => {
|
||
const result = cloneState(state);
|
||
const target = result.balances.find((item) => item.id === balance.id)!;
|
||
target.remaining--;
|
||
notePlacementAdded(state.placements, result.placements, date);
|
||
result.placements.set(date, {
|
||
date,
|
||
balanceId: target.id,
|
||
type: target.type,
|
||
ruleIds: ruleId ? new Set([ruleId]) : new Set(),
|
||
supporting: false,
|
||
});
|
||
return result;
|
||
});
|
||
}
|
||
|
||
function assignDateToBalance(
|
||
state: State,
|
||
date: string,
|
||
balanceId: string,
|
||
context: SearchContext,
|
||
ruleIds = new Set<string>(),
|
||
): State | null {
|
||
context.check();
|
||
if (context.declaredDates.has(date)) return null;
|
||
if (state.placements.has(date)) {
|
||
return null;
|
||
}
|
||
const balance = state.balances.find((item) => item.id === balanceId);
|
||
if (
|
||
!balance ||
|
||
balance.remaining <= 0 ||
|
||
compareDate(balance.availableFrom, date) > 0 ||
|
||
compareDate(date, balance.expiryDate) > 0
|
||
) {
|
||
return null;
|
||
}
|
||
// Reject impossible mandatory moves before copying; validate complete patterns later.
|
||
if (!mandatoryRelocationDate(date, balance, ruleIds, context)) return null;
|
||
// Existing placements stay read-only; this assignment owns only its new placement.
|
||
const result = cloneState(state, new Map(state.placements));
|
||
const target = result.balances.find((item) => item.id === balanceId)!;
|
||
target.remaining--;
|
||
notePlacementAdded(state.placements, result.placements, date);
|
||
result.placements.set(date, {
|
||
date,
|
||
balanceId,
|
||
type: balance.type,
|
||
ruleIds: new Set(ruleIds),
|
||
supporting: false,
|
||
});
|
||
return result;
|
||
}
|
||
|
||
function addEarnedBalance(
|
||
state: State,
|
||
rule: NormalizedCalendarRule,
|
||
occurrence: string,
|
||
dates: string[],
|
||
context: SearchContext,
|
||
): State {
|
||
if (!rule.rewardDays || !rule.rewardType) return state;
|
||
const result = cloneState(state);
|
||
const id = `earned:${rule.id}:${occurrence}`;
|
||
const activation = { id, ruleId: rule.id, occurrence, dates: [...dates] };
|
||
if (!result.earnedActivations.some((item) => item.id === id))
|
||
result.earnedActivations.push(activation);
|
||
const bounds = earnedPeriodBounds(state, activation, context);
|
||
if (bounds.availableFrom <= context.request.calculationDate) return result;
|
||
if (!result.balances.some((balance) => balance.id === id)) {
|
||
result.balances.push({
|
||
id,
|
||
type: rule.rewardType,
|
||
quantity: rule.rewardDays,
|
||
remaining: rule.rewardDays,
|
||
...bounds,
|
||
earned: true,
|
||
sourceRuleId: rule.id,
|
||
});
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function earnedPeriodBounds(
|
||
state: State,
|
||
activation: EarnedActivation,
|
||
context: SearchContext,
|
||
): { availableFrom: string; expiryDate: string } {
|
||
const allDates = [...activation.dates, activation.occurrence].sort(compareDate);
|
||
let start = allDates[0] ?? activation.occurrence;
|
||
let end = allDates[allDates.length - 1] ?? activation.occurrence;
|
||
const mergedPeriod = periodData(state, context).find((period) =>
|
||
activation.dates.some(
|
||
(date) => period.placementDates.includes(date) || period.declaredDates?.includes(date),
|
||
),
|
||
);
|
||
if (mergedPeriod) {
|
||
start = mergedPeriod.start;
|
||
end = mergedPeriod.end;
|
||
}
|
||
while (!isWorking(addDays(start, -1), context.holidays)) start = addDays(start, -1);
|
||
while (!isWorking(addDays(end, 1), context.holidays)) end = addDays(end, 1);
|
||
const availableFrom = addDays(end, 1);
|
||
return { availableFrom, expiryDate: addPlanningYears(availableFrom, 1) };
|
||
}
|
||
|
||
/**
|
||
* Reconcile generated balances whenever a branch gains placements.
|
||
* A later placement can extend the generating merged period, so a
|
||
* provisional reward must not finance an earlier placement in that same final
|
||
* period.
|
||
*/
|
||
function finalizeEarnedState(state: State, context: SearchContext): State | null {
|
||
let reconciled = state;
|
||
// Earning follows actual completed patterns, including those another rule already placed.
|
||
// Both comparison sides must contain these rights before an objective can rank them.
|
||
for (const rule of context.rules) {
|
||
if (rule.type !== 'if_then' || !rule.rewardDays) continue;
|
||
for (const occurrence of occurrences(rule, context)) {
|
||
if (
|
||
reconciled.earnedActivations.some(
|
||
(activation) =>
|
||
activation.ruleId === rule.id && activation.occurrence === occurrence,
|
||
)
|
||
)
|
||
continue;
|
||
const pattern = selectedOccurrencePattern(rule, occurrence, reconciled, context);
|
||
if (pattern)
|
||
reconciled = addEarnedBalance(reconciled, rule, occurrence, pattern, context);
|
||
}
|
||
}
|
||
if (!reconciled.earnedActivations.length) return reconciled;
|
||
let result = cloneState(reconciled);
|
||
for (const activation of result.earnedActivations) {
|
||
context.check();
|
||
const bounds = earnedPeriodBounds(result, activation, context);
|
||
if (bounds.availableFrom <= context.request.calculationDate) {
|
||
if (
|
||
[...result.placements.values()].some(
|
||
(placement) => placement.balanceId === activation.id,
|
||
)
|
||
)
|
||
return null;
|
||
result.balances = result.balances.filter((balance) => balance.id !== activation.id);
|
||
continue;
|
||
}
|
||
let balance = result.balances.find((item) => item.id === activation.id);
|
||
if (!balance) {
|
||
const rule = context.rules.find(
|
||
(rule): rule is NormalizedCalendarRule =>
|
||
rule.type === 'if_then' && rule.id === activation.ruleId,
|
||
);
|
||
if (!rule) return null;
|
||
result = addEarnedBalance(
|
||
result,
|
||
rule,
|
||
activation.occurrence,
|
||
activation.dates,
|
||
context,
|
||
);
|
||
balance = result.balances.find((item) => item.id === activation.id);
|
||
if (!balance) continue;
|
||
}
|
||
balance.availableFrom = bounds.availableFrom;
|
||
balance.expiryDate = bounds.expiryDate;
|
||
for (const placement of result.placements.values()) {
|
||
if (
|
||
placement.balanceId === activation.id &&
|
||
(compareDate(placement.date, balance.availableFrom) < 0 ||
|
||
compareDate(placement.date, balance.expiryDate) > 0)
|
||
)
|
||
return null;
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function* assignDatesVariants(
|
||
state: State,
|
||
dates: string[],
|
||
rule: NormalizedCalendarRule,
|
||
context: SearchContext,
|
||
): PlanningSteps<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 [];
|
||
if (context.declaredDates.has(date)) {
|
||
if (declaredPatternDay(date, rule.placeType, context))
|
||
return yield* assignDatesVariants(state, rest, rule, context);
|
||
return [];
|
||
}
|
||
const result: State[] = [];
|
||
for (const next of assignDateVariants(state, date, rule.placeType, rule.id, context)) {
|
||
yield;
|
||
result.push(...(yield* assignDatesVariants(next, rest, rule, context)));
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function* satisfyObligations(
|
||
requirements: Requirement[],
|
||
state: State,
|
||
context: SearchContext,
|
||
index = 0,
|
||
solutions?: State[],
|
||
solutionLimit = OBLIGATION_SOLUTION_LIMIT,
|
||
chooseOrder = true,
|
||
): PlanningSteps<State | null> {
|
||
yield;
|
||
context.check();
|
||
if (index >= requirements.length) {
|
||
const finalized = finalizeRuleCandidate(state, context, state);
|
||
if (!finalized) return null;
|
||
if (solutions) solutions.push(finalized);
|
||
context.retain?.(finalized);
|
||
return solutions ? null : finalized;
|
||
}
|
||
const requirement = requirements[index];
|
||
// Extending a reported generator requires filling its first working gap.
|
||
// Only move requirements that can fill that gap ahead of a funded consumer.
|
||
const extensionDates =
|
||
chooseOrder && context.horizon.end > context.request.calculationDate
|
||
? state.earnedActivations.map(
|
||
(activation) =>
|
||
state.balances.find((balance) => balance.id === activation.id)
|
||
?.availableFrom ??
|
||
earnedPeriodBounds(state, activation, context).availableFrom,
|
||
)
|
||
: [];
|
||
const extensions = extensionDates.length
|
||
? requirements
|
||
.slice(index + 1)
|
||
.flatMap((candidate, offset) =>
|
||
(
|
||
candidate.patterns
|
||
? candidate.patterns.some((pattern) =>
|
||
pattern.some((date) => extensionDates.includes(date)),
|
||
)
|
||
: candidate.requiredPlacements > 0 &&
|
||
candidate.candidates?.some((date) => extensionDates.includes(date))
|
||
)
|
||
? [index + offset + 1]
|
||
: [],
|
||
)
|
||
: [];
|
||
if (extensions.length) {
|
||
for (const next of [index, ...extensions]) {
|
||
yield;
|
||
const ordered =
|
||
next === index
|
||
? requirements
|
||
: [
|
||
...requirements.slice(0, index),
|
||
requirements[next],
|
||
...requirements.slice(index, next),
|
||
...requirements.slice(next + 1),
|
||
];
|
||
const complete = yield* satisfyObligations(
|
||
ordered,
|
||
state,
|
||
context,
|
||
index,
|
||
solutions,
|
||
solutionLimit,
|
||
false,
|
||
);
|
||
if (complete && !solutions) return complete;
|
||
if (solutions && solutionLimit > 0 && solutions.length >= solutionLimit) break;
|
||
}
|
||
return null;
|
||
}
|
||
if (requirement.optional) {
|
||
const complete = yield* satisfyObligations(
|
||
requirements,
|
||
state,
|
||
context,
|
||
index + 1,
|
||
solutions,
|
||
solutionLimit,
|
||
);
|
||
if (complete && !solutions) return complete;
|
||
}
|
||
if (requirement.patterns) {
|
||
for (const pattern of requirement.patterns) {
|
||
yield;
|
||
for (const assigned of yield* assignDatesVariants(
|
||
state,
|
||
pattern,
|
||
requirement.rule,
|
||
context,
|
||
)) {
|
||
yield;
|
||
const candidate = finalizeEarnedState(
|
||
addEarnedBalance(
|
||
assigned,
|
||
requirement.rule,
|
||
requirement.occurrenceDate!,
|
||
pattern,
|
||
context,
|
||
),
|
||
context,
|
||
);
|
||
if (!candidate) continue;
|
||
const complete = yield* satisfyObligations(
|
||
requirements,
|
||
candidate,
|
||
context,
|
||
index + 1,
|
||
solutions,
|
||
solutionLimit,
|
||
);
|
||
if (complete && !solutions) return complete;
|
||
if (solutions && solutionLimit > 0 && solutions.length >= solutionLimit) {
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
const candidates = requirement.candidates ?? [];
|
||
const needed = requirement.requiredPlacements;
|
||
const choose = function* (
|
||
position: number,
|
||
picked: string[],
|
||
current: State,
|
||
): PlanningSteps<State | null> {
|
||
yield;
|
||
context.check();
|
||
if (picked.length === needed) {
|
||
const finalized = finalizeEarnedState(current, context);
|
||
if (!finalized) return null;
|
||
const complete = yield* satisfyObligations(
|
||
requirements,
|
||
finalized,
|
||
context,
|
||
index + 1,
|
||
solutions,
|
||
solutionLimit,
|
||
);
|
||
if (complete && !solutions) return complete;
|
||
return null;
|
||
}
|
||
if (candidates.length - position < needed - picked.length) {
|
||
return null;
|
||
}
|
||
for (let i = position; i < candidates.length; i++) {
|
||
yield;
|
||
for (const next of assignDateVariants(
|
||
current,
|
||
candidates[i],
|
||
requirement.rule.placeType,
|
||
requirement.rule.id,
|
||
context,
|
||
)) {
|
||
yield;
|
||
const complete = yield* choose(i + 1, [...picked, candidates[i]], next);
|
||
if (complete && !solutions) return complete;
|
||
if (solutions && solutionLimit > 0 && solutions.length >= solutionLimit) {
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
};
|
||
return yield* choose(0, [], state);
|
||
}
|
||
|
||
function releasePlacement(state: State, date: string): State {
|
||
// Removing a slot never edits surviving placements. Later assignments and
|
||
// metadata reconciliation still detach their placements before changing them.
|
||
const result: State = {
|
||
balances: state.balances.map((balance) => ({ ...balance })),
|
||
placements: new Map(state.placements),
|
||
earnedActivations: state.earnedActivations.map((activation) => ({
|
||
...activation,
|
||
dates: [...activation.dates],
|
||
})),
|
||
};
|
||
const placement = result.placements.get(date)!;
|
||
notePlacementRemoved(state.placements, result.placements, date);
|
||
result.placements.delete(date);
|
||
result.balances.find((balance) => balance.id === placement.balanceId)!.remaining++;
|
||
return result;
|
||
}
|
||
|
||
function reconcileOptimizationProvenance(
|
||
state: State,
|
||
context: SearchContext,
|
||
before: State,
|
||
objectives: readonly PlanningObjective[] = activeObjectiveVector(state, context),
|
||
): State {
|
||
const optimizations = objectives.filter(({ id }) => id.startsWith('optimization:'));
|
||
if (!optimizations.length) return state;
|
||
let result = state;
|
||
let observations: OptimizationObservations | undefined;
|
||
for (const placement of state.placements.values()) {
|
||
const previous = before.placements.get(placement.date);
|
||
const creditedIds = new Set([...placement.ruleIds, ...(previous?.ruleIds ?? [])]);
|
||
const credits = optimizations.filter(({ id }) =>
|
||
creditedIds.has(id.slice('optimization:'.length)),
|
||
);
|
||
if (!credits.length) continue;
|
||
const withoutState = releasePlacement(state, placement.date);
|
||
const withoutPlacement = activeObjectiveVector(withoutState, context);
|
||
const ruleIds = new Set(
|
||
[...placement.ruleIds].filter(
|
||
(id) => !credits.some((objective) => objective.id === `optimization:${id}`),
|
||
),
|
||
);
|
||
for (const objective of credits) {
|
||
const without = withoutPlacement.find(({ id }) => id === objective.id)!;
|
||
const id = objective.id.slice('optimization:'.length);
|
||
const rule = context.rules.find(
|
||
(rule): rule is NormalizedOptimizationRule =>
|
||
rule.id === id && rule.type === 'optimization',
|
||
)!;
|
||
const compared = compareObjectiveValues(
|
||
objective.direction,
|
||
objective.value,
|
||
without.value,
|
||
);
|
||
let contributes = compared < 0;
|
||
if (compared === 0 && rule.configuration.aggregation === 'maximum') {
|
||
// Another tied maximum can mask a placement's improvement to its own element.
|
||
observations ??= optimizationObservations(state, context);
|
||
const withoutObservations = optimizationObservations(withoutState, context);
|
||
const summed = { ...rule.configuration, aggregation: 'sum' as const };
|
||
const summedComparison = compareObjectiveValues(
|
||
objective.direction,
|
||
evaluateOptimization(summed, observations),
|
||
evaluateOptimization(summed, withoutObservations),
|
||
);
|
||
contributes =
|
||
summedComparison < 0 ||
|
||
(summedComparison === 0 &&
|
||
rule.configuration.elements === 'time-off-periods' &&
|
||
objective.direction === 'maximize' &&
|
||
observations.timeOffPeriods.length <
|
||
withoutObservations.timeOffPeriods.length);
|
||
}
|
||
if (contributes) ruleIds.add(id);
|
||
}
|
||
if (
|
||
ruleIds.size === placement.ruleIds.size &&
|
||
[...ruleIds].every((id) => placement.ruleIds.has(id))
|
||
)
|
||
continue;
|
||
if (result === state) result = cloneState(state);
|
||
result.placements.get(placement.date)!.ruleIds = ruleIds;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function supportingCalendarBenefit(
|
||
before: OptimizationObservations,
|
||
after: State,
|
||
context: SearchContext,
|
||
): boolean {
|
||
const observations = optimizationObservations(after, context);
|
||
return (
|
||
evaluateOptimization(OPTIMIZATION_PRESETS.holidayWeekValue, observations) >
|
||
evaluateOptimization(OPTIMIZATION_PRESETS.holidayWeekValue, before) ||
|
||
evaluateOptimization(OPTIMIZATION_PRESETS.nonWorkingDayGain, observations) >
|
||
evaluateOptimization(OPTIMIZATION_PRESETS.nonWorkingDayGain, before) ||
|
||
evaluateOptimization(OPTIMIZATION_PRESETS.longestWorkSequence, observations) <
|
||
evaluateOptimization(OPTIMIZATION_PRESETS.longestWorkSequence, before) ||
|
||
evaluateOptimization(OPTIMIZATION_PRESETS.shortSequenceDeficit, observations) <
|
||
evaluateOptimization(OPTIMIZATION_PRESETS.shortSequenceDeficit, before)
|
||
);
|
||
}
|
||
|
||
function mandatoryRelocationDate(
|
||
date: string,
|
||
balance: InternalBalance,
|
||
ruleIds: ReadonlySet<string>,
|
||
context: SearchContext,
|
||
): boolean {
|
||
return [...ruleIds].every((id) => {
|
||
const rule = context.rules.find((item) => item.id === id);
|
||
if (rule?.force !== 'obligation') return true;
|
||
if (!selectorMatches(rule.placeType, balance.type)) return false;
|
||
return rule.type === 'time_window'
|
||
? date >= rule.startDate! && date <= rule.endDate!
|
||
: occurrences(rule, context).some((occurrence) =>
|
||
patternsFor(rule, occurrence, context).some((pattern) =>
|
||
pattern.includes(date),
|
||
),
|
||
);
|
||
});
|
||
}
|
||
|
||
/** Optimistic reachability ignores preference values and complete-pattern validity. */
|
||
function relocationCanReachFree(
|
||
state: State,
|
||
context: SearchContext,
|
||
balanceId: string,
|
||
ruleIds: ReadonlySet<string>,
|
||
visited: ReadonlySet<string>,
|
||
failed: Set<string>,
|
||
): boolean {
|
||
const key = JSON.stringify([balanceId, [...ruleIds].sort(), [...visited].sort()]);
|
||
if (failed.has(key)) return false;
|
||
const queue = [{ balanceId, ruleIds }];
|
||
const seen = new Set<string>();
|
||
for (let index = 0; index < queue.length; index++) {
|
||
const entry = queue[index];
|
||
const identity = JSON.stringify([entry.balanceId, [...entry.ruleIds].sort()]);
|
||
if (seen.has(identity)) continue;
|
||
seen.add(identity);
|
||
const balance = state.balances.find((item) => item.id === entry.balanceId)!;
|
||
for (const date of context.workingDays) {
|
||
context.check();
|
||
if (
|
||
visited.has(date) ||
|
||
context.declaredDates.has(date) ||
|
||
date < balance.availableFrom ||
|
||
date > balance.expiryDate ||
|
||
!mandatoryRelocationDate(date, balance, entry.ruleIds, context)
|
||
)
|
||
continue;
|
||
const placement = state.placements.get(date);
|
||
if (!placement) return true;
|
||
queue.push({ balanceId: placement.balanceId, ruleIds: placement.ruleIds });
|
||
}
|
||
}
|
||
failed.add(key);
|
||
return false;
|
||
}
|
||
|
||
/** Relocate occupied dates along an augmenting path and validate only the complete addition. */
|
||
function* relocateForAddition(
|
||
state: State,
|
||
context: SearchContext,
|
||
date: string,
|
||
balanceId: string,
|
||
ruleIds: Set<string>,
|
||
supporting: boolean,
|
||
accept: (candidate: State) => boolean,
|
||
visited = new Set<string>(),
|
||
failedReachability = new Set<string>(),
|
||
failedSearches = new Set<string>(),
|
||
): PlanningSteps<boolean> {
|
||
yield;
|
||
context.check();
|
||
if (visited.has(date) || context.declaredDates.has(date)) return false;
|
||
const existing = state.placements.get(date);
|
||
if (
|
||
existing?.balanceId === balanceId &&
|
||
existing.supporting === supporting &&
|
||
existing.ruleIds.size === ruleIds.size &&
|
||
[...existing.ruleIds].every((id) => ruleIds.has(id))
|
||
)
|
||
return false;
|
||
const key = JSON.stringify([
|
||
date,
|
||
balanceId,
|
||
[...ruleIds].sort(),
|
||
supporting,
|
||
[...visited].sort(),
|
||
state.balances.map((balance) => [balance.id, balance.remaining]),
|
||
[...state.placements.values()]
|
||
.sort((a, b) => compareDate(a.date, b.date))
|
||
.map((placement) => [
|
||
placement.date,
|
||
placement.balanceId,
|
||
[...placement.ruleIds].sort(),
|
||
placement.supporting,
|
||
]),
|
||
]);
|
||
if (failedSearches.has(key)) {
|
||
return false;
|
||
}
|
||
const assigned = assignDateToBalance(
|
||
existing ? releasePlacement(state, date) : state,
|
||
date,
|
||
balanceId,
|
||
context,
|
||
ruleIds,
|
||
);
|
||
if (!assigned) return false;
|
||
assigned.placements.get(date)!.supporting = supporting;
|
||
if (!existing) {
|
||
const accepted = accept(assigned);
|
||
if (!accepted) failedSearches.add(key);
|
||
return accepted;
|
||
}
|
||
const displaced = assigned.balances.find((balance) => balance.id === existing.balanceId)!;
|
||
const nextVisited = new Set([...visited, date]);
|
||
if (
|
||
!relocationCanReachFree(
|
||
assigned,
|
||
context,
|
||
existing.balanceId,
|
||
existing.ruleIds,
|
||
nextVisited,
|
||
failedReachability,
|
||
)
|
||
)
|
||
return false;
|
||
for (const destination of context.workingDays) {
|
||
yield;
|
||
if (destination < displaced.availableFrom || destination > displaced.expiryDate) continue;
|
||
if (!mandatoryRelocationDate(destination, displaced, existing.ruleIds, context)) continue;
|
||
if (
|
||
yield* relocateForAddition(
|
||
assigned,
|
||
context,
|
||
destination,
|
||
existing.balanceId,
|
||
existing.ruleIds,
|
||
existing.supporting,
|
||
accept,
|
||
nextVisited,
|
||
failedReachability,
|
||
failedSearches,
|
||
)
|
||
)
|
||
return true;
|
||
}
|
||
failedSearches.add(key);
|
||
return false;
|
||
}
|
||
|
||
function finalizeRuleCandidate(state: State, context: SearchContext, before: State): State | null {
|
||
let result = state;
|
||
const windowCredits = new Map<string, number>();
|
||
const patternCredits = new Map<string, Set<string>>();
|
||
for (const placement of state.placements.values()) {
|
||
let lostCalendarRequest = false;
|
||
let hasCalendarRequest = false;
|
||
for (const id of placement.ruleIds) {
|
||
const rule = context.rules.find((item) => item.id === id);
|
||
if (rule?.type === 'optimization') continue;
|
||
const valid = counterfactualRuleValid(
|
||
placement.date,
|
||
placement.type,
|
||
[id],
|
||
context,
|
||
state,
|
||
);
|
||
const credited = windowCredits.get(id) ?? 0;
|
||
let withinRequest = true;
|
||
if (rule?.type === 'time_window')
|
||
withinRequest =
|
||
credited <
|
||
Math.max(0, rule.placeDays - declaredWindowDays(rule, context).length);
|
||
else if (rule?.type === 'if_then') {
|
||
if (!patternCredits.has(id))
|
||
patternCredits.set(
|
||
id,
|
||
new Set(
|
||
occurrences(rule, context).flatMap(
|
||
(occurrence) =>
|
||
selectedOccurrencePattern(rule, occurrence, state, context) ??
|
||
[],
|
||
),
|
||
),
|
||
);
|
||
withinRequest = patternCredits.get(id)!.has(placement.date);
|
||
}
|
||
if (valid && withinRequest) {
|
||
hasCalendarRequest = true;
|
||
if (rule?.type === 'time_window') windowCredits.set(id, credited + 1);
|
||
continue;
|
||
}
|
||
if (!valid && rule?.force === 'obligation') return null;
|
||
if (result === state) result = cloneState(state);
|
||
result.placements.get(placement.date)!.ruleIds.delete(id);
|
||
lostCalendarRequest = true;
|
||
}
|
||
const current = result.placements.get(placement.date)!;
|
||
const previous = before.placements.get(placement.date);
|
||
let supporting = current.supporting;
|
||
if (hasCalendarRequest) supporting = false;
|
||
else if (
|
||
lostCalendarRequest ||
|
||
(previous &&
|
||
(previous.balanceId !== placement.balanceId ||
|
||
previous.supporting !== current.supporting))
|
||
) {
|
||
const balance = result.balances.find((item) => item.id === placement.balanceId)!;
|
||
supporting =
|
||
!isAtRisk(balance, context) &&
|
||
(current.supporting ||
|
||
(previous?.balanceId === placement.balanceId && previous.supporting) ||
|
||
supportingCalendarBenefit(
|
||
optimizationObservations(releasePlacement(result, placement.date), context),
|
||
result,
|
||
context,
|
||
));
|
||
}
|
||
if (supporting !== current.supporting) {
|
||
if (result === state) result = cloneState(state);
|
||
result.placements.get(placement.date)!.supporting = supporting;
|
||
}
|
||
}
|
||
for (const activation of state.earnedActivations) {
|
||
const rule = context.rules.find(
|
||
(item): item is NormalizedCalendarRule =>
|
||
item.type === 'if_then' && item.id === activation.ruleId,
|
||
)!;
|
||
if (activation.dates.every((date) => patternDateSatisfied(date, rule, state, context)))
|
||
continue;
|
||
if (
|
||
[...state.placements.values()].some(
|
||
(placement) => placement.balanceId === activation.id,
|
||
)
|
||
)
|
||
return null;
|
||
result = cloneState(result);
|
||
result.earnedActivations = result.earnedActivations.filter(
|
||
(item) => item.id !== activation.id,
|
||
);
|
||
result.balances = result.balances.filter((balance) => balance.id !== activation.id);
|
||
}
|
||
const finalized = finalizeEarnedState(result, context);
|
||
return finalized && !unsatisfiedObligationCount(finalized, context) ? finalized : null;
|
||
}
|
||
|
||
function typedCalendarDates(context: SearchContext): Set<string> {
|
||
const typedDates = new Set<string>();
|
||
for (const rule of context.rules) {
|
||
if (rule.type === 'optimization' || rule.placeType === 'any') continue;
|
||
const dates =
|
||
rule.type === 'time_window'
|
||
? context.workingDays.filter(
|
||
(date) => rule.startDate! <= date && date <= rule.endDate!,
|
||
)
|
||
: occurrences(rule, context).flatMap((occurrence) =>
|
||
patternsFor(rule, occurrence, context).flat(),
|
||
);
|
||
for (const date of dates) typedDates.add(date);
|
||
}
|
||
return typedDates;
|
||
}
|
||
|
||
function* calendarPlacementCandidates(
|
||
state: State,
|
||
targetDates: string[],
|
||
ruleId: string,
|
||
context: SearchContext,
|
||
accept: (candidate: State, addition?: boolean) => boolean,
|
||
): PlanningSteps<boolean> {
|
||
const missing = targetDates.filter(
|
||
(date) => !state.placements.has(date) && !context.declaredDates.has(date),
|
||
);
|
||
if (!missing.length) return false;
|
||
const before = optimizationObservations(state, context);
|
||
const typedDates = typedCalendarDates(context);
|
||
const failed = new Set<string>();
|
||
const rankedDonors: { date: string; objectives: PlanningObjective[] }[] = [];
|
||
for (const placement of state.placements.values()) {
|
||
yield;
|
||
context.check();
|
||
if (targetDates.includes(placement.date)) continue;
|
||
rankedDonors.push({
|
||
date: placement.date,
|
||
objectives: activeObjectiveVector(releasePlacement(state, placement.date), context),
|
||
});
|
||
}
|
||
// shortcut: recursion retains the original donor ranking; rerank when a measured bundle misses a better allocation.
|
||
rankedDonors.sort(
|
||
(a, b) => compareObjectiveLists(a.objectives, b.objectives) || compareDate(b.date, a.date),
|
||
);
|
||
const fill = function* (
|
||
index: number,
|
||
current: State,
|
||
additions: string[] = [],
|
||
): PlanningSteps<boolean> {
|
||
yield;
|
||
context.check();
|
||
const key = JSON.stringify([
|
||
index,
|
||
current.balances.map((balance) => [balance.id, balance.remaining]),
|
||
[...current.placements.values()]
|
||
.sort((a, b) => compareDate(a.date, b.date))
|
||
.map((placement) => [
|
||
placement.date,
|
||
targetDates.includes(placement.date)
|
||
? typedDates.has(placement.date)
|
||
? placement.type
|
||
: null
|
||
: placement.type,
|
||
targetDates.includes(placement.date) &&
|
||
!current.balances.find((balance) => balance.id === placement.balanceId)!.earned
|
||
? null
|
||
: placement.balanceId,
|
||
[...placement.ruleIds].sort(),
|
||
placement.supporting,
|
||
additions.includes(placement.date),
|
||
]),
|
||
]);
|
||
if (failed.has(key)) return false;
|
||
if (index === missing.length) {
|
||
const benefit = supportingCalendarBenefit(before, current, context);
|
||
for (const date of additions) {
|
||
const placement = { ...current.placements.get(date)! };
|
||
current.placements.set(date, placement);
|
||
const balance = current.balances.find((item) => item.id === placement.balanceId)!;
|
||
placement.supporting = benefit && !isAtRisk(balance, context);
|
||
}
|
||
const accepted = accept(current, additions.length > 0);
|
||
if (!accepted) failed.add(key);
|
||
return accepted;
|
||
}
|
||
const date = missing[index];
|
||
for (const balance of current.balances) {
|
||
if (balance.remaining <= 0 || date < balance.availableFrom || date > balance.expiryDate)
|
||
continue;
|
||
const assigned = assignDateToBalance(
|
||
current,
|
||
date,
|
||
balance.id,
|
||
context,
|
||
new Set([ruleId]),
|
||
);
|
||
if (!assigned) continue;
|
||
if (yield* fill(index + 1, assigned, [...additions, date])) return true;
|
||
}
|
||
const donors = new Set<string>();
|
||
for (const donor of rankedDonors) {
|
||
yield;
|
||
const source = current.placements.get(donor.date);
|
||
if (!source) continue;
|
||
const balance = current.balances.find((item) => item.id === source.balanceId)!;
|
||
if (
|
||
date < balance.availableFrom ||
|
||
date > balance.expiryDate ||
|
||
!mandatoryRelocationDate(date, balance, source.ruleIds, context)
|
||
)
|
||
continue;
|
||
const without = releasePlacement(current, source.date);
|
||
const identity = JSON.stringify([
|
||
source.balanceId,
|
||
[...source.ruleIds].sort(),
|
||
source.supporting,
|
||
activeObjectiveVector(without, context),
|
||
]);
|
||
// shortcut: equivalent donor tokens share one deterministic source; expand coordinated donor choices if a measured plan needs them.
|
||
if (donors.has(identity)) continue;
|
||
donors.add(identity);
|
||
const assigned = assignDateToBalance(
|
||
without,
|
||
date,
|
||
source.balanceId,
|
||
context,
|
||
source.ruleIds,
|
||
);
|
||
if (!assigned) continue;
|
||
assigned.placements.get(date)!.supporting = source.supporting;
|
||
if (yield* fill(index + 1, assigned, additions)) return true;
|
||
}
|
||
failed.add(key);
|
||
return false;
|
||
};
|
||
return yield* fill(0, state);
|
||
}
|
||
|
||
function* holidayWeekPlacementCandidates(
|
||
state: State,
|
||
ruleId: string,
|
||
context: SearchContext,
|
||
accept: (candidate: State, addition?: boolean) => boolean,
|
||
): PlanningSteps<void> {
|
||
for (const week of context.holidayWeeks)
|
||
yield* calendarPlacementCandidates(state, week.dates, ruleId, context, accept);
|
||
}
|
||
|
||
function* timeOffPeriodPlacementCandidates(
|
||
state: State,
|
||
ruleId: string,
|
||
context: SearchContext,
|
||
accept: (candidate: State, addition?: boolean) => boolean,
|
||
): PlanningSteps<void> {
|
||
const periods = periodData(state, context);
|
||
for (let index = 1; index < periods.length; index++) {
|
||
const dates = context.workingDays.filter(
|
||
(date) => date > periods[index - 1].end && date < periods[index].start,
|
||
);
|
||
if (dates.length > 1)
|
||
yield* calendarPlacementCandidates(state, dates, ruleId, context, accept);
|
||
}
|
||
}
|
||
|
||
type FinalizeInvariance = 'exact' | 'without-earning' | 'unknown';
|
||
const finalizeInvarianceCache = new WeakMap<SearchContext, FinalizeInvariance[]>();
|
||
|
||
/**
|
||
* Finalizing never moves or retypes placements; it only edits ruleIds, supporting, and earned
|
||
* activations/balances. Preference results and the calendar optimizations (public-holiday-weeks,
|
||
* time-off-periods, work-sequences) read only placement dates and types, so the pre-finalize
|
||
* value is already the final one. leave-balances is exact only when nothing can be earned (no
|
||
* rewarding if_then rule and no earned activation on the candidate). leave-placements reads
|
||
* `supporting`, which finalizing rewrites. Entries follow activeObjectives order.
|
||
*/
|
||
function finalizeInvariance(context: SearchContext): FinalizeInvariance[] {
|
||
let entries = finalizeInvarianceCache.get(context);
|
||
if (!entries) {
|
||
const earning = context.rules.some(
|
||
(rule) => rule.enabled && rule.type === 'if_then' && rule.rewardDays,
|
||
);
|
||
entries = context.rules
|
||
.filter((rule) => rule.enabled && rule.force === 'preference')
|
||
.map((rule) => {
|
||
if (rule.type !== 'optimization') return 'exact';
|
||
switch (rule.configuration.elements) {
|
||
case 'public-holiday-weeks':
|
||
case 'time-off-periods':
|
||
case 'work-sequences':
|
||
return 'exact';
|
||
case 'leave-balances':
|
||
return earning ? 'unknown' : 'without-earning';
|
||
default:
|
||
// New element kinds stay unpruned until their bound is argued.
|
||
return 'unknown';
|
||
}
|
||
});
|
||
finalizeInvarianceCache.set(context, entries);
|
||
}
|
||
return entries;
|
||
}
|
||
|
||
/** True when the first differing finalize-invariant protected objective is already worse than the baseline. */
|
||
function provablyWorseThanProtectedPrefix(
|
||
candidate: State,
|
||
context: SearchContext,
|
||
throughIndex: number,
|
||
baseline: readonly PlanningObjective[],
|
||
): boolean {
|
||
const invariance = finalizeInvariance(context);
|
||
let index = 0;
|
||
for (const objective of activeObjectives(candidate, context, throughIndex)) {
|
||
const kind = invariance[index];
|
||
if (
|
||
kind === 'unknown' ||
|
||
(kind === 'without-earning' && candidate.earnedActivations.length)
|
||
)
|
||
return false;
|
||
const comparison = compareObjectiveValues(
|
||
objective.direction,
|
||
objective.value,
|
||
baseline[index].value,
|
||
);
|
||
if (comparison !== 0) return comparison > 0;
|
||
index++;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function evaluateRuleCandidate(
|
||
before: State,
|
||
candidate: State | null,
|
||
context: SearchContext,
|
||
throughIndex: number,
|
||
addition = false,
|
||
baseline = activeObjectiveVector(before, context, throughIndex),
|
||
incumbentVector = objectiveVector(before, context),
|
||
): { state: State; vector?: PlanningObjectiveVector } | undefined {
|
||
if (!candidate) return undefined;
|
||
if (provablyWorseThanProtectedPrefix(candidate, context, throughIndex, baseline))
|
||
return undefined;
|
||
const state = finalizeRuleCandidate(candidate, context, before);
|
||
if (!state) return undefined;
|
||
const active: PlanningObjective[] = [];
|
||
let comparison = 0;
|
||
let incumbentComparison = 0;
|
||
for (const objective of activeObjectives(state, context)) {
|
||
const index = active.length;
|
||
active.push(objective);
|
||
if (index <= throughIndex && comparison === 0) {
|
||
comparison = compareObjectiveValues(
|
||
objective.direction,
|
||
objective.value,
|
||
baseline[index].value,
|
||
);
|
||
// A worse protected prefix cannot improve the jointly valid incumbent.
|
||
if (comparison > 0) return undefined;
|
||
}
|
||
if (incumbentComparison === 0)
|
||
incumbentComparison = compareObjectiveValues(
|
||
objective.direction,
|
||
objective.value,
|
||
incumbentVector.objectives[index + 1].value,
|
||
);
|
||
if (incumbentComparison > 0 && (comparison < 0 || index >= throughIndex))
|
||
return addition && comparison === 0 ? undefined : { state };
|
||
}
|
||
const vector = objectiveVector(state, context, active);
|
||
// Preserve the best valid candidate even if the active prefix rejects it.
|
||
if (compareObjectiveLists(vector.objectives, incumbentVector.objectives) < 0)
|
||
context.retain?.(state, undefined, vector);
|
||
// shortcut: single additions cannot cross equal-value plateaus; add coordinated balance bundles when a measured configuration needs them.
|
||
if (addition && comparison === 0) return undefined;
|
||
return { state, vector };
|
||
}
|
||
|
||
/** Only the complete sequence change is compared; a partial gap fill can worsen the deficit. */
|
||
function* workSequencePlacementCandidates(
|
||
state: State,
|
||
rule: NormalizedOptimizationRule,
|
||
context: SearchContext,
|
||
consider: (candidate: State) => boolean,
|
||
): PlanningSteps<void> {
|
||
const configuration = rule.configuration;
|
||
if (configuration.direction !== 'minimize') return;
|
||
const sequences = workSequenceDates(state, context).filter(
|
||
({ dates, internal }) =>
|
||
dates.length > 0 && (!configuration.conditions.includes('internal') || internal),
|
||
);
|
||
const bundles: string[][][] = [];
|
||
if (configuration.aggregation === 'count')
|
||
bundles.push(...sequences.map(({ dates }) => dates.map((date) => [date])));
|
||
else if (configuration.measure === 'work-sequence-deficit') {
|
||
const short = sequences.filter(({ dates }) => dates.length < configuration.thresholdDays);
|
||
if (configuration.aggregation === 'maximum') {
|
||
const shortest = Math.min(...short.map(({ dates }) => dates.length));
|
||
bundles.push(
|
||
short
|
||
.filter(({ dates }) => dates.length === shortest)
|
||
.flatMap(({ dates }) => dates.map((date) => [date])),
|
||
);
|
||
} else bundles.push(...short.map(({ dates }) => dates.map((date) => [date])));
|
||
} else if (
|
||
configuration.measure === 'work-sequence-length' &&
|
||
configuration.aggregation === 'maximum'
|
||
) {
|
||
const maximum = Math.max(0, ...sequences.map(({ dates }) => dates.length));
|
||
const tied = sequences.filter(({ dates }) => dates.length === maximum);
|
||
if (tied.length > 1) {
|
||
for (const offset of [0, 1]) {
|
||
const bundle: string[][] = [];
|
||
for (const { dates } of tied) {
|
||
const middle = Math.floor((dates.length - 1 + offset) / 2);
|
||
const centered = dates
|
||
.map((date, index) => ({ date, index }))
|
||
.sort(
|
||
(a, b) =>
|
||
Math.abs(a.index - middle) - Math.abs(b.index - middle) ||
|
||
compareDate(a.date, b.date),
|
||
);
|
||
const eligible = [
|
||
...new Set(
|
||
state.balances
|
||
.filter((balance) => balance.remaining > 0)
|
||
.map(
|
||
(balance) =>
|
||
centered.find(
|
||
({ date }) =>
|
||
balance.availableFrom <= date &&
|
||
date <= balance.expiryDate,
|
||
)?.date,
|
||
)
|
||
.filter((date): date is string => date !== undefined),
|
||
),
|
||
];
|
||
if (!eligible.length) break;
|
||
bundle.push(
|
||
eligible.sort(
|
||
(a, b) =>
|
||
centered.findIndex(({ date }) => date === a) -
|
||
centered.findIndex(({ date }) => date === b),
|
||
),
|
||
);
|
||
}
|
||
if (bundle.length === tied.length) bundles.push(bundle);
|
||
}
|
||
}
|
||
}
|
||
const before = optimizationObservations(state, context);
|
||
const available = state.balances.reduce((total, balance) => total + balance.remaining, 0);
|
||
const typedDates = typedCalendarDates(context);
|
||
const failedAssignments = new Set<string>();
|
||
function* assignBundle(
|
||
candidate: State,
|
||
slots: string[][],
|
||
dates: string[] = [],
|
||
): PlanningSteps<boolean> {
|
||
yield;
|
||
context.check();
|
||
// Equivalent rejected allocations differ only in non-earned balance provenance.
|
||
const key = JSON.stringify([
|
||
slots,
|
||
dates,
|
||
candidate.balances.map(({ id, remaining }) => [id, remaining]),
|
||
dates.map((date) => {
|
||
const placement = candidate.placements.get(date)!;
|
||
const balance = candidate.balances.find((item) => item.id === placement.balanceId)!;
|
||
return [
|
||
typedDates.has(date) ? placement.type : undefined,
|
||
balance.earned ? balance.id : undefined,
|
||
];
|
||
}),
|
||
]);
|
||
if (failedAssignments.has(key)) return false;
|
||
if (dates.length === slots.length) {
|
||
const supporting = supportingCalendarBenefit(before, candidate, context);
|
||
for (const date of dates) {
|
||
const placement = candidate.placements.get(date)!;
|
||
const balance = candidate.balances.find((item) => item.id === placement.balanceId)!;
|
||
placement.supporting = supporting && !isAtRisk(balance, context);
|
||
}
|
||
const accepted = consider(candidate);
|
||
if (!accepted) failedAssignments.add(key);
|
||
return accepted;
|
||
}
|
||
for (const date of slots[dates.length]) {
|
||
for (const next of assignDateVariants(candidate, date, 'any', rule.id, context))
|
||
if (yield* assignBundle(next, slots, [...dates, date])) return true;
|
||
}
|
||
failedAssignments.add(key);
|
||
return false;
|
||
}
|
||
// shortcut: bundles keep the first accepted allocation and nearest center per balance; extend when a measured case needs another coordinated allocation.
|
||
const examined = new Set<string>();
|
||
for (const slots of bundles) {
|
||
yield;
|
||
const key = JSON.stringify(slots);
|
||
if (slots.length <= 1 || slots.length > available || examined.has(key)) continue;
|
||
examined.add(key);
|
||
yield* assignBundle(state, slots);
|
||
}
|
||
}
|
||
|
||
/** Candidate additions need an active rule improvement; expiry and date ties only reshape existing placements. */
|
||
function* optimizeRulePlacements(
|
||
state: State,
|
||
context: SearchContext,
|
||
throughIndex: number,
|
||
allowAdd: boolean,
|
||
rule?: NormalizedRule,
|
||
): PlanningSteps<State> {
|
||
let result = state;
|
||
const addingBalanceQuantity =
|
||
allowAdd &&
|
||
rule?.type === 'optimization' &&
|
||
rule.configuration.elements === 'leave-balances' &&
|
||
rule.configuration.measure === 'unplaced-quantity' &&
|
||
rule.configuration.aggregation === 'sum' &&
|
||
rule.configuration.direction === 'minimize';
|
||
const equivalentAdditionFunding =
|
||
addingBalanceQuantity &&
|
||
throughIndex === 0 &&
|
||
!context.rules.some((item) => item.enabled && item.type === 'if_then') &&
|
||
context.rules.every(
|
||
(item) =>
|
||
!item.enabled ||
|
||
item.type !== 'optimization' ||
|
||
item.configuration.elements !== 'leave-balances' ||
|
||
item.configuration.aggregation !== 'maximum',
|
||
);
|
||
const fundingWindows = context.rules.filter(
|
||
(item): item is NormalizedCalendarRule => item.enabled && item.type === 'time_window',
|
||
);
|
||
const preferencePrefix = context.rules
|
||
.filter((item) => item.enabled && item.force === 'preference')
|
||
.slice(0, throughIndex + 1);
|
||
const prefixBounds = preferencePrefix.map((item, index): number | undefined => {
|
||
if (index === 0)
|
||
return item.type === 'optimization' &&
|
||
item.configuration.elements === 'leave-balances' &&
|
||
item.configuration.measure === 'unplaced-quantity' &&
|
||
item.configuration.aggregation === 'sum' &&
|
||
item.configuration.direction === 'minimize'
|
||
? 0
|
||
: undefined;
|
||
if (item.type === 'time_window') return item.placeDays;
|
||
if (
|
||
item.type === 'optimization' &&
|
||
item.configuration.elements === 'public-holiday-weeks' &&
|
||
item.configuration.measure === 'public-holiday-week-value' &&
|
||
item.configuration.direction === 'maximize'
|
||
)
|
||
return evaluateOptimization(item.configuration, {
|
||
leaveBalances: [],
|
||
publicHolidayWeeks: context.holidayWeeks.map(({ value }) => ({
|
||
value,
|
||
complete: true,
|
||
})),
|
||
timeOffPeriods: [],
|
||
workSequences: [],
|
||
leavePlacements: [],
|
||
});
|
||
return undefined;
|
||
});
|
||
const dateInvariantObjectives = context.rules
|
||
.filter((item) => item.force === 'preference')
|
||
.every(
|
||
(item) =>
|
||
item.type === 'optimization' &&
|
||
(item.configuration!.elements === 'leave-balances' ||
|
||
item.configuration!.elements === 'leave-placements'),
|
||
);
|
||
while (true) {
|
||
yield;
|
||
context.check();
|
||
const active = activeObjectiveVector(result, context);
|
||
const baseline = active.slice(0, throughIndex + 1);
|
||
const beforeVector = objectiveVector(result, context, active);
|
||
context.retain?.(result, undefined, beforeVector);
|
||
if (
|
||
allowAdd &&
|
||
prefixBounds.length > 0 &&
|
||
prefixBounds.every(
|
||
(bound, index) => bound !== undefined && active[index].value === bound,
|
||
)
|
||
) {
|
||
return result;
|
||
}
|
||
const genericStateKey =
|
||
allowAdd &&
|
||
result.balances.every(({ remaining }) => remaining === 0) &&
|
||
!context.rules.some((item) => item.enabled && item.type === 'if_then')
|
||
? preferenceStateKey(result)
|
||
: undefined;
|
||
const reuseGeneric =
|
||
genericStateKey !== undefined && context.completedGenericState === genericStateKey;
|
||
const compareEquivalentFunding =
|
||
equivalentAdditionFunding &&
|
||
result.earnedActivations.length === 0 &&
|
||
Number.isSafeInteger(
|
||
result.balances.reduce((sum, balance) => sum + balance.remaining, 0),
|
||
);
|
||
let beforeCalendar: OptimizationObservations | undefined;
|
||
let best = result;
|
||
let bestVector = beforeVector;
|
||
const consider = (candidate: State | null, addition = false): boolean => {
|
||
const evaluated = evaluateRuleCandidate(
|
||
result,
|
||
candidate,
|
||
context,
|
||
throughIndex,
|
||
addition,
|
||
baseline,
|
||
bestVector,
|
||
);
|
||
if (!evaluated) return false;
|
||
if (!evaluated.vector) return true;
|
||
const compared = compareObjectiveLists(
|
||
evaluated.vector.objectives,
|
||
bestVector.objectives,
|
||
);
|
||
if (compared >= 0) return true;
|
||
best = evaluated.state;
|
||
bestVector = evaluated.vector;
|
||
return true;
|
||
};
|
||
const dates = [...context.workingDays].sort(
|
||
(a, b) => Number(result.placements.has(a)) - Number(result.placements.has(b)),
|
||
);
|
||
// shortcut: loaded plans sample flexible move destinations, upgrade to interval search when a measured case misses a better plan.
|
||
const moveDates =
|
||
result.placements.size <= 24
|
||
? context.workingDays
|
||
: context.workingDays.filter(
|
||
(date, index) =>
|
||
!isWorking(addDays(date, -1), context.holidays) ||
|
||
!isWorking(addDays(date, 1), context.holidays) ||
|
||
context.declaredDates.has(addDays(date, -1)) ||
|
||
context.declaredDates.has(addDays(date, 1)) ||
|
||
index % 7 === 0 ||
|
||
context.rules.some(
|
||
(item) =>
|
||
item.type === 'time_window' &&
|
||
date >= item.startDate! &&
|
||
date <= item.endDate!,
|
||
),
|
||
);
|
||
if (
|
||
allowAdd &&
|
||
rule?.type === 'optimization' &&
|
||
rule.configuration.elements === 'work-sequences'
|
||
)
|
||
yield* workSequencePlacementCandidates(result, rule, context, (candidate) =>
|
||
consider(candidate, true),
|
||
);
|
||
if (
|
||
allowAdd &&
|
||
rule?.type === 'optimization' &&
|
||
rule.configuration.elements === 'public-holiday-weeks' &&
|
||
rule.configuration.conditions.includes('complete') &&
|
||
rule.configuration.direction === 'maximize'
|
||
)
|
||
yield* holidayWeekPlacementCandidates(result, rule.id, context, consider);
|
||
if (
|
||
allowAdd &&
|
||
rule?.type === 'optimization' &&
|
||
rule.configuration.elements === 'time-off-periods' &&
|
||
((rule.configuration.aggregation === 'maximum' &&
|
||
rule.configuration.direction === 'maximize') ||
|
||
(rule.configuration.aggregation === 'count' &&
|
||
rule.configuration.direction === 'minimize'))
|
||
)
|
||
yield* timeOffPeriodPlacementCandidates(result, rule.id, context, consider);
|
||
// shortcut: commit a strict bundle prefix improvement before exploring old-state
|
||
// generic alternatives; upgrade to a combined frontier if measured cases need it.
|
||
const deferGeneric =
|
||
allowAdd &&
|
||
best !== result &&
|
||
compareObjectiveLists(bestVector.objectives.slice(1, throughIndex + 2), baseline) < 0;
|
||
for (const date of reuseGeneric || deferGeneric ? [] : dates) {
|
||
yield;
|
||
context.check();
|
||
if (context.declaredDates.has(date)) continue;
|
||
const existing = result.placements.get(date);
|
||
if (!existing && allowAdd) {
|
||
if (
|
||
rule?.type === 'time_window' &&
|
||
(date < rule.startDate! || date > rule.endDate!)
|
||
)
|
||
continue;
|
||
const evaluatedFunding = new Set<string>();
|
||
for (const balance of result.balances) {
|
||
yield;
|
||
if (
|
||
rule?.type === 'time_window' &&
|
||
!selectorMatches(rule.placeType, balance.type)
|
||
)
|
||
continue;
|
||
if (
|
||
balance.remaining <= 0 ||
|
||
balance.availableFrom > date ||
|
||
balance.expiryDate < date
|
||
)
|
||
continue;
|
||
// Complete single additions with equal expiry/window scores cannot beat the
|
||
// first evaluated funder. Recursive bundles still need every funding choice.
|
||
const fundingKey =
|
||
compareEquivalentFunding && isAtRisk(balance, context)
|
||
? `${balance.expiryDate}:${fundingWindows
|
||
.map((window) =>
|
||
Number(
|
||
date >= window.startDate! &&
|
||
date <= window.endDate! &&
|
||
selectorMatches(window.placeType, balance.type),
|
||
),
|
||
)
|
||
.join('')}`
|
||
: undefined;
|
||
if (fundingKey !== undefined && evaluatedFunding.has(fundingKey)) {
|
||
continue;
|
||
}
|
||
const assigned = assignDateToBalance(
|
||
result,
|
||
date,
|
||
balance.id,
|
||
context,
|
||
rule ? new Set([rule.id]) : new Set(),
|
||
);
|
||
if (assigned && rule?.type === 'optimization' && !isAtRisk(balance, context)) {
|
||
beforeCalendar ??= optimizationObservations(result, context);
|
||
assigned.placements.get(date)!.supporting = supportingCalendarBenefit(
|
||
beforeCalendar,
|
||
assigned,
|
||
context,
|
||
);
|
||
}
|
||
const evaluated = consider(assigned, true);
|
||
if (evaluated && fundingKey !== undefined) evaluatedFunding.add(fundingKey);
|
||
}
|
||
}
|
||
if (!existing || (addingBalanceQuantity && best !== result)) continue;
|
||
const without = releasePlacement(result, date);
|
||
consider(without);
|
||
for (const balance of without.balances) {
|
||
yield;
|
||
if (
|
||
balance.id === existing.balanceId ||
|
||
balance.remaining <= 0 ||
|
||
balance.availableFrom > date ||
|
||
balance.expiryDate < date
|
||
)
|
||
continue;
|
||
const reassigned = assignDateToBalance(
|
||
without,
|
||
date,
|
||
balance.id,
|
||
context,
|
||
existing.ruleIds,
|
||
);
|
||
if (reassigned)
|
||
reassigned.placements.get(date)!.supporting =
|
||
existing.supporting && !isAtRisk(balance, context);
|
||
consider(reassigned);
|
||
}
|
||
for (const destination of moveDates) {
|
||
yield;
|
||
context.check();
|
||
if (destination === date || context.declaredDates.has(destination)) continue;
|
||
const other = without.placements.get(destination);
|
||
if (other) {
|
||
if (other.balanceId === existing.balanceId) continue;
|
||
const swapped = releasePlacement(without, destination);
|
||
const first = assignDateToBalance(
|
||
swapped,
|
||
destination,
|
||
existing.balanceId,
|
||
context,
|
||
existing.ruleIds,
|
||
);
|
||
const second =
|
||
first &&
|
||
assignDateToBalance(first, date, other.balanceId, context, other.ruleIds);
|
||
if (second) {
|
||
second.placements.get(destination)!.supporting = existing.supporting;
|
||
second.placements.get(date)!.supporting = other.supporting;
|
||
}
|
||
consider(second);
|
||
} else {
|
||
const moved = assignDateToBalance(
|
||
without,
|
||
destination,
|
||
existing.balanceId,
|
||
context,
|
||
existing.ruleIds,
|
||
);
|
||
if (moved) moved.placements.get(destination)!.supporting = existing.supporting;
|
||
if (
|
||
moved &&
|
||
dateInvariantObjectives &&
|
||
compareObjectiveLists(
|
||
tieBreakObjectives(moved, context),
|
||
beforeVector.objectives.slice(-2),
|
||
) >= 0
|
||
) {
|
||
continue;
|
||
}
|
||
consider(moved);
|
||
}
|
||
}
|
||
}
|
||
if (
|
||
allowAdd &&
|
||
(addingBalanceQuantity ||
|
||
(rule?.type === 'time_window' &&
|
||
Number(active[throughIndex].value) < rule.placeDays)) &&
|
||
compareObjectiveLists(bestVector.objectives.slice(1, throughIndex + 2), baseline) === 0
|
||
) {
|
||
for (const balance of result.balances) {
|
||
if (balance.remaining <= 0) continue;
|
||
for (const date of result.placements.keys()) {
|
||
yield;
|
||
if (date < balance.availableFrom || date > balance.expiryDate) continue;
|
||
const sourceRuleApplies =
|
||
rule?.type !== 'time_window' ||
|
||
(date >= rule.startDate! &&
|
||
date <= rule.endDate! &&
|
||
selectorMatches(rule.placeType, balance.type));
|
||
yield* relocateForAddition(
|
||
result,
|
||
context,
|
||
date,
|
||
balance.id,
|
||
rule && sourceRuleApplies ? new Set([rule.id]) : new Set(),
|
||
false,
|
||
(candidate) => {
|
||
if (rule?.type === 'optimization' && !isAtRisk(balance, context)) {
|
||
beforeCalendar ??= optimizationObservations(result, context);
|
||
candidate.placements.set(date, {
|
||
...candidate.placements.get(date)!,
|
||
supporting: supportingCalendarBenefit(
|
||
beforeCalendar,
|
||
candidate,
|
||
context,
|
||
),
|
||
});
|
||
}
|
||
return consider(candidate, true);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
}
|
||
if (best === result) {
|
||
// Admit only a completed fixed point, after both bundles and the generic scan.
|
||
if (genericStateKey !== undefined) context.completedGenericState = genericStateKey;
|
||
return result;
|
||
}
|
||
best = reconcileOptimizationProvenance(best, context, result, bestVector.objectives);
|
||
context.retain?.(best);
|
||
result = best;
|
||
}
|
||
}
|
||
|
||
function associateCalendarPreference(
|
||
state: State,
|
||
rule: NormalizedCalendarRule,
|
||
context: SearchContext,
|
||
): State {
|
||
const result = cloneState(state);
|
||
const dates =
|
||
rule.type === 'time_window'
|
||
? [...result.placements.values()]
|
||
.filter(
|
||
(placement) =>
|
||
placement.date >= rule.startDate! &&
|
||
placement.date <= rule.endDate! &&
|
||
selectorMatches(rule.placeType, placement.type),
|
||
)
|
||
.sort((a, b) => Number(b.ruleIds.has(rule.id)) - Number(a.ruleIds.has(rule.id)))
|
||
.slice(
|
||
0,
|
||
Math.max(0, rule.placeDays - declaredWindowDays(rule, context).length),
|
||
)
|
||
.map((placement) => placement.date)
|
||
: occurrences(rule, context).flatMap(
|
||
(occurrence) =>
|
||
selectedOccurrencePattern(rule, occurrence, result, context) ?? [],
|
||
);
|
||
for (const date of dates) {
|
||
const placement = result.placements.get(date);
|
||
if (!placement) continue;
|
||
placement.ruleIds.add(rule.id);
|
||
placement.supporting = false;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/** Complete the entire typed pattern before checking the ordered objectives. */
|
||
function* preferencePatternVariants(
|
||
state: State,
|
||
pattern: string[],
|
||
rule: NormalizedCalendarRule,
|
||
context: SearchContext,
|
||
): PlanningSteps<State[]> {
|
||
let variants = [state];
|
||
for (const date of pattern) {
|
||
yield;
|
||
const next: State[] = [];
|
||
for (const current of variants) {
|
||
yield;
|
||
const existing = current.placements.get(date);
|
||
if (!existing || selectorMatches(rule.placeType, existing.type)) {
|
||
if (declaredPatternDay(date, rule.placeType, context)) next.push(current);
|
||
else
|
||
next.push(
|
||
...assignDateVariants(current, date, rule.placeType, rule.id, context),
|
||
);
|
||
continue;
|
||
}
|
||
const without = releasePlacement(current, date);
|
||
for (const balance of without.balances) {
|
||
yield;
|
||
if (!selectorMatches(rule.placeType, balance.type)) continue;
|
||
const reassigned = assignDateToBalance(
|
||
without,
|
||
date,
|
||
balance.id,
|
||
context,
|
||
new Set([rule.id]),
|
||
);
|
||
if (reassigned) next.push(reassigned);
|
||
}
|
||
for (const other of without.placements.values()) {
|
||
yield;
|
||
if (pattern.includes(other.date) || !selectorMatches(rule.placeType, other.type))
|
||
continue;
|
||
const released = releasePlacement(without, other.date);
|
||
const first = assignDateToBalance(
|
||
released,
|
||
date,
|
||
other.balanceId,
|
||
context,
|
||
new Set([...other.ruleIds, rule.id]),
|
||
);
|
||
const swapped =
|
||
first &&
|
||
assignDateToBalance(
|
||
first,
|
||
other.date,
|
||
existing.balanceId,
|
||
context,
|
||
existing.ruleIds,
|
||
);
|
||
if (!swapped) continue;
|
||
swapped.placements.get(date)!.supporting = other.supporting;
|
||
swapped.placements.get(other.date)!.supporting = existing.supporting;
|
||
next.push(swapped);
|
||
}
|
||
}
|
||
variants = next;
|
||
if (!variants.length) break;
|
||
}
|
||
return variants;
|
||
}
|
||
|
||
function* preferenceIfThen(
|
||
state: State,
|
||
rule: NormalizedCalendarRule,
|
||
context: SearchContext,
|
||
preferenceIndex: number,
|
||
): PlanningSteps<{ state: State; count: number }> {
|
||
const matchingOccurrences = occurrences(rule, context);
|
||
let bestState = state;
|
||
let bestCount = 0;
|
||
const baseline = activeObjectiveVector(state, context, preferenceIndex);
|
||
const search = function* (index: number, current: State, count: number): PlanningSteps<void> {
|
||
yield;
|
||
context.check();
|
||
const maximumCount = count + matchingOccurrences.length - index;
|
||
if (
|
||
maximumCount < bestCount &&
|
||
baseline.slice(0, preferenceIndex).every((objective) => {
|
||
if (objective.direction === 'minimize') return objective.value === 0;
|
||
const earlier = context.rules.find(
|
||
(item) => `preference:${item.id}` === objective.id,
|
||
);
|
||
return (
|
||
earlier &&
|
||
objective.value ===
|
||
(earlier.type === 'time_window'
|
||
? earlier.placeDays
|
||
: occurrences(earlier, context).length)
|
||
);
|
||
})
|
||
) {
|
||
return;
|
||
}
|
||
if (index >= matchingOccurrences.length) {
|
||
const finalized = finalizeRuleCandidate(current, context, state);
|
||
if (!finalized) {
|
||
return;
|
||
}
|
||
if (
|
||
unsatisfiedObligationCount(finalized, context) ||
|
||
compareObjectiveLists(
|
||
activeObjectiveVector(finalized, context, preferenceIndex),
|
||
baseline,
|
||
) > 0
|
||
)
|
||
return;
|
||
context.retain?.(finalized);
|
||
const better =
|
||
compareObligationPreviews({ state: finalized }, { state: bestState }, context) < 0;
|
||
if (better) {
|
||
bestCount = count;
|
||
bestState = finalized;
|
||
}
|
||
return;
|
||
}
|
||
const occurrence = matchingOccurrences[index];
|
||
const patterns = [...patternsFor(rule, occurrence, context)].sort(
|
||
(a, b) =>
|
||
patternConflictScore(rule.placeType, a, context) -
|
||
patternConflictScore(rule.placeType, b, context) || compareDateLists(a, b),
|
||
);
|
||
for (const pattern of patterns) {
|
||
yield;
|
||
const variants = yield* preferencePatternVariants(current, pattern, rule, context);
|
||
for (const variant of variants) {
|
||
yield;
|
||
const finalized = finalizeEarnedState(
|
||
addEarnedBalance(variant, rule, occurrence, pattern, context),
|
||
context,
|
||
);
|
||
if (!finalized) continue;
|
||
yield* search(index + 1, finalized, count + 1);
|
||
}
|
||
}
|
||
// A preference may leave an occurrence unplaced. Exploring this branch after
|
||
// complete patterns keeps the result deterministic while still maximizing the
|
||
// number of complete occurrences.
|
||
yield* search(index + 1, current, count);
|
||
};
|
||
yield* search(0, state, 0);
|
||
context.retain?.(bestState);
|
||
return { state: bestState, count: bestCount };
|
||
}
|
||
|
||
function preferenceDateConflictScore(
|
||
selector: LeaveSelector,
|
||
date: string,
|
||
context: SearchContext,
|
||
): number {
|
||
const protectedTypes = context.protectedTypesByDate.get(date);
|
||
const demand = context.preferenceDemandByDate.get(date) ?? 0;
|
||
const anyDemand = context.anyPreferenceDemandByDate.get(date) ?? 0;
|
||
if (!protectedTypes?.size && !demand) return 0;
|
||
// An ANY preference should prefer a date protected by a later preference:
|
||
// sharing one compatible placement lets both lexicographic criteria win.
|
||
if (selector === 'any') return -(demand * 2 + (protectedTypes?.size ?? 0));
|
||
if (protectedTypes?.has(selector)) return -(demand * 2 + 1);
|
||
// An untyped future preference can share any leave type, while a typed
|
||
// future preference for another type must not be allowed to steal this slot.
|
||
if (protectedTypes?.size) return anyDemand ? -anyDemand : protectedTypes.size;
|
||
return demand ? -demand : 0;
|
||
}
|
||
|
||
function patternConflictScore(
|
||
selector: LeaveSelector,
|
||
pattern: string[],
|
||
context: SearchContext,
|
||
): number {
|
||
return pattern.reduce(
|
||
(score, date) => score + preferenceDateConflictScore(selector, date, context),
|
||
0,
|
||
);
|
||
}
|
||
|
||
function preferenceReason(
|
||
rule: NormalizedCalendarRule,
|
||
count: number,
|
||
context: SearchContext,
|
||
state: State,
|
||
): PlanningReason | undefined {
|
||
const requested = rule.type === 'if_then' ? occurrences(rule, context).length : rule.placeDays;
|
||
if (count >= requested) return undefined;
|
||
|
||
const candidateDates =
|
||
rule.type === 'time_window'
|
||
? context.workingDays.filter(
|
||
(date) =>
|
||
!context.declaredDates.has(date) &&
|
||
compareDate(date, rule.startDate!) >= 0 &&
|
||
compareDate(date, rule.endDate!) <= 0,
|
||
)
|
||
: occurrences(rule, context).flatMap((occurrence) =>
|
||
patternsFor(rule, occurrence, context).flat(),
|
||
);
|
||
if (!candidateDates.length)
|
||
return reason(
|
||
'horizon',
|
||
`Preference "${rule.name}" has no placeable date in the horizon.`,
|
||
[rule.id],
|
||
);
|
||
|
||
// Include generated balances: an earned entitlement is a real source for
|
||
// later preferences and must participate in both type and exhaustion reasons.
|
||
const matchingBalances = state.balances.filter((balance) =>
|
||
selectorMatches(rule.placeType, balance.type),
|
||
);
|
||
const availableBalances = matchingBalances.filter((balance) =>
|
||
candidateDates.some(
|
||
(date) =>
|
||
compareDate(balance.availableFrom, date) <= 0 &&
|
||
compareDate(date, balance.expiryDate) <= 0,
|
||
),
|
||
);
|
||
const currentRuleIndex = context.rules.findIndex((item) => item.id === rule.id);
|
||
const consumedByHigherPriority = matchingBalances.some(
|
||
(balance) =>
|
||
balance.remaining === 0 &&
|
||
[...state.placements.values()].some(
|
||
(placement) =>
|
||
placement.balanceId === balance.id &&
|
||
[...placement.ruleIds].some((id) => {
|
||
const index = context.rules.findIndex((item) => item.id === id);
|
||
return (
|
||
index >= 0 &&
|
||
(context.rules[index].force === 'obligation' ||
|
||
index < currentRuleIndex)
|
||
);
|
||
}),
|
||
),
|
||
);
|
||
const freeEligibleDate = candidateDates.some(
|
||
(date) =>
|
||
!state.placements.has(date) &&
|
||
state.balances.some(
|
||
(balance) =>
|
||
balance.remaining > 0 &&
|
||
selectorMatches(rule.placeType, balance.type) &&
|
||
compareDate(balance.availableFrom, date) <= 0 &&
|
||
compareDate(date, balance.expiryDate) <= 0,
|
||
),
|
||
);
|
||
const compatibleExisting = candidateDates.some((date) => {
|
||
const placement = state.placements.get(date);
|
||
return placement && selectorMatches(rule.placeType, placement.type);
|
||
});
|
||
const blockedByHigherPriority = candidateDates.some((date) => {
|
||
const placement = state.placements.get(date);
|
||
return (
|
||
placement &&
|
||
!selectorMatches(rule.placeType, placement.type) &&
|
||
[...placement.ruleIds].some((id) => {
|
||
const index = context.rules.findIndex((item) => item.id === id);
|
||
return (
|
||
index >= 0 &&
|
||
(context.rules[index].force === 'obligation' || index < currentRuleIndex)
|
||
);
|
||
})
|
||
);
|
||
});
|
||
let code: string;
|
||
if (blockedByHigherPriority && !freeEligibleDate && !compatibleExisting)
|
||
code = 'higher-priority';
|
||
else if (!matchingBalances.length && !compatibleExisting) code = 'type';
|
||
else if (!availableBalances.length) code = 'availability';
|
||
else if (consumedByHigherPriority && !freeEligibleDate && !compatibleExisting)
|
||
code = 'higher-priority';
|
||
else code = 'capacity';
|
||
return reason(code, `Preference "${rule.name}" could not be completely satisfied.`, [rule.id]);
|
||
}
|
||
|
||
function* applyPreferenceRules(
|
||
state: State,
|
||
preferenceRules: NormalizedRule[],
|
||
context: SearchContext,
|
||
): PlanningSteps<{ state: State; preferences: PreferenceResult[] }> {
|
||
let result = state;
|
||
const preferences: PreferenceResult[] = [];
|
||
for (let preferenceIndex = 0; preferenceIndex < preferenceRules.length; preferenceIndex++) {
|
||
yield;
|
||
result = yield* applyPreferenceStep(
|
||
result,
|
||
preferenceRules,
|
||
preferenceIndex,
|
||
preferences,
|
||
context,
|
||
);
|
||
}
|
||
return { state: result, preferences };
|
||
}
|
||
|
||
function* applyPreferenceStep(
|
||
state: State,
|
||
preferenceRules: NormalizedRule[],
|
||
preferenceIndex: number,
|
||
preferences: PreferenceResult[],
|
||
context: SearchContext,
|
||
): PlanningSteps<State> {
|
||
let result = state;
|
||
const apply = function* (index: number): PlanningSteps<void> {
|
||
const rule = preferenceRules[index];
|
||
context.protectedTypesByDate = futurePreferenceProtection(
|
||
preferenceRules,
|
||
index + 1,
|
||
context,
|
||
);
|
||
if (context.completedPreferenceStates.get(index) === preferenceStateKey(result)) {
|
||
return;
|
||
}
|
||
if (rule.type === 'optimization')
|
||
result = yield* optimizeRulePlacements(result, context, index, true, rule);
|
||
else if (rule.type === 'time_window')
|
||
result = associateCalendarPreference(
|
||
yield* optimizeRulePlacements(result, context, index, true, rule),
|
||
rule,
|
||
context,
|
||
);
|
||
else
|
||
result = associateCalendarPreference(
|
||
(yield* preferenceIfThen(result, rule, context, index)).state,
|
||
rule,
|
||
context,
|
||
);
|
||
context.completedPreferenceStates.set(index, preferenceStateKey(result));
|
||
};
|
||
yield* apply(preferenceIndex);
|
||
// An earned entitlement produced later can satisfy an earlier preference.
|
||
let previous: PlanningObjective[];
|
||
do {
|
||
previous = activeObjectiveVector(result, context);
|
||
const entitlements = result.balances
|
||
.map(
|
||
({ id, quantity, availableFrom, expiryDate }) =>
|
||
`${id}:${quantity}:${availableFrom}:${expiryDate}`,
|
||
)
|
||
.join('|');
|
||
for (let index = 0; index < preferenceIndex; index++) {
|
||
yield;
|
||
yield* apply(index);
|
||
}
|
||
if (
|
||
compareObjectiveLists(activeObjectiveVector(result, context), previous) < 0 ||
|
||
result.balances
|
||
.map(
|
||
({ id, quantity, availableFrom, expiryDate }) =>
|
||
`${id}:${quantity}:${availableFrom}:${expiryDate}`,
|
||
)
|
||
.join('|') !== entitlements
|
||
)
|
||
yield* apply(preferenceIndex);
|
||
} while (compareObjectiveLists(activeObjectiveVector(result, context), previous) < 0);
|
||
preferences.splice(
|
||
0,
|
||
preferences.length,
|
||
...preferenceResultsForState(result, context).map((preference) => ({
|
||
...preference,
|
||
reason: preferenceReason(
|
||
context.rules.find(
|
||
(rule): rule is NormalizedCalendarRule =>
|
||
rule.type !== 'optimization' && rule.id === preference.ruleId,
|
||
)!,
|
||
preference.satisfied,
|
||
context,
|
||
result,
|
||
),
|
||
})),
|
||
);
|
||
return result;
|
||
}
|
||
|
||
function preferenceStateKey(state: State): string {
|
||
// Preserve iteration order: a coordinated allocator may keep its first valid branch.
|
||
return JSON.stringify([
|
||
state.balances,
|
||
[...state.placements].map(([date, placement]) => [
|
||
date,
|
||
{ ...placement, ruleIds: [...placement.ruleIds] },
|
||
]),
|
||
state.earnedActivations,
|
||
]);
|
||
}
|
||
|
||
function activeObjectiveVector(
|
||
state: State,
|
||
context: SearchContext,
|
||
throughIndex = Number.POSITIVE_INFINITY,
|
||
): PlanningObjective[] {
|
||
return [...activeObjectives(state, context, throughIndex)];
|
||
}
|
||
|
||
function* activeObjectives(
|
||
state: State,
|
||
context: SearchContext,
|
||
throughIndex = Number.POSITIVE_INFINITY,
|
||
): Generator<PlanningObjective> {
|
||
let preferences: PreferenceResult[] | undefined;
|
||
let balances: OptimizationObservations | undefined;
|
||
let calendar: OptimizationObservations | undefined;
|
||
let index = 0;
|
||
for (const rule of context.rules) {
|
||
if (!rule.enabled || rule.force !== 'preference') continue;
|
||
if (index++ > throughIndex) return;
|
||
if (rule.type === 'optimization') {
|
||
const observations =
|
||
rule.configuration.elements === 'leave-balances'
|
||
? (balances ??= {
|
||
leaveBalances: optimizationBalanceObservations(state, context),
|
||
publicHolidayWeeks: [],
|
||
timeOffPeriods: [],
|
||
workSequences: [],
|
||
leavePlacements: [],
|
||
})
|
||
: (calendar ??= optimizationObservations(state, context));
|
||
yield {
|
||
id: `optimization:${rule.id}`,
|
||
direction: rule.configuration.direction,
|
||
value: evaluateStateOptimization(rule.configuration, state, context, observations),
|
||
label: rule.name,
|
||
};
|
||
} else {
|
||
preferences ??= preferenceResultsForState(state, context);
|
||
yield {
|
||
id: `preference:${rule.id}`,
|
||
direction: 'maximize',
|
||
value:
|
||
preferences.find((preference) => preference.ruleId === rule.id)?.satisfied ?? 0,
|
||
label: rule.name,
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
function tieBreakObjectives(state: State, context: SearchContext): PlanningObjective[] {
|
||
return [
|
||
{
|
||
id: 'balance-expiry',
|
||
direction: 'minimize',
|
||
value: expiryTieKey(state),
|
||
label: 'Balance expiry tie-break',
|
||
},
|
||
{
|
||
id: 'placement-date',
|
||
direction: 'minimize',
|
||
value: placementTieKey(state, context),
|
||
label: 'Placement date tie-break',
|
||
},
|
||
];
|
||
}
|
||
|
||
function obligationPreviewObjectives(
|
||
preview: { state: State },
|
||
context: SearchContext,
|
||
): PlanningObjective[] {
|
||
return [
|
||
...activeObjectiveVector(preview.state, context),
|
||
...tieBreakObjectives(preview.state, context),
|
||
];
|
||
}
|
||
|
||
/** Compare complete previews so obligation DFS does not commit to its first feasible branch. */
|
||
function compareObligationPreviews(
|
||
a: { state: State },
|
||
b: { state: State },
|
||
context: SearchContext,
|
||
): number {
|
||
return compareObjectiveLists(
|
||
obligationPreviewObjectives(a, context),
|
||
obligationPreviewObjectives(b, context),
|
||
);
|
||
}
|
||
|
||
function qualityMetrics(
|
||
state: State,
|
||
context: SearchContext,
|
||
preferences: readonly PreferenceResult[],
|
||
): PlanningMetrics {
|
||
const periods = periodData(state, context);
|
||
const observations = optimizationObservations(state, context, periods);
|
||
const nonEmptySequences = observations.workSequences.filter((sequence) => sequence.length > 0);
|
||
return {
|
||
unplacedAtRisk: evaluateStateOptimization(
|
||
OPTIMIZATION_PRESETS.unplacedAtRisk,
|
||
state,
|
||
context,
|
||
observations,
|
||
),
|
||
holidayWeekValue: evaluateStateOptimization(
|
||
OPTIMIZATION_PRESETS.holidayWeekValue,
|
||
state,
|
||
context,
|
||
observations,
|
||
),
|
||
nonWorkingDayGain: evaluateStateOptimization(
|
||
OPTIMIZATION_PRESETS.nonWorkingDayGain,
|
||
state,
|
||
context,
|
||
observations,
|
||
),
|
||
longestWorkSequence: evaluateStateOptimization(
|
||
OPTIMIZATION_PRESETS.longestWorkSequence,
|
||
state,
|
||
context,
|
||
observations,
|
||
),
|
||
averageWorkSequence: nonEmptySequences.length
|
||
? nonEmptySequences.reduce((sum, sequence) => sum + sequence.length, 0) /
|
||
nonEmptySequences.length
|
||
: null,
|
||
averageTimeOffPeriod: periods.length
|
||
? periods.reduce((sum, period) => sum + period.duration, 0) / periods.length
|
||
: null,
|
||
longestTimeOffPeriod: periods.length
|
||
? Math.max(...periods.map((period) => period.duration))
|
||
: null,
|
||
shortSequenceDeficit: evaluateStateOptimization(
|
||
OPTIMIZATION_PRESETS.shortSequenceDeficit,
|
||
state,
|
||
context,
|
||
observations,
|
||
),
|
||
supportingPlacements: evaluateStateOptimization(
|
||
OPTIMIZATION_PRESETS.supportingPlacements,
|
||
state,
|
||
context,
|
||
observations,
|
||
),
|
||
preferenceSatisfaction: preferences.map((preference) => preference.satisfied),
|
||
};
|
||
}
|
||
|
||
function nonPlacementReason(
|
||
balance: NormalizedBalance,
|
||
state: State,
|
||
context: SearchContext,
|
||
completed: boolean,
|
||
): PlanningReason {
|
||
if (balance.expiryDate < context.horizon.start)
|
||
return reason('unavoidable-expiry', 'This balance expired before the planning horizon.');
|
||
if (balance.availableFrom >= context.horizon.end)
|
||
return reason(
|
||
'available-after-horizon',
|
||
'This balance becomes available after the last planning date.',
|
||
);
|
||
const dates = context.workingDays.filter(
|
||
(date) => date >= balance.availableFrom && date <= balance.expiryDate,
|
||
);
|
||
if (!dates.length)
|
||
return reason(
|
||
'no-working-date',
|
||
'There is no working date within both the horizon and this balance’s validity period.',
|
||
);
|
||
if (dates.every((date) => context.declaredDates.has(date) || state.placements.has(date)))
|
||
return reason(
|
||
'all-dates-occupied',
|
||
'Every working date within this balance’s validity period is already occupied in the displayed plan.',
|
||
);
|
||
if (!completed)
|
||
return reason(
|
||
'calculation-incomplete',
|
||
'The calculation stopped before completing its planning priorities.',
|
||
);
|
||
if (balance.expiryDate > context.horizon.end)
|
||
return reason(
|
||
'preserved',
|
||
'This balance expires after the horizon; the planner retained no additional placement under its planning priorities.',
|
||
);
|
||
return reason(
|
||
'active-priorities',
|
||
'The calculation retained no additional placement under the active planning rules.',
|
||
);
|
||
}
|
||
|
||
function remainingBalances(
|
||
state: State,
|
||
context: SearchContext,
|
||
completed: boolean,
|
||
): RemainingBalance[] {
|
||
return state.balances
|
||
.filter((balance) => balance.remaining > 0)
|
||
.map((balance) => {
|
||
const classification: RemainingClassification =
|
||
compareDate(balance.expiryDate, context.horizon.start) < 0
|
||
? 'unavoidable-expiry'
|
||
: compareDate(balance.expiryDate, context.horizon.end) <= 0
|
||
? 'at-risk'
|
||
: 'deferred';
|
||
return {
|
||
id: balance.id,
|
||
type: balance.type,
|
||
quantity: balance.remaining,
|
||
availableFrom: balance.availableFrom,
|
||
expiryDate: balance.expiryDate,
|
||
classification,
|
||
reason: nonPlacementReason(balance, state, context, completed),
|
||
earned: balance.earned,
|
||
sourceRuleId: balance.sourceRuleId,
|
||
};
|
||
});
|
||
}
|
||
|
||
function unsatisfiedObligationCount(state: State, context: SearchContext): number {
|
||
const placementCounts = new Map<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.requiredPlacements)
|
||
unsatisfied++;
|
||
continue;
|
||
}
|
||
let complete = false;
|
||
for (const pattern of requirement.patterns) {
|
||
if (
|
||
pattern.every((date) =>
|
||
patternDateSatisfied(date, requirement.rule, state, context),
|
||
)
|
||
) {
|
||
complete = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!complete) unsatisfied++;
|
||
}
|
||
return unsatisfied;
|
||
}
|
||
|
||
function objectiveVector(
|
||
state: State,
|
||
context: SearchContext,
|
||
active = activeObjectiveVector(state, context),
|
||
): PlanningObjectiveVector {
|
||
return {
|
||
objectives: [
|
||
{
|
||
id: 'obligation-validity',
|
||
direction: 'minimize',
|
||
value: unsatisfiedObligationCount(state, context),
|
||
label: 'Unsatisfied obligations',
|
||
},
|
||
...active,
|
||
...tieBreakObjectives(state, context),
|
||
],
|
||
};
|
||
}
|
||
|
||
function displayLabel(value: unknown, fallback: string): string {
|
||
return typeof value === 'string' && value.trim() ? value : fallback;
|
||
}
|
||
|
||
function timeWindowContributions(state: State, context: SearchContext): TimeWindowContribution[] {
|
||
if (!context.declaredDays.length) return [];
|
||
const labels = recordValue(context.request.labels) as Record<string, unknown> | undefined;
|
||
const identities = recordValue(context.request.identities) as
|
||
| Record<string, unknown>
|
||
| undefined;
|
||
return context.rules
|
||
.filter(
|
||
(rule): rule is NormalizedCalendarRule => rule.enabled && rule.type === 'time_window',
|
||
)
|
||
.flatMap((rule) => {
|
||
const declaredDays = declaredWindowDays(rule, context);
|
||
return declaredDays.length
|
||
? [
|
||
{
|
||
ruleId: rule.id,
|
||
label: displayLabel(
|
||
labels?.[rule.id] ?? identities?.[rule.id] ?? rule.name,
|
||
rule.id,
|
||
),
|
||
requested: rule.placeDays,
|
||
declaredDays,
|
||
placementDates: [...state.placements.values()]
|
||
.filter((placement) => placement.ruleIds.has(rule.id))
|
||
.map(({ date }) => date)
|
||
.sort(compareDate),
|
||
},
|
||
]
|
||
: [];
|
||
});
|
||
}
|
||
|
||
function ifThenContributions(state: State, context: SearchContext): IfThenContribution[] {
|
||
const labels = recordValue(context.request.labels) as Record<string, unknown> | undefined;
|
||
const identities = recordValue(context.request.identities) as
|
||
| Record<string, unknown>
|
||
| undefined;
|
||
return context.rules
|
||
.filter((rule): rule is NormalizedCalendarRule => rule.enabled && rule.type === 'if_then')
|
||
.flatMap((rule) =>
|
||
occurrences(rule, context).flatMap((occurrence) => {
|
||
const id = `earned:${rule.id}:${occurrence}`;
|
||
const activation = state.earnedActivations.find((item) => item.id === id);
|
||
const pattern = selectedOccurrencePattern(rule, occurrence, state, context);
|
||
if (!pattern) return [];
|
||
const declaredDays = pattern.flatMap((date) => {
|
||
const day = declaredPatternDay(date, rule.placeType, context);
|
||
return day ? [day] : [];
|
||
});
|
||
if (!declaredDays.length && !activation) return [];
|
||
const bounds = activation
|
||
? earnedPeriodBounds(state, activation, context)
|
||
: undefined;
|
||
return [
|
||
{
|
||
ruleId: rule.id,
|
||
label: displayLabel(
|
||
labels?.[rule.id] ?? identities?.[rule.id] ?? rule.name,
|
||
rule.id,
|
||
),
|
||
occurrence,
|
||
requested: rule.placeDays,
|
||
declaredDays,
|
||
placementDates: pattern.filter((date) => !context.declaredDates.has(date)),
|
||
...(activation && bounds
|
||
? {
|
||
earnedEntitlement: {
|
||
id,
|
||
type: rule.rewardType!,
|
||
quantity: rule.rewardDays!,
|
||
...bounds,
|
||
added:
|
||
bounds.availableFrom > context.request.calculationDate,
|
||
},
|
||
}
|
||
: {}),
|
||
},
|
||
];
|
||
}),
|
||
);
|
||
}
|
||
|
||
function planExplanation(
|
||
state: State,
|
||
context: SearchContext,
|
||
placements: readonly LeavePlacement[],
|
||
periods: readonly TimeOffPeriod[],
|
||
preferences: readonly PreferenceResult[],
|
||
remaining: readonly RemainingBalance[],
|
||
metrics: PlanningMetrics,
|
||
): PlanExplanation {
|
||
const source = context.request;
|
||
const rawBalances = Array.isArray(source.balances) ? source.balances : [];
|
||
const rawRules = Array.isArray(source.rules) ? source.rules : [];
|
||
const labels = recordValue(source.labels) as Record<string, unknown> | undefined;
|
||
const identities = recordValue(source.identities) as Record<string, unknown> | undefined;
|
||
const vector = objectiveVector(state, context);
|
||
const windowContributions = timeWindowContributions(state, context);
|
||
const patternContributions = ifThenContributions(state, context);
|
||
return {
|
||
kind: 'PlanExplanation',
|
||
calculationDate: context.request.calculationDate,
|
||
...(context.declaredDays.length ? { declaredDays: context.declaredDays } : {}),
|
||
timeOffPeriods: periods,
|
||
...(windowContributions.length ? { timeWindowContributions: windowContributions } : {}),
|
||
...(patternContributions.length ? { ifThenContributions: patternContributions } : {}),
|
||
placements: placements.map((placement) => {
|
||
const balance = rawBalances
|
||
.map(recordValue)
|
||
.find((candidate) => candidate?.id === placement.balanceId);
|
||
const normalizedBalance = state.balances.find(
|
||
(candidate) => candidate.id === placement.balanceId,
|
||
);
|
||
const period = periods.find((candidate) =>
|
||
candidate.placementDates.includes(placement.date),
|
||
);
|
||
const sourceRules = placement.ruleIds.map((id) => {
|
||
const rule = context.rules.find((candidate) => candidate.id === id);
|
||
const rawRule = rawRules.map(recordValue).find((candidate) => candidate?.id === id);
|
||
const label = displayLabel(
|
||
labels?.[id] ?? identities?.[id] ?? rawRule?.name ?? rule?.name,
|
||
id,
|
||
);
|
||
return { id, label };
|
||
});
|
||
const atRisk = normalizedBalance
|
||
? compareDate(normalizedBalance.expiryDate, context.horizon.start) >= 0 &&
|
||
compareDate(normalizedBalance.expiryDate, context.horizon.end) <= 0
|
||
: false;
|
||
const withoutPlacement = cloneState(state);
|
||
notePlacementRemoved(state.placements, withoutPlacement.placements, placement.date);
|
||
withoutPlacement.placements.delete(placement.date);
|
||
const released = withoutPlacement.balances.find(
|
||
(candidate) => candidate.id === placement.balanceId,
|
||
);
|
||
if (released) released.remaining++;
|
||
const comparison = objectiveComparison(
|
||
objectiveVector(withoutPlacement, context),
|
||
vector,
|
||
);
|
||
const contributions: PlacementContribution[] = [
|
||
{
|
||
kind: 'entitlement',
|
||
code: 'balance',
|
||
label: displayLabel(
|
||
labels?.[placement.balanceId] ??
|
||
identities?.[placement.balanceId] ??
|
||
balance?.label ??
|
||
balance?.name ??
|
||
balance?.id,
|
||
placement.balanceId,
|
||
),
|
||
},
|
||
...sourceRules.map((rule) => ({
|
||
kind: 'rule' as const,
|
||
code: 'rule',
|
||
label: rule.label,
|
||
ruleId: rule.id,
|
||
})),
|
||
];
|
||
if (atRisk)
|
||
contributions.push({
|
||
kind: 'at-risk',
|
||
code: 'at-risk',
|
||
label: 'Protects leave at risk of expiry.',
|
||
});
|
||
if (placement.supporting)
|
||
contributions.push({
|
||
kind: 'supporting',
|
||
code: 'supporting',
|
||
label: 'Supports the surrounding time-off plan.',
|
||
});
|
||
if (period)
|
||
contributions.push({
|
||
kind: 'time-off-period',
|
||
code: 'time-off-period',
|
||
label: `Contributes to the time-off period ${period.start}–${period.end}.`,
|
||
dates: period.nonWorkingDates,
|
||
});
|
||
for (const delta of comparison.delta) {
|
||
if (typeof delta.value !== 'number' || delta.value === 0) continue;
|
||
const objective = vector.objectives.find((candidate) => candidate.id === delta.id)!;
|
||
contributions.push({
|
||
kind: 'metric',
|
||
code: objective.id,
|
||
label: objective.label ?? objective.id,
|
||
value: delta.value,
|
||
});
|
||
}
|
||
const balanceSnapshot: Readonly<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, stages.includes('dates'));
|
||
const windowContributions = timeWindowContributions(state, context);
|
||
const patternContributions = ifThenContributions(state, context);
|
||
const plan: PlanOutcome = {
|
||
kind: 'Plan',
|
||
calculationDate: context.request.calculationDate,
|
||
...(context.declaredDays.length ? { declaredDays: context.declaredDays } : {}),
|
||
...(windowContributions.length ? { timeWindowContributions: windowContributions } : {}),
|
||
...(patternContributions.length ? { ifThenContributions: patternContributions } : {}),
|
||
horizon: context.horizon,
|
||
placements,
|
||
timeOffPeriods: periods,
|
||
preferences,
|
||
remainingBalances: remaining,
|
||
metrics,
|
||
completedStages: [...stages],
|
||
lastCompletedStage: stages[stages.length - 1] ?? 'validation',
|
||
};
|
||
context.retain?.(state);
|
||
return plan;
|
||
}
|
||
|
||
function markProgress(context: SearchContext, stage: string): void {
|
||
if (typeof context.request.progress === 'function')
|
||
observeCallback(context, () =>
|
||
(context.request.progress as (stage: string) => void)(stage),
|
||
);
|
||
}
|
||
|
||
function ruleStage(rule: NormalizedRule): string {
|
||
return `${rule.type === 'optimization' ? 'optimization' : 'preference'}:${rule.id}`;
|
||
}
|
||
|
||
function planningStages(context: SearchContext): string[] {
|
||
return [
|
||
'validation',
|
||
'obligations',
|
||
...context.rules
|
||
.filter((rule) => rule.enabled && rule.force === 'preference')
|
||
.map(ruleStage),
|
||
'expiry',
|
||
'dates',
|
||
];
|
||
}
|
||
|
||
function conflictExplanation(
|
||
outcome: PlanningConflictOutcome,
|
||
context: SearchContext,
|
||
): ConflictExplanation {
|
||
const labels = recordValue(context.request.labels) as Record<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 is NormalizedCalendarRule =>
|
||
rule.enabled &&
|
||
rule.type === 'if_then' &&
|
||
(rule.rewardDays ?? 0) > 0 &&
|
||
!!rule.rewardType,
|
||
)
|
||
.map((rule) => rule.rewardType!),
|
||
);
|
||
let generatedCapacityBottleneck = false;
|
||
for (const requirement of requirements) {
|
||
if (requirement.rule.placeType === 'any') continue;
|
||
const required = requirement.requiredPlacements;
|
||
const capacity = context.initialBalances
|
||
.filter((balance) => balance.type === requirement.rule.placeType)
|
||
.reduce((sum, balance) => sum + balance.quantity, 0);
|
||
if (capacity < required) {
|
||
if (!generatedTypes.has(requirement.rule.placeType))
|
||
return {
|
||
kind: 'PlanningConflict',
|
||
conflictingRuleIds: ruleIds,
|
||
reasons: [
|
||
reason(
|
||
'typed-capacity',
|
||
`The obligations require more ${requirement.rule.placeType.toUpperCase()} leave than is available.`,
|
||
ruleIds,
|
||
),
|
||
],
|
||
};
|
||
generatedCapacityBottleneck = true;
|
||
}
|
||
}
|
||
if (
|
||
requirements.some(
|
||
(requirement) =>
|
||
(requirement.patterns !== undefined && requirement.patterns.length === 0) ||
|
||
(requirement.candidates !== undefined &&
|
||
requirement.requiredPlacements > 0 &&
|
||
requirement.candidates.length === 0),
|
||
)
|
||
)
|
||
return {
|
||
kind: 'PlanningConflict',
|
||
conflictingRuleIds: ruleIds,
|
||
reasons: [
|
||
reason(
|
||
'horizon-bottleneck',
|
||
'The planning horizon contains no eligible dates for at least one obligation.',
|
||
ruleIds,
|
||
),
|
||
],
|
||
};
|
||
if (generatedCapacityBottleneck)
|
||
return {
|
||
kind: 'PlanningConflict',
|
||
conflictingRuleIds: ruleIds,
|
||
reasons: [
|
||
reason(
|
||
'earned-entitlement-bottleneck',
|
||
'An earned leave entitlement is not available early enough to satisfy every obligation.',
|
||
ruleIds,
|
||
),
|
||
],
|
||
};
|
||
return {
|
||
kind: 'PlanningConflict',
|
||
conflictingRuleIds: ruleIds,
|
||
reasons: [
|
||
reason(
|
||
'joint-date-conflict',
|
||
'The obligation date patterns cannot be satisfied together.',
|
||
ruleIds,
|
||
),
|
||
],
|
||
};
|
||
}
|
||
|
||
export type PlanningCheckpoint = (plan: PlanOutcome, state?: PlanningStateSnapshot) => void;
|
||
export interface PlanningArtifacts {
|
||
explanation?: PlanningExplanation;
|
||
finalState?: PlanningStateSnapshot;
|
||
explanationError?: PlanningReason;
|
||
timings?: PlanningArtifactTimings;
|
||
}
|
||
export type PlanningArtifactsCallback = (artifacts: PlanningArtifacts) => void;
|
||
export type PlanningOutcomeCallback = (outcome: PlanningOutcome) => void;
|
||
export type PlanningExplanationCallback = (
|
||
explanation: PlanningExplanation | undefined,
|
||
explanationError: PlanningReason | undefined,
|
||
finalState: PlanningStateSnapshot | undefined,
|
||
) => void;
|
||
|
||
/** @internal Synchronous adapter retained for non-browser callers and deterministic checks. */
|
||
export function calculatePlanningInternal(
|
||
request: unknown,
|
||
checkpointCallback?: PlanningCheckpoint,
|
||
artifactsCallback?: PlanningArtifactsCallback,
|
||
outcomeCallback?: PlanningOutcomeCallback,
|
||
explanationCallback?: PlanningExplanationCallback,
|
||
): PlanningOutcome {
|
||
const steps = calculatePlanningSteps(
|
||
request,
|
||
checkpointCallback,
|
||
artifactsCallback,
|
||
outcomeCallback,
|
||
explanationCallback,
|
||
);
|
||
let next = steps.next();
|
||
while (!next.done) next = steps.next();
|
||
return next.value;
|
||
}
|
||
|
||
/** @internal Runs the same calculation while allowing worker messages between CPU slices. */
|
||
export async function calculatePlanningAsyncInternal(
|
||
request: unknown,
|
||
checkpointCallback?: PlanningCheckpoint,
|
||
artifactsCallback?: PlanningArtifactsCallback,
|
||
outcomeCallback?: PlanningOutcomeCallback,
|
||
explanationCallback?: PlanningExplanationCallback,
|
||
isCancelled: () => boolean = () => false,
|
||
): Promise<PlanningOutcome> {
|
||
const steps = calculatePlanningSteps(
|
||
request,
|
||
checkpointCallback,
|
||
artifactsCallback,
|
||
outcomeCallback,
|
||
explanationCallback,
|
||
true,
|
||
);
|
||
let next = steps.next();
|
||
let pauseAt = now() + 8;
|
||
while (!next.done) {
|
||
if (next.value || now() >= pauseAt) {
|
||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||
pauseAt = now() + 8;
|
||
}
|
||
next = isCancelled() ? steps.throw(new UserCancelled()) : steps.next();
|
||
}
|
||
return next.value;
|
||
}
|
||
|
||
function* prepareCalendar(
|
||
horizon: {
|
||
start: string;
|
||
end: string;
|
||
},
|
||
progress: () => void,
|
||
): PlanningSteps<PlanningCalendar> {
|
||
const start = addDays(horizon.start, -400);
|
||
const end = addDays(horizon.end, 400);
|
||
const holidays = new Set<string>();
|
||
for (let year = Number(start.slice(0, 4)); year <= Number(end.slice(0, 4)); year++) {
|
||
yield true;
|
||
for (const holiday of frenchPublicHolidaysForYear(year)) holidays.add(holiday);
|
||
progress();
|
||
}
|
||
const workingDays: string[] = [];
|
||
const calendarDays: string[] = [];
|
||
const calendarIndex = new Map<string, number>();
|
||
const workingCalendar = new Set<string>();
|
||
const workingPrefix = [0];
|
||
for (let date = start; compareDate(date, end) < 0; date = addDays(date, 1)) {
|
||
yield;
|
||
calendarIndex.set(date, calendarDays.length);
|
||
calendarDays.push(date);
|
||
const working = isWorking(date, holidays);
|
||
if (working) {
|
||
workingCalendar.add(date);
|
||
if (date >= horizon.start && date < horizon.end) workingDays.push(date);
|
||
}
|
||
workingPrefix.push(workingPrefix[workingPrefix.length - 1] + Number(working));
|
||
}
|
||
return { holidays, workingDays, calendarDays, calendarIndex, workingCalendar, workingPrefix };
|
||
}
|
||
|
||
function* calculatePlanningSteps(
|
||
request: unknown,
|
||
checkpointCallback?: PlanningCheckpoint,
|
||
artifactsCallback?: PlanningArtifactsCallback,
|
||
outcomeCallback?: PlanningOutcomeCallback,
|
||
explanationCallback?: PlanningExplanationCallback,
|
||
retainIncumbents = false,
|
||
): PlanningSteps<PlanningOutcome> {
|
||
const source = runtimeRequest(request);
|
||
const normalized = normalizeRequest(source);
|
||
if (normalized.reasons.length) {
|
||
const outcome = { ...invalidInputFor(source)!, calculationDate: source.calculationDate };
|
||
outcomeCallback?.(outcome);
|
||
artifactsCallback?.({});
|
||
return outcome;
|
||
}
|
||
const horizon = normalized.horizon!;
|
||
let calendar: PlanningCalendar;
|
||
try {
|
||
if (typeof source.progress === 'function') source.progress('preparation');
|
||
yield true;
|
||
calendar = yield* prepareCalendar(horizon, () => {
|
||
if (typeof source.progress === 'function') source.progress('preparation');
|
||
});
|
||
} catch (error) {
|
||
if (!(error instanceof UserCancelled)) throw error;
|
||
const outcome: PlanningIncompleteOutcome = {
|
||
kind: 'PlanningIncomplete',
|
||
calculationDate: source.calculationDate,
|
||
completedStages: ['validation'],
|
||
lastCompletedStage: 'validation',
|
||
reasons: [reason('user-cancelled', 'The calculation was cancelled by the user.')],
|
||
};
|
||
const explanation: IncompleteExplanation = {
|
||
kind: 'IncompleteExplanation',
|
||
calculationDate: source.calculationDate,
|
||
reason: outcome.reasons[0],
|
||
completedStages: outcome.completedStages,
|
||
uncompletedStages: ['preparation', 'obligations'],
|
||
};
|
||
outcomeCallback?.(outcome);
|
||
explanationCallback?.(explanation, undefined, undefined);
|
||
artifactsCallback?.({
|
||
explanation,
|
||
});
|
||
return outcome;
|
||
}
|
||
const { holidays, workingDays, calendarDays, calendarIndex, workingCalendar, workingPrefix } =
|
||
calendar;
|
||
const started =
|
||
typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();
|
||
const deadline =
|
||
typeof source.deadlineMs === 'number' ? source.deadlineMs : Number.POSITIVE_INFINITY;
|
||
const context = {
|
||
horizon,
|
||
holidays,
|
||
holidayWeeks: [],
|
||
calendarDays,
|
||
calendarIndex,
|
||
workingCalendar,
|
||
workingPrefix,
|
||
workingDays,
|
||
declaredDays: normalized.declaredDays,
|
||
declaredDates: new Set(normalized.declaredDays.map(({ date }) => date)),
|
||
initialBalances: normalized.balances,
|
||
rules: normalized.rules,
|
||
deadline,
|
||
started,
|
||
request: source,
|
||
nodes: 0,
|
||
check: () => undefined,
|
||
reservedTypes: new Set(),
|
||
sharedTypesByDate: new Map(),
|
||
protectedTypesByDate: new Map(),
|
||
preferenceDemandByDate: new Map(),
|
||
anyPreferenceDemandByDate: new Map(),
|
||
observerTime: 0,
|
||
completedPreferenceStates: new Map(),
|
||
} as SearchContext;
|
||
context.holidayWeeks = holidayWeekCandidates(context);
|
||
context.check = () => {
|
||
context.nodes++;
|
||
if (context.deadline <= 1 || context.nodes % 16 === 0) {
|
||
if (now() - context.started - context.observerTime >= context.deadline)
|
||
throw new DeadlineReached();
|
||
}
|
||
};
|
||
const initialState: State = {
|
||
balances: normalized.balances.map((balance) => ({
|
||
...balance,
|
||
remaining: balance.quantity,
|
||
})),
|
||
placements: new Map(),
|
||
earnedActivations: [],
|
||
};
|
||
const stages: string[] = ['validation'];
|
||
let state = initialState;
|
||
const preferences: PreferenceResult[] = [];
|
||
let checkpoint: PlanOutcome | undefined;
|
||
let checkpointState: State | undefined;
|
||
let retained:
|
||
| (Pick<ObligationPreview, 'state' | 'preferences' | 'metrics'> & {
|
||
vector: PlanningObjectiveVector;
|
||
})
|
||
| undefined;
|
||
if (retainIncumbents)
|
||
context.retain = (candidate, finalizedMetrics, candidateVector) => {
|
||
const check = context.check;
|
||
context.check = () => undefined;
|
||
try {
|
||
const finalized =
|
||
finalizedMetrics || candidateVector
|
||
? candidate
|
||
: finalizeEarnedState(candidate, context);
|
||
if (!finalized) return;
|
||
if (unsatisfiedObligationCount(finalized, context)) return;
|
||
if (
|
||
retained &&
|
||
preferenceStateKey(retained.state) === preferenceStateKey(finalized)
|
||
)
|
||
return;
|
||
const vector = candidateVector ?? objectiveVector(finalized, context);
|
||
const compared = retained
|
||
? compareObjectiveLists(vector.objectives, retained.vector.objectives)
|
||
: -1;
|
||
// Calendar association can complete provenance after the scoring candidate is retained.
|
||
const provenanceRefresh =
|
||
compared === 0 &&
|
||
!!retained &&
|
||
JSON.stringify(retained.state.balances) ===
|
||
JSON.stringify(finalized.balances) &&
|
||
retained.state.placements.size === finalized.placements.size &&
|
||
[...retained.state.placements].every(([date, previous]) => {
|
||
const placement = finalized.placements.get(date);
|
||
return (
|
||
placement &&
|
||
placement.balanceId === previous.balanceId &&
|
||
placement.type === previous.type &&
|
||
placement.supporting === previous.supporting
|
||
);
|
||
}) &&
|
||
JSON.stringify(retained.state.earnedActivations) ===
|
||
JSON.stringify(finalized.earnedActivations);
|
||
const chosen = compared < 0 || provenanceRefresh;
|
||
if (chosen) {
|
||
const firstValidCandidate = !retained;
|
||
const candidatePreferences = preferenceResultsForState(finalized, context);
|
||
retained = {
|
||
state: cloneState(
|
||
reconcileOptimizationProvenance(
|
||
finalized,
|
||
context,
|
||
retained?.state ?? initialState,
|
||
vector.objectives,
|
||
),
|
||
),
|
||
vector,
|
||
preferences: candidatePreferences,
|
||
metrics: finalizedMetrics
|
||
? {
|
||
...finalizedMetrics,
|
||
preferenceSatisfaction: candidatePreferences.map(
|
||
({ satisfied }) => satisfied,
|
||
),
|
||
}
|
||
: qualityMetrics(finalized, context, candidatePreferences),
|
||
};
|
||
if (firstValidCandidate && checkpointCallback)
|
||
observeCallback(context, () =>
|
||
checkpointCallback(
|
||
makePlan(retained!.state, context, retained!.preferences, stages),
|
||
planningStateSnapshot(retained!.state, context),
|
||
),
|
||
);
|
||
}
|
||
} finally {
|
||
context.check = check;
|
||
}
|
||
};
|
||
const finish = (outcome: PlanningOutcome, explanationState?: State): PlanningOutcome => {
|
||
outcome.calculationDate = source.calculationDate;
|
||
const planningFinishedAt = now();
|
||
const planningExcludedTime = context.observerTime;
|
||
outcomeCallback?.(outcome);
|
||
// Explanation materialization is post-outcome work and must not consume or
|
||
// inherit the planning deadline.
|
||
context.check = () => undefined;
|
||
let explanation: PlanningExplanation | undefined;
|
||
let explanationError: PlanningReason | undefined;
|
||
let finalState: PlanningStateSnapshot | undefined;
|
||
try {
|
||
if (outcome.kind === 'Plan' && explanationState)
|
||
explanation = planExplanation(
|
||
explanationState,
|
||
context,
|
||
outcome.placements,
|
||
outcome.timeOffPeriods,
|
||
outcome.preferences,
|
||
outcome.remainingBalances,
|
||
outcome.metrics,
|
||
);
|
||
else if (outcome.kind === 'PlanningIncomplete') {
|
||
if (outcome.incumbent && explanationState) {
|
||
const incumbentExplanation = planExplanation(
|
||
explanationState,
|
||
context,
|
||
outcome.incumbent.placements,
|
||
outcome.incumbent.timeOffPeriods,
|
||
outcome.incumbent.preferences,
|
||
outcome.incumbent.remainingBalances,
|
||
outcome.incumbent.metrics,
|
||
);
|
||
explanation = {
|
||
...incumbentExplanation,
|
||
incomplete: {
|
||
kind: 'IncompleteExplanation',
|
||
reason: outcome.reasons[0],
|
||
completedStages: [...outcome.completedStages],
|
||
uncompletedStages: planningStages(context).filter(
|
||
(stage) => !outcome.completedStages.includes(stage),
|
||
),
|
||
},
|
||
};
|
||
} else
|
||
explanation = {
|
||
kind: 'IncompleteExplanation',
|
||
reason: outcome.reasons[0],
|
||
completedStages: [...outcome.completedStages],
|
||
uncompletedStages: planningStages(context).filter(
|
||
(stage) => !outcome.completedStages.includes(stage),
|
||
),
|
||
};
|
||
} else if (outcome.kind === 'PlanningConflict')
|
||
explanation = conflictExplanation(outcome, context);
|
||
} catch (error) {
|
||
explanationError = reason(
|
||
'explanation-error',
|
||
error instanceof Error ? error.message : String(error),
|
||
);
|
||
}
|
||
try {
|
||
if (explanationState) finalState = planningStateSnapshot(explanationState, context);
|
||
} catch (error) {
|
||
explanationError ??= reason(
|
||
'explanation-error',
|
||
error instanceof Error ? error.message : String(error),
|
||
);
|
||
}
|
||
if (explanation) explanation.calculationDate = source.calculationDate;
|
||
try {
|
||
explanationCallback?.(explanation, explanationError, finalState);
|
||
} catch (error) {
|
||
explanationError ??= reason(
|
||
'explanation-error',
|
||
error instanceof Error ? error.message : String(error),
|
||
);
|
||
}
|
||
artifactsCallback?.({
|
||
explanation,
|
||
finalState,
|
||
explanationError,
|
||
timings: {
|
||
planningWorkMs: Math.max(0, planningFinishedAt - started - planningExcludedTime),
|
||
},
|
||
});
|
||
return outcome;
|
||
};
|
||
const publishCheckpoint = (): void => {
|
||
state = reconcileOptimizationProvenance(state, context, checkpointState ?? initialState);
|
||
context.retain?.(state);
|
||
checkpoint = makePlan(state, context, preferences, stages);
|
||
checkpointState = cloneState(state);
|
||
if (checkpointCallback) {
|
||
observeCallback(context, () =>
|
||
checkpointCallback(checkpoint!, planningStateSnapshot(checkpointState!, context)),
|
||
);
|
||
}
|
||
};
|
||
try {
|
||
markProgress(context, 'validation');
|
||
yield true;
|
||
// Entirely fixed patterns already hold before any placement choices,
|
||
// including preference producers needed by mandatory consumers.
|
||
if (context.declaredDays.length) {
|
||
for (const rule of context.rules) {
|
||
if (!rule.enabled || rule.type !== 'if_then' || !rule.rewardDays) continue;
|
||
for (const occurrence of occurrences(rule, context)) {
|
||
yield;
|
||
const pattern = patternsFor(rule, occurrence, context).find((dates) =>
|
||
dates.every((date) => declaredPatternDay(date, rule.placeType, context)),
|
||
);
|
||
if (pattern)
|
||
state = addEarnedBalance(state, rule, occurrence, pattern, context);
|
||
}
|
||
}
|
||
if (state !== initialState) context.retain?.(state);
|
||
}
|
||
// Equivalent obligation placements can share compatible dates with active preferences.
|
||
context.protectedTypesByDate = futurePreferenceProtection(normalized.rules, 0, context);
|
||
markProgress(context, 'obligations');
|
||
yield true;
|
||
const obligationRequirements = buildRequirements(normalized.rules, context, 'obligation');
|
||
context.reservedTypes = new Set(
|
||
obligationRequirements
|
||
.filter((requirement) => requirement.requiredPlacements > 0)
|
||
.map((requirement) => requirement.rule.placeType)
|
||
.filter((selector): selector is LeaveType => selector !== 'any'),
|
||
);
|
||
for (const requirement of obligationRequirements) {
|
||
yield;
|
||
if (
|
||
!requirement.candidates ||
|
||
!requirement.requiredPlacements ||
|
||
requirement.candidates.length !== 1 ||
|
||
requirement.rule.placeType === 'any'
|
||
)
|
||
continue;
|
||
const types =
|
||
context.sharedTypesByDate.get(requirement.candidates[0]) ?? new Set<LeaveType>();
|
||
types.add(requirement.rule.placeType);
|
||
context.sharedTypesByDate.set(requirement.candidates[0], types);
|
||
}
|
||
const obligationSolutions: State[] = [];
|
||
const optionalProducers = obligationRequirements.some(
|
||
(requirement) => requirement.requiredPlacements > 0,
|
||
)
|
||
? buildRequirements(normalized.rules, context, 'preference')
|
||
.filter(
|
||
(requirement) =>
|
||
requirement.patterns?.length &&
|
||
requirement.rule.rewardDays &&
|
||
requirement.requiredPlacements > 0,
|
||
)
|
||
.map((requirement) => ({ ...requirement, optional: true }))
|
||
: [];
|
||
const jointRequirements = optionalProducers.length
|
||
? [...obligationRequirements, ...optionalProducers].sort(compareRequirements)
|
||
: obligationRequirements;
|
||
const hasEarnedObligation =
|
||
optionalProducers.length > 0 ||
|
||
obligationRequirements.some((requirement) => (requirement.rule.rewardDays ?? 0) > 0);
|
||
const obligationSolutionLimit =
|
||
hasEarnedObligation ||
|
||
obligationBranchEstimate(obligationRequirements) <= EXACT_OBLIGATION_BRANCH_LIMIT
|
||
? 0
|
||
: OBLIGATION_SOLUTION_LIMIT;
|
||
yield* satisfyObligations(
|
||
jointRequirements,
|
||
state,
|
||
context,
|
||
0,
|
||
obligationSolutions,
|
||
obligationSolutionLimit,
|
||
);
|
||
if (!obligationSolutions.length) {
|
||
stages.push('obligations');
|
||
return finish(obligationConflict(obligationRequirements, context));
|
||
}
|
||
const preferenceRules = normalized.rules.filter(
|
||
(item) => item.enabled && item.force === 'preference',
|
||
);
|
||
let previewSolutions = obligationSolutions;
|
||
if (
|
||
obligationSolutionLimit > 0 &&
|
||
obligationRequirements.every(
|
||
(requirement) => requirement.rule.type === 'time_window',
|
||
) &&
|
||
obligationSolutions.length > WINDOW_OBLIGATION_PREVIEW_LIMIT
|
||
) {
|
||
// shortcut: large window searches preview the eight best active vectors; upgrade to shared bounded previews when a measured case misses a better plan.
|
||
const ranked: { state: State; objectives: PlanningObjective[] }[] = [];
|
||
for (const candidate of obligationSolutions) {
|
||
yield;
|
||
context.check();
|
||
ranked.push({
|
||
state: candidate,
|
||
objectives: obligationPreviewObjectives({ state: candidate }, context),
|
||
});
|
||
}
|
||
ranked.sort((a, b) => compareObjectiveLists(a.objectives, b.objectives));
|
||
previewSolutions = ranked
|
||
.slice(0, WINDOW_OBLIGATION_PREVIEW_LIMIT)
|
||
.map(({ state }) => state);
|
||
}
|
||
// A completed enumeration with one finalized solution has no alternative to rank.
|
||
const soleSolution =
|
||
obligationSolutions.length === 1 &&
|
||
(obligationSolutionLimit === 0 || obligationSolutions.length < obligationSolutionLimit)
|
||
? obligationSolutions[0]
|
||
: undefined;
|
||
const solePreferences = soleSolution
|
||
? preferenceResultsForState(soleSolution, context)
|
||
: [];
|
||
let bestPreview: ObligationPreview | null = soleSolution
|
||
? {
|
||
obligationState: soleSolution,
|
||
state: soleSolution,
|
||
preferences: solePreferences,
|
||
metrics: qualityMetrics(soleSolution, context, solePreferences),
|
||
}
|
||
: null;
|
||
for (const obligationSolution of soleSolution ? [] : previewSolutions) {
|
||
yield;
|
||
context.check();
|
||
const preview = yield* applyPreferenceRules(
|
||
cloneState(obligationSolution),
|
||
preferenceRules,
|
||
context,
|
||
);
|
||
const previewState = finalizeEarnedState(preview.state, context);
|
||
if (!previewState) continue;
|
||
const candidate = {
|
||
obligationState: obligationSolution,
|
||
state: previewState,
|
||
preferences: preview.preferences,
|
||
metrics: qualityMetrics(previewState, context, preview.preferences),
|
||
};
|
||
const compared: number | undefined = bestPreview
|
||
? compareObligationPreviews(candidate, bestPreview, context)
|
||
: undefined;
|
||
if (!bestPreview || compared! < 0) bestPreview = candidate;
|
||
}
|
||
if (!bestPreview) {
|
||
stages.push('obligations');
|
||
return finish(obligationConflict(obligationRequirements, context));
|
||
}
|
||
state = bestPreview.obligationState;
|
||
stages.push('obligations');
|
||
publishCheckpoint();
|
||
for (let preferenceIndex = 0; preferenceIndex < preferenceRules.length; preferenceIndex++) {
|
||
yield;
|
||
const rule = preferenceRules[preferenceIndex];
|
||
markProgress(context, ruleStage(rule));
|
||
yield true;
|
||
state = yield* applyPreferenceStep(
|
||
state,
|
||
preferenceRules,
|
||
preferenceIndex,
|
||
preferences,
|
||
context,
|
||
);
|
||
stages.push(ruleStage(rule));
|
||
publishCheckpoint();
|
||
}
|
||
markProgress(context, 'expiry');
|
||
yield true;
|
||
state = yield* optimizeRulePlacements(state, context, preferenceRules.length - 1, false);
|
||
stages.push('expiry');
|
||
publishCheckpoint();
|
||
markProgress(context, 'dates');
|
||
yield true;
|
||
stages.push('dates');
|
||
const finalized = finalizeEarnedState(state, context);
|
||
if (!finalized) {
|
||
return finish({
|
||
kind: 'PlanningConflict',
|
||
conflictingRuleIds: state.earnedActivations.map((activation) => activation.ruleId),
|
||
reasons: [
|
||
reason(
|
||
'earned-entitlement-order',
|
||
'Generated leave became available only after its final merged time-off period.',
|
||
),
|
||
],
|
||
});
|
||
}
|
||
state = finalized;
|
||
publishCheckpoint();
|
||
markProgress(context, 'complete');
|
||
yield true;
|
||
return finish(checkpoint!, state);
|
||
} catch (error) {
|
||
if (error instanceof UserCancelled && retained) {
|
||
// Freeze the chosen valid candidate, rather than exposing a temporary search state.
|
||
context.check = () => undefined;
|
||
context.retain = undefined;
|
||
checkpointState = retained.state;
|
||
const partialPreferences = retained.preferences.map((preference) => ({
|
||
...preference,
|
||
reason: preferenceReason(
|
||
normalized.rules.find(
|
||
(rule): rule is NormalizedCalendarRule =>
|
||
rule.type !== 'optimization' && rule.id === preference.ruleId,
|
||
)!,
|
||
preference.satisfied,
|
||
context,
|
||
retained!.state,
|
||
),
|
||
}));
|
||
checkpoint = makePlan(checkpointState, context, partialPreferences, stages);
|
||
}
|
||
const interruption =
|
||
error instanceof UserCancelled
|
||
? reason('user-cancelled', 'The calculation was cancelled by the user.')
|
||
: error instanceof DeadlineReached
|
||
? reason(
|
||
'deadline',
|
||
`Planning stopped after ${context.deadline}ms before all quality stages completed.`,
|
||
)
|
||
: reason(
|
||
'planner-error',
|
||
error instanceof Error ? error.message : String(error),
|
||
);
|
||
return finish(
|
||
{
|
||
kind: 'PlanningIncomplete',
|
||
incumbent: checkpoint,
|
||
completedStages: [...stages],
|
||
lastCompletedStage: stages[stages.length - 1],
|
||
reasons: [interruption],
|
||
},
|
||
checkpointState,
|
||
);
|
||
}
|
||
}
|
||
|
||
/** Materialize an incumbent explanation from a delivered checkpoint without rerunning planning. */
|
||
export function explainIncompleteCheckpoint(
|
||
request: unknown,
|
||
incumbent: PlanOutcome,
|
||
checkpoint: PlanningStateSnapshot,
|
||
interruption: PlanningReason,
|
||
): PlanningArtifacts {
|
||
try {
|
||
const source = runtimeRequest(
|
||
request,
|
||
incumbent.calculationDate ?? checkpoint.calculationDate,
|
||
);
|
||
const normalized = normalizeRequest(source);
|
||
if (normalized.reasons.length || !normalized.horizon)
|
||
throw new Error('The checkpoint explanation request is invalid.');
|
||
const horizon = normalized.horizon;
|
||
const holidays = holidaySet(horizon);
|
||
const calendarDays = dateRange(addDays(horizon.start, -400), addDays(horizon.end, 400));
|
||
const calendarIndex = new Map(calendarDays.map((date, index) => [date, index]));
|
||
const workingCalendar = new Set(calendarDays.filter((date) => isWorking(date, holidays)));
|
||
const workingPrefix = [0];
|
||
for (const date of calendarDays)
|
||
workingPrefix.push(
|
||
workingPrefix[workingPrefix.length - 1] + Number(workingCalendar.has(date)),
|
||
);
|
||
const context = {
|
||
horizon,
|
||
holidayWeeks: [],
|
||
holidays,
|
||
calendarDays,
|
||
calendarIndex,
|
||
workingCalendar,
|
||
workingPrefix,
|
||
workingDays: dateRange(horizon.start, horizon.end).filter((date) =>
|
||
isWorking(date, holidays),
|
||
),
|
||
declaredDays: normalized.declaredDays,
|
||
declaredDates: new Set(normalized.declaredDays.map(({ date }) => date)),
|
||
initialBalances: normalized.balances,
|
||
rules: normalized.rules,
|
||
deadline: Number.POSITIVE_INFINITY,
|
||
started: 0,
|
||
request: source,
|
||
nodes: 0,
|
||
check: () => undefined,
|
||
reservedTypes: new Set<LeaveType>(),
|
||
sharedTypesByDate: new Map<string, Set<LeaveType>>(),
|
||
protectedTypesByDate: new Map<string, Set<LeaveType>>(),
|
||
preferenceDemandByDate: new Map<string, number>(),
|
||
anyPreferenceDemandByDate: new Map<string, number>(),
|
||
observerTime: 0,
|
||
completedPreferenceStates: new Map(),
|
||
} as SearchContext;
|
||
context.holidayWeeks = holidayWeekCandidates(context);
|
||
const state: State = {
|
||
balances: checkpoint.balances.map((balance) => ({ ...balance })),
|
||
placements: new Map(
|
||
checkpoint.placements.map((placement) => [
|
||
placement.date,
|
||
{ ...placement, ruleIds: new Set(placement.ruleIds) },
|
||
]),
|
||
),
|
||
earnedActivations: checkpoint.earnedActivations.map((activation) => ({
|
||
...activation,
|
||
dates: [...activation.dates],
|
||
})),
|
||
};
|
||
const explanation = planExplanation(
|
||
state,
|
||
context,
|
||
incumbent.placements,
|
||
incumbent.timeOffPeriods,
|
||
incumbent.preferences,
|
||
incumbent.remainingBalances,
|
||
incumbent.metrics,
|
||
);
|
||
const uncompletedStages = planningStages(context).filter(
|
||
(stage) => !incumbent.completedStages.includes(stage),
|
||
);
|
||
return {
|
||
explanation:
|
||
uncompletedStages.length === 0
|
||
? explanation
|
||
: {
|
||
...explanation,
|
||
incomplete: {
|
||
kind: 'IncompleteExplanation',
|
||
reason: interruption,
|
||
completedStages: [...incumbent.completedStages],
|
||
uncompletedStages,
|
||
},
|
||
},
|
||
finalState: planningStateSnapshot(state, context),
|
||
};
|
||
} catch (error) {
|
||
return {
|
||
explanationError: reason(
|
||
'explanation-error',
|
||
error instanceof Error ? error.message : String(error),
|
||
),
|
||
};
|
||
}
|
||
}
|
||
|
||
function counterfactualReason(
|
||
code: CounterfactualReasonCode,
|
||
message: string,
|
||
): PlanningReason & { code: CounterfactualReasonCode } {
|
||
return { code, message };
|
||
}
|
||
|
||
function counterfactualContext(
|
||
source: RuntimePlanningRequest,
|
||
normalized: ReturnType<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),
|
||
),
|
||
declaredDays: normalized.declaredDays,
|
||
declaredDates: new Set(normalized.declaredDays.map(({ date }) => date)),
|
||
initialBalances: normalized.balances,
|
||
rules: normalized.rules,
|
||
deadline: Number.POSITIVE_INFINITY,
|
||
started: 0,
|
||
request: source,
|
||
nodes: 0,
|
||
check: () => undefined,
|
||
reservedTypes: new Set<LeaveType>(),
|
||
sharedTypesByDate: new Map<string, Set<LeaveType>>(),
|
||
protectedTypesByDate: new Map<string, Set<LeaveType>>(),
|
||
preferenceDemandByDate: new Map<string, number>(),
|
||
anyPreferenceDemandByDate: new Map<string, number>(),
|
||
observerTime: 0,
|
||
completedPreferenceStates: new Map(),
|
||
} as SearchContext;
|
||
context.holidayWeeks = holidayWeekCandidates(context);
|
||
return context;
|
||
}
|
||
|
||
function stateFromPlanningSnapshot(state: PlanningStateSnapshot): State {
|
||
return {
|
||
balances: state.balances.map((balance) => ({ ...balance })),
|
||
placements: new Map(
|
||
state.placements.map((placement) => [
|
||
placement.date,
|
||
{ ...placement, ruleIds: new Set(placement.ruleIds) },
|
||
]),
|
||
),
|
||
earnedActivations: state.earnedActivations.map((activation) => ({
|
||
...activation,
|
||
dates: [...activation.dates],
|
||
})),
|
||
};
|
||
}
|
||
|
||
function sameObjectiveValue(a: number | string, b: number | string): boolean {
|
||
return a === b;
|
||
}
|
||
|
||
function objectiveComparison(
|
||
before: PlanningObjectiveVector,
|
||
after: PlanningObjectiveVector,
|
||
): PlanningObjectiveComparison {
|
||
const delta: PlanningObjectiveDelta[] = after.objectives.map((objective, index) => {
|
||
const previous = before.objectives[index];
|
||
const value =
|
||
typeof objective.value === 'number' && typeof previous?.value === 'number'
|
||
? objective.value - previous.value
|
||
: sameObjectiveValue(objective.value, previous?.value ?? objective.value)
|
||
? 0
|
||
: null;
|
||
return {
|
||
id: objective.id,
|
||
direction: objective.direction,
|
||
value,
|
||
label: objective.label,
|
||
};
|
||
});
|
||
const first = firstDifferentObjective(after.objectives, before.objectives);
|
||
const decisive = first >= 0 ? after.objectives[first].id : undefined;
|
||
return {
|
||
before,
|
||
after,
|
||
delta,
|
||
firstDecisiveObjective: decisive,
|
||
tieBreak:
|
||
decisive === 'balance-expiry'
|
||
? 'balance-expiry'
|
||
: decisive === 'placement-date'
|
||
? 'placement-date'
|
||
: undefined,
|
||
};
|
||
}
|
||
|
||
function counterfactualRuleValid(
|
||
date: string,
|
||
type: LeaveType,
|
||
ruleIds: readonly string[],
|
||
context: SearchContext,
|
||
baseline: State,
|
||
): boolean {
|
||
for (const ruleId of ruleIds) {
|
||
const rule = context.rules.find((candidate) => candidate.id === ruleId);
|
||
if (!rule) return false;
|
||
if (rule.type === 'optimization') continue;
|
||
if (rule.type === 'time_window') {
|
||
if (
|
||
!selectorMatches(rule.placeType, type) ||
|
||
compareDate(date, rule.startDate!) < 0 ||
|
||
compareDate(date, rule.endDate!) > 0
|
||
)
|
||
return false;
|
||
continue;
|
||
}
|
||
const matchesPattern = occurrences(rule, context).some((occurrence) =>
|
||
patternsFor(rule, occurrence, context).some(
|
||
(pattern) =>
|
||
pattern.includes(date) &&
|
||
pattern.every(
|
||
(candidateDate) =>
|
||
candidateDate === date ||
|
||
patternDateSatisfied(candidateDate, rule, baseline, context),
|
||
),
|
||
),
|
||
);
|
||
if (!matchesPattern || !selectorMatches(rule.placeType, type)) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function counterfactualObligationsValid(
|
||
state: State,
|
||
context: SearchContext,
|
||
previous: State,
|
||
): boolean {
|
||
return context.rules
|
||
.filter(
|
||
(rule): rule is NormalizedCalendarRule => rule.enabled && rule.force === 'obligation',
|
||
)
|
||
.every((rule) => {
|
||
if (rule.type === 'time_window') {
|
||
const count = (candidate: State) =>
|
||
[...candidate.placements.values()].filter((placement) =>
|
||
placement.ruleIds.has(rule.id),
|
||
).length;
|
||
const requiredPlacements =
|
||
rule.placeDays - declaredWindowDays(rule, context).length;
|
||
return (
|
||
count(previous) !== requiredPlacements || count(state) === requiredPlacements
|
||
);
|
||
}
|
||
return occurrences(rule, context).every((occurrence) => {
|
||
const patterns = patternsFor(rule, occurrence, context);
|
||
const complete = (candidate: State) =>
|
||
patterns.some((pattern) =>
|
||
pattern.every((date) =>
|
||
patternDateSatisfied(date, rule, candidate, context),
|
||
),
|
||
);
|
||
return !complete(previous) || complete(state);
|
||
});
|
||
});
|
||
}
|
||
|
||
function preferenceResultsForState(state: State, context: SearchContext): PreferenceResult[] {
|
||
let patternsByRule = preferencePatternCache.get(context);
|
||
if (!patternsByRule) {
|
||
patternsByRule = new Map(
|
||
context.rules
|
||
.filter(
|
||
(rule): rule is NormalizedCalendarRule =>
|
||
rule.enabled && rule.force === 'preference' && rule.type === 'if_then',
|
||
)
|
||
.map((rule) => [
|
||
rule.id,
|
||
occurrences(rule, context).map((occurrence) =>
|
||
patternsFor(rule, occurrence, context),
|
||
),
|
||
]),
|
||
);
|
||
preferencePatternCache.set(context, patternsByRule);
|
||
}
|
||
return context.rules
|
||
.filter(
|
||
(rule): rule is NormalizedCalendarRule =>
|
||
rule.enabled && rule.force === 'preference' && rule.type !== 'optimization',
|
||
)
|
||
.map((rule) => {
|
||
const occurrencePatterns = patternsByRule.get(rule.id) ?? [];
|
||
const requested =
|
||
rule.type === 'time_window' ? rule.placeDays : occurrencePatterns.length;
|
||
const satisfied =
|
||
rule.type === 'time_window'
|
||
? Math.min(
|
||
rule.placeDays,
|
||
declaredWindowDays(rule, context).length +
|
||
[...state.placements.values()].filter(
|
||
(placement) =>
|
||
placement.date >= rule.startDate! &&
|
||
placement.date <= rule.endDate! &&
|
||
selectorMatches(rule.placeType, placement.type),
|
||
).length,
|
||
)
|
||
: occurrencePatterns.filter((patterns) =>
|
||
patterns.some((pattern) =>
|
||
pattern.every((date) =>
|
||
patternDateSatisfied(date, rule, state, context),
|
||
),
|
||
),
|
||
).length;
|
||
return {
|
||
ruleId: rule.id,
|
||
requested,
|
||
satisfied,
|
||
completeOccurrences: rule.type === 'if_then' ? satisfied : 0,
|
||
};
|
||
});
|
||
}
|
||
|
||
/** Analyze only the selected placement against the final-plan baseline. */
|
||
export function analyzeCounterfactualInternal(
|
||
request: unknown,
|
||
plan: PlanOutcome,
|
||
selected: LeavePlacement,
|
||
finalStateSnapshot?: PlanningStateSnapshot,
|
||
): CounterfactualAnalysis {
|
||
const source = runtimeRequest(
|
||
request,
|
||
plan.calculationDate ?? finalStateSnapshot?.calculationDate,
|
||
);
|
||
const normalized = normalizeRequest(source);
|
||
const horizon = normalized.horizon ?? plan.horizon;
|
||
const context = counterfactualContext(source, normalized, horizon);
|
||
const finalState: State = finalStateSnapshot
|
||
? stateFromPlanningSnapshot(finalStateSnapshot)
|
||
: (() => {
|
||
const balances = normalized.balances.map((balance) => ({
|
||
...balance,
|
||
remaining: balance.quantity,
|
||
}));
|
||
for (const placement of plan.placements) {
|
||
let balance = balances.find(
|
||
(candidate) => candidate.id === placement.balanceId,
|
||
);
|
||
if (!balance) {
|
||
const remaining = plan.remainingBalances.find(
|
||
(candidate) => candidate.id === placement.balanceId,
|
||
);
|
||
balance = {
|
||
id: placement.balanceId,
|
||
type: placement.type,
|
||
quantity: 0,
|
||
remaining: 0,
|
||
availableFrom: remaining?.availableFrom ?? horizon.start,
|
||
expiryDate: remaining?.expiryDate ?? horizon.end,
|
||
earned: remaining?.earned ?? true,
|
||
sourceRuleId: remaining?.sourceRuleId,
|
||
};
|
||
balances.push(balance);
|
||
}
|
||
balance.remaining = Math.max(0, balance.remaining - 1);
|
||
}
|
||
return {
|
||
balances,
|
||
placements: new Map(
|
||
plan.placements.map((placement) => [
|
||
placement.date,
|
||
{ ...placement, ruleIds: new Set(placement.ruleIds) },
|
||
]),
|
||
),
|
||
earnedActivations: [],
|
||
};
|
||
})();
|
||
const comparisonState = cloneState(finalState);
|
||
const baseline = cloneState(finalState);
|
||
const removeDate = selected.date;
|
||
const removed = removeDate ? baseline.placements.get(removeDate) : undefined;
|
||
if (removeDate) {
|
||
notePlacementRemoved(finalState.placements, baseline.placements, removeDate);
|
||
baseline.placements.delete(removeDate);
|
||
}
|
||
const released = baseline.balances.find((balance) => balance.id === selected.balanceId);
|
||
if (released && removed?.balanceId === selected.balanceId) released.remaining++;
|
||
const beforeState = baseline;
|
||
const before = objectiveVector(beforeState, context);
|
||
const results: CounterfactualDateResult[] = [];
|
||
for (const date of dateRange(horizon.start, horizon.end)) {
|
||
let ineligible: (PlanningReason & { code: CounterfactualReasonCode }) | undefined;
|
||
if (!isWorking(date, context.holidays))
|
||
ineligible = counterfactualReason(
|
||
'non-working',
|
||
'The date is a weekend or public holiday.',
|
||
);
|
||
else if (context.declaredDates.has(date))
|
||
ineligible = counterfactualReason(
|
||
'declared-day',
|
||
'The date is a declared non-working day fixed by the user.',
|
||
);
|
||
else if (baseline.placements.has(date))
|
||
ineligible = counterfactualReason(
|
||
'occupied',
|
||
'Another final placement already uses this date.',
|
||
);
|
||
else if (!released)
|
||
ineligible = counterfactualReason(
|
||
'unavailable',
|
||
'The selected leave entitlement is unavailable.',
|
||
);
|
||
else if (compareDate(released.availableFrom, date) > 0)
|
||
ineligible = counterfactualReason(
|
||
'unavailable',
|
||
'The selected entitlement is not available yet.',
|
||
);
|
||
else if (compareDate(date, released.expiryDate) > 0)
|
||
ineligible = counterfactualReason(
|
||
'expired',
|
||
'The selected entitlement has expired by this date.',
|
||
);
|
||
else if (released.type !== selected.type)
|
||
ineligible = counterfactualReason(
|
||
'type-incompatible',
|
||
'The date cannot host this leave type.',
|
||
);
|
||
else if (!counterfactualRuleValid(date, selected.type, selected.ruleIds, context, baseline))
|
||
ineligible = counterfactualReason(
|
||
'rule-invalid',
|
||
'The date does not preserve the placement rules.',
|
||
);
|
||
if (ineligible) {
|
||
results.push({ date, status: 'ineligible', reason: ineligible });
|
||
continue;
|
||
}
|
||
const candidate = assignDateToBalance(
|
||
baseline,
|
||
date,
|
||
selected.balanceId,
|
||
context,
|
||
new Set(selected.ruleIds),
|
||
);
|
||
if (!candidate) {
|
||
results.push({
|
||
date,
|
||
status: 'ineligible',
|
||
reason: counterfactualReason(
|
||
'unavailable',
|
||
'The selected entitlement cannot be placed on this date.',
|
||
),
|
||
});
|
||
continue;
|
||
}
|
||
candidate.placements.get(date)!.supporting = selected.supporting;
|
||
if (removeDate && removeDate !== date)
|
||
for (const activation of candidate.earnedActivations) {
|
||
if (!selected.ruleIds.includes(activation.ruleId)) continue;
|
||
activation.dates = activation.dates.map((activationDate) =>
|
||
activationDate === removeDate ? date : activationDate,
|
||
);
|
||
}
|
||
const finalized = finalizeEarnedState(candidate, context);
|
||
if (!finalized) {
|
||
results.push({
|
||
date,
|
||
status: 'ineligible',
|
||
reason: counterfactualReason(
|
||
'unavailable',
|
||
'This date would make generated leave unavailable until its producing period ends.',
|
||
),
|
||
});
|
||
continue;
|
||
}
|
||
if (!counterfactualObligationsValid(finalized, context, comparisonState)) {
|
||
results.push({
|
||
date,
|
||
status: 'ineligible',
|
||
reason: counterfactualReason(
|
||
'rule-invalid',
|
||
'The date would leave an obligation unsatisfied.',
|
||
),
|
||
});
|
||
continue;
|
||
}
|
||
const after = objectiveVector(finalized, context);
|
||
const comparison = objectiveComparison(before, after);
|
||
results.push({ date, status: 'eligible', comparison });
|
||
}
|
||
return {
|
||
kind: 'Counterfactual',
|
||
calculationId: '',
|
||
placement: { ...selected, ruleIds: [...selected.ruleIds] },
|
||
hypothetical: true,
|
||
baseline: before,
|
||
results,
|
||
};
|
||
}
|