Files
vac-optimizer/src/app/services/planning-objectives.ts
T
nessarandClaude Opus 5.5 9d9404c3db perf(planning): validate each optimization configuration once
Every objective evaluation revalidated its optimization configuration.
Rule normalization already validates each configuration once per
calculation and keeps its own copy, the presets are constant, and the
summed variant of a valid configuration stays valid, so the core now
scores them without revalidating. evaluateOptimization still validates
for other callers.

Part of #302.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:03:22 +02:00

317 lines
10 KiB
TypeScript

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<OptimizationMeasure, 'work-sequence-deficit'>; 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<string, unknown>;
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<string, OptimizationConfiguration>;
/** 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<readonly string[], number>();
export function evaluateOptimization(
configuration: OptimizationConfiguration,
observations: OptimizationObservations,
): number {
const validation = validateOptimizationConfiguration(configuration);
if (!validation.valid) throw new Error(`${validation.code}: ${validation.message}`);
return evaluateValidatedOptimization(configuration, observations);
}
/** Scores a configuration that {@link validateOptimizationConfiguration} already accepted. */
export function evaluateValidatedOptimization(
configuration: OptimizationConfiguration,
observations: OptimizationObservations,
): number {
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);
}