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,193 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
compareObjectiveLists,
|
||||
compareObjectiveValues,
|
||||
evaluateOptimization,
|
||||
firstDifferentObjective,
|
||||
OPTIMIZATION_PRESETS,
|
||||
optimizationUnit,
|
||||
validateOptimizationConfiguration,
|
||||
type OptimizationObservations,
|
||||
} from './planning-objectives';
|
||||
|
||||
const empty: OptimizationObservations = {
|
||||
leaveBalances: [],
|
||||
publicHolidayWeeks: [],
|
||||
timeOffPeriods: [],
|
||||
workSequences: [],
|
||||
leavePlacements: [],
|
||||
};
|
||||
|
||||
describe('composable planning objectives', () => {
|
||||
it('evaluates each internal non-empty sequence with its own working-day threshold', () => {
|
||||
const observations: OptimizationObservations = {
|
||||
...empty,
|
||||
workSequences: [
|
||||
{ length: 1, internal: false },
|
||||
{ length: 0, internal: true },
|
||||
{ length: 1, internal: true },
|
||||
{ length: 4, internal: true },
|
||||
{ length: 6, internal: false },
|
||||
],
|
||||
};
|
||||
expect(evaluateOptimization(OPTIMIZATION_PRESETS.shortSequenceDeficit, observations)).toBe(
|
||||
5,
|
||||
);
|
||||
expect(
|
||||
evaluateOptimization(
|
||||
{ ...OPTIMIZATION_PRESETS.shortSequenceDeficit, thresholdDays: 3 },
|
||||
observations,
|
||||
),
|
||||
).toBe(2);
|
||||
expect(evaluateOptimization(OPTIMIZATION_PRESETS.longestWorkSequence, observations)).toBe(
|
||||
6,
|
||||
);
|
||||
});
|
||||
|
||||
it('evaluates all six recipes from the same concrete observations', () => {
|
||||
const observations: OptimizationObservations = {
|
||||
leaveBalances: [
|
||||
{ unplacedQuantity: 3, atRisk: true },
|
||||
{ unplacedQuantity: 8, atRisk: false },
|
||||
],
|
||||
publicHolidayWeeks: [
|
||||
{ value: 3, complete: true },
|
||||
{ value: 1, complete: false },
|
||||
{ value: 4, complete: true },
|
||||
],
|
||||
timeOffPeriods: [
|
||||
{ nonWorkingDates: ['2024-01-06', '2024-01-07'] },
|
||||
{ nonWorkingDates: ['2024-05-08', '2024-05-09', '2024-05-09'] },
|
||||
],
|
||||
workSequences: [
|
||||
{ length: 1, internal: false },
|
||||
{ length: 0, internal: true },
|
||||
{ length: 1, internal: true },
|
||||
{ length: 4, internal: true },
|
||||
{ length: 6, internal: false },
|
||||
],
|
||||
leavePlacements: [{ supporting: true }, { supporting: false }, { supporting: true }],
|
||||
};
|
||||
expect(
|
||||
Object.values(OPTIMIZATION_PRESETS).map((configuration) =>
|
||||
evaluateOptimization(configuration, observations),
|
||||
),
|
||||
).toEqual([3, 7, 4, 6, 5, 2]);
|
||||
});
|
||||
|
||||
it.each(['sum', 'count', 'maximum'] as const)(
|
||||
'defines empty %s as zero for every measure',
|
||||
(aggregation) => {
|
||||
for (const configuration of Object.values(OPTIMIZATION_PRESETS)) {
|
||||
expect(evaluateOptimization({ ...configuration, aggregation }, empty)).toBe(0);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('composes conditions, aggregation and direction independently of a rule name or identity', () => {
|
||||
const configuration = {
|
||||
...OPTIMIZATION_PRESETS.longestWorkSequence,
|
||||
conditions: ['internal', 'non-empty'] as const,
|
||||
aggregation: 'sum' as const,
|
||||
direction: 'maximize' as const,
|
||||
};
|
||||
const observations: OptimizationObservations = {
|
||||
...empty,
|
||||
workSequences: [
|
||||
{ length: 20, internal: false },
|
||||
{ length: 0, internal: true },
|
||||
{ length: 2, internal: true },
|
||||
{ length: 4, internal: true },
|
||||
],
|
||||
};
|
||||
const rules = [
|
||||
{ id: 'provided', name: 'Original name', configuration },
|
||||
{ id: 'personal', name: 'Renamed rule', configuration: structuredClone(configuration) },
|
||||
];
|
||||
expect(rules.map((rule) => evaluateOptimization(rule.configuration, observations))).toEqual(
|
||||
[6, 6],
|
||||
);
|
||||
expect(evaluateOptimization({ ...configuration, aggregation: 'count' }, observations)).toBe(
|
||||
2,
|
||||
);
|
||||
expect(
|
||||
evaluateOptimization({ ...configuration, aggregation: 'maximum' }, observations),
|
||||
).toBe(4);
|
||||
expect(optimizationUnit(configuration)).toBe('working-days');
|
||||
expect(optimizationUnit({ ...configuration, aggregation: 'count' })).toBe('count');
|
||||
expect(optimizationUnit(OPTIMIZATION_PRESETS.nonWorkingDayGain)).toBe('calendar-days');
|
||||
});
|
||||
|
||||
it.each([
|
||||
0,
|
||||
-1,
|
||||
1.5,
|
||||
Number.NaN,
|
||||
Number.POSITIVE_INFINITY,
|
||||
undefined,
|
||||
'3',
|
||||
Number.MAX_SAFE_INTEGER + 1,
|
||||
])('rejects the invalid working-day deficit threshold %s', (thresholdDays) => {
|
||||
expect(
|
||||
validateOptimizationConfiguration({
|
||||
...OPTIMIZATION_PRESETS.shortSequenceDeficit,
|
||||
thresholdDays,
|
||||
}),
|
||||
).toMatchObject({ valid: false, code: 'invalid-optimization-configuration' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ ...OPTIMIZATION_PRESETS.nonWorkingDayGain, elements: 'work-sequences' },
|
||||
{ ...OPTIMIZATION_PRESETS.nonWorkingDayGain, conditions: ['at-risk'] },
|
||||
{ ...OPTIMIZATION_PRESETS.longestWorkSequence, aggregation: 'average' },
|
||||
{ ...OPTIMIZATION_PRESETS.longestWorkSequence, thresholdDays: 3 },
|
||||
{ ...OPTIMIZATION_PRESETS.shortSequenceDeficit, thresholdUnit: 'calendar-days' },
|
||||
])('identifies an unsupported composition', (configuration) => {
|
||||
expect(validateOptimizationConfiguration(configuration)).toMatchObject({
|
||||
valid: false,
|
||||
code: 'unsupported-optimization-configuration',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
null,
|
||||
[],
|
||||
{ ...OPTIMIZATION_PRESETS.shortSequenceDeficit, conditions: ['internal', 'internal'] },
|
||||
{ ...OPTIMIZATION_PRESETS.longestWorkSequence, direction: 'up' },
|
||||
{ ...OPTIMIZATION_PRESETS.unplacedAtRisk, conditions: new Array(1) },
|
||||
])('rejects malformed configurations', (configuration) => {
|
||||
expect(validateOptimizationConfiguration(configuration)).toMatchObject({
|
||||
valid: false,
|
||||
code: 'invalid-optimization-configuration',
|
||||
});
|
||||
});
|
||||
|
||||
it('never evaluates an unsupported configuration as a preset', () => {
|
||||
expect(() =>
|
||||
evaluateOptimization(
|
||||
{ ...OPTIMIZATION_PRESETS.nonWorkingDayGain, elements: 'work-sequences' },
|
||||
empty,
|
||||
),
|
||||
).toThrow('unsupported-optimization-configuration');
|
||||
});
|
||||
|
||||
it('compares ordered objectives lexicographically with the configured directions', () => {
|
||||
const before = [
|
||||
{ id: 'first', direction: 'minimize' as const, value: 3 },
|
||||
{ id: 'second', direction: 'maximize' as const, value: 100 },
|
||||
{ id: 'date', direction: 'minimize' as const, value: '2024-01-10' },
|
||||
];
|
||||
const earlierPriority = [{ ...before[0], value: 2 }, { ...before[1], value: 0 }, before[2]];
|
||||
expect(compareObjectiveLists(earlierPriority, before)).toBeLessThan(0);
|
||||
expect(firstDifferentObjective(earlierPriority, before)).toBe(0);
|
||||
const laterPriority = [before[0], { ...before[1], value: 101 }, before[2]];
|
||||
expect(compareObjectiveLists(laterPriority, before)).toBeLessThan(0);
|
||||
expect(firstDifferentObjective(laterPriority, before)).toBe(1);
|
||||
const earlierDate = [before[0], before[1], { ...before[2], value: '2024-01-09' }];
|
||||
expect(compareObjectiveLists(earlierDate, before)).toBeLessThan(0);
|
||||
expect(firstDifferentObjective(earlierDate, before)).toBe(2);
|
||||
expect(compareObjectiveLists(before, structuredClone(before))).toBe(0);
|
||||
expect(firstDifferentObjective(before, structuredClone(before))).toBe(-1);
|
||||
expect(compareObjectiveValues('maximize', 0, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user