feat(planning): make optimization rules composable
Evaluate explicit, ordered optimization configurations through one shared planner model. Add six editable presets, durable one-time legacy migration, and consistent completed and cancelled plan provenance. Include functional regressions and the agreed trace-removal and benchmark follow-up specs. Performance acceptance remains in #78 under unchanged gates. Refs: #70, #71, #72, #73, #74, #75, #76, #77, #78
This commit is contained in:
1 parent
209bf05f36
commit
6811142299
45 files changed
+8795
-3075
No files matched your search
@@ -0,0 +1,300 @@
|
||||
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 }[];
|
||||
}
|
||||
|
||||
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(
|
||||
(period) => new Set(period.nonWorkingDates).size,
|
||||
);
|
||||
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);
|
||||
}
|
||||
Reference in new issue
Block a user