Files
vac-optimizer/src/app/services/leave-planner.core.ts
T
nessarandClaude Opus 5.5 68a9e33de6 perf(planning): prune candidates that cannot beat the protected prefix
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>
2026-10-17 00:45:28 +02:00

5174 lines
159 KiB
TypeScript
Raw Blame History

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