export type OptimizationElements = | 'leave-balances' | 'public-holiday-weeks' | 'time-off-periods' | 'work-sequences' | 'leave-placements'; export type OptimizationCondition = | 'at-risk' | 'complete' | 'internal' | 'non-empty' | 'supporting'; export type OptimizationMeasure = | 'unplaced-quantity' | 'public-holiday-week-value' | 'non-working-day-gain' | 'work-sequence-length' | 'work-sequence-deficit' | 'placement-count'; export type OptimizationAggregation = 'sum' | 'count' | 'maximum'; export type OptimizationDirection = 'minimize' | 'maximize'; export type OptimizationUnit = 'working-days' | 'calendar-days' | 'count'; interface OptimizationConfigurationBase { elements: OptimizationElements; conditions: readonly OptimizationCondition[]; aggregation: OptimizationAggregation; direction: OptimizationDirection; } export type OptimizationConfiguration = OptimizationConfigurationBase & ( | { measure: 'work-sequence-deficit'; thresholdDays: number } | { measure: Exclude; thresholdDays?: never } ); /** Each row accepts any subset of its conditions and both comparison directions. */ export const OPTIMIZATION_CATALOG = [ { elements: 'leave-balances', measure: 'unplaced-quantity', conditions: ['at-risk'], aggregations: ['sum', 'count', 'maximum'], unit: 'working-days', }, { elements: 'public-holiday-weeks', measure: 'public-holiday-week-value', conditions: ['complete'], aggregations: ['sum', 'count', 'maximum'], unit: 'calendar-days', }, { elements: 'time-off-periods', measure: 'non-working-day-gain', conditions: [], aggregations: ['sum', 'count', 'maximum'], unit: 'calendar-days', }, { elements: 'work-sequences', measure: 'work-sequence-length', conditions: ['internal', 'non-empty'], aggregations: ['sum', 'count', 'maximum'], unit: 'working-days', }, { elements: 'work-sequences', measure: 'work-sequence-deficit', conditions: ['internal', 'non-empty'], aggregations: ['sum', 'count', 'maximum'], unit: 'working-days', }, { elements: 'leave-placements', measure: 'placement-count', conditions: ['supporting'], aggregations: ['sum', 'count', 'maximum'], unit: 'count', }, ] as const; export type OptimizationValidation = | { valid: true; configuration: OptimizationConfiguration } | { valid: false; code: 'invalid-optimization-configuration' | 'unsupported-optimization-configuration'; message: string; }; export function validateOptimizationConfiguration(value: unknown): OptimizationValidation { const invalid = (message: string): OptimizationValidation => ({ valid: false, code: 'invalid-optimization-configuration', message, }); const unsupported = (message: string): OptimizationValidation => ({ valid: false, code: 'unsupported-optimization-configuration', message, }); if (!value || typeof value !== 'object' || Array.isArray(value)) return invalid('An optimization configuration must be an object.'); const config = value as Record; const fields = [ 'elements', 'conditions', 'measure', 'aggregation', 'direction', 'thresholdDays', ]; if (Object.keys(config).some((key) => !fields.includes(key))) return unsupported('The optimization configuration contains an unsupported field.'); if (config['direction'] !== 'minimize' && config['direction'] !== 'maximize') return invalid('The optimization direction must be minimize or maximize.'); if ( !Array.isArray(config['conditions']) || [...config['conditions']].some((condition) => typeof condition !== 'string') || new Set(config['conditions']).size !== config['conditions'].length ) return invalid('Optimization conditions must be a list of distinct conditions.'); const row = OPTIMIZATION_CATALOG.find( (item) => item.elements === config['elements'] && item.measure === config['measure'], ); if (!row) return unsupported('The planning elements and measure are not compatible.'); const conditions: readonly string[] = row.conditions; if (config['conditions'].some((condition) => !conditions.includes(condition))) return unsupported('A condition is not supported for these planning elements.'); const aggregations: readonly string[] = row.aggregations; if (typeof config['aggregation'] !== 'string' || !aggregations.includes(config['aggregation'])) return unsupported('The aggregation is not supported for this measure.'); if (config['measure'] === 'work-sequence-deficit') { if (!Number.isSafeInteger(config['thresholdDays']) || Number(config['thresholdDays']) <= 0) return invalid('The deficit threshold must be a positive integer in working days.'); } else if ('thresholdDays' in config) return unsupported('Only the work-sequence deficit accepts a working-day threshold.'); return { valid: true, configuration: value as OptimizationConfiguration }; } export function optimizationUnit(configuration: OptimizationConfiguration): OptimizationUnit { return configuration.aggregation === 'count' ? 'count' : OPTIMIZATION_CATALOG.find( (row) => row.elements === configuration.elements && row.measure === configuration.measure, )!.unit; } export const OPTIMIZATION_PRESETS = { unplacedAtRisk: { elements: 'leave-balances', conditions: ['at-risk'], measure: 'unplaced-quantity', aggregation: 'sum', direction: 'minimize', }, holidayWeekValue: { elements: 'public-holiday-weeks', conditions: ['complete'], measure: 'public-holiday-week-value', aggregation: 'sum', direction: 'maximize', }, nonWorkingDayGain: { elements: 'time-off-periods', conditions: [], measure: 'non-working-day-gain', aggregation: 'sum', direction: 'maximize', }, longestWorkSequence: { elements: 'work-sequences', conditions: [], measure: 'work-sequence-length', aggregation: 'maximum', direction: 'minimize', }, shortSequenceDeficit: { elements: 'work-sequences', conditions: ['internal', 'non-empty'], measure: 'work-sequence-deficit', thresholdDays: 5, aggregation: 'sum', direction: 'minimize', }, supportingPlacements: { elements: 'leave-placements', conditions: ['supporting'], measure: 'placement-count', aggregation: 'count', direction: 'minimize', }, } as const satisfies Record; /** Concrete observations from one plan; they carry no rule identity or display name. */ export interface OptimizationObservations { leaveBalances: readonly { unplacedQuantity: number; atRisk: boolean }[]; publicHolidayWeeks: readonly { value: number; complete: boolean }[]; timeOffPeriods: readonly { nonWorkingDates: readonly string[] }[]; workSequences: readonly { length: number; internal: boolean }[]; leavePlacements: readonly { supporting: boolean }[]; } // Calendar geometry reuses frozen date arrays across candidate plans. const nonWorkingDateCounts = new WeakMap(); export function evaluateOptimization( configuration: OptimizationConfiguration, observations: OptimizationObservations, ): number { const validation = validateOptimizationConfiguration(configuration); if (!validation.valid) throw new Error(`${validation.code}: ${validation.message}`); const selected = (condition: OptimizationCondition) => configuration.conditions.includes(condition); let values: number[]; switch (configuration.elements) { case 'leave-balances': values = observations.leaveBalances .filter((balance) => !selected('at-risk') || balance.atRisk) .map((balance) => balance.unplacedQuantity); break; case 'public-holiday-weeks': values = observations.publicHolidayWeeks .filter((week) => !selected('complete') || week.complete) .map((week) => week.value); break; case 'time-off-periods': values = observations.timeOffPeriods.map(({ nonWorkingDates }) => { const cached = nonWorkingDateCounts.get(nonWorkingDates); if (cached !== undefined) return cached; const count = new Set(nonWorkingDates).size; if (Object.isFrozen(nonWorkingDates)) nonWorkingDateCounts.set(nonWorkingDates, count); return count; }); break; case 'work-sequences': values = observations.workSequences .filter( (sequence) => (!selected('internal') || sequence.internal) && (!selected('non-empty') || sequence.length > 0), ) .map((sequence) => configuration.measure === 'work-sequence-deficit' ? sequence.length > 0 ? Math.max(0, configuration.thresholdDays - sequence.length) : 0 : sequence.length, ); break; case 'leave-placements': values = observations.leavePlacements .filter((placement) => !selected('supporting') || placement.supporting) .map(() => 1); } switch (configuration.aggregation) { case 'count': return values.length; case 'sum': return values.reduce((total, value) => total + value, 0); case 'maximum': return values.reduce((maximum, value) => Math.max(maximum, value), 0); } } export interface ComparableObjective { id: string; direction: OptimizationDirection; value: number | string; } /** Negative means the left candidate is preferred. Strings retain lexical tie-break ordering. */ export function compareObjectiveValues( direction: OptimizationDirection, left: number | string, right: number | string, ): number { if (left === right) return 0; const difference = typeof left === 'number' && typeof right === 'number' ? left - right : left < right ? -1 : left > right ? 1 : 0; return direction === 'minimize' ? difference : -difference; } export function firstDifferentObjective( left: readonly ComparableObjective[], right: readonly ComparableObjective[], ): number { return left.findIndex((objective, index) => objective.value !== right[index]?.value); } export function compareObjectiveLists( left: readonly ComparableObjective[], right: readonly ComparableObjective[], ): number { const index = firstDifferentObjective(left, right); return index < 0 ? 0 : compareObjectiveValues(left[index].direction, left[index].value, right[index].value); }