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:
nessar committed 2026-10-10 16:07:34 +02:00
1 parent 209bf05f36
commit 6811142299
45 files changed
+8795 -3075

No files matched your search

+152 -117
View File
@@ -1,3 +1,4 @@
import { createOptimizationPresetRules } from './rule-service';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createPlanningCalculation, applyReplayDeltas } from './leave-planner.service';
@@ -15,6 +16,8 @@ afterEach(() => vi.unstubAllGlobals());
beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z')));
afterEach(() => vi.useRealTimers());
const presets = createOptimizationPresetRules();
describe('planner explanation and trace seam', () => {
it('replays a reported occurrence without borrowing its future balance from the final plan', async () => {
vi.setSystemTime(new Date('2024-05-03T12:00:00Z'));
@@ -180,7 +183,7 @@ describe('planner explanation and trace seam', () => {
},
);
it('removes an entitlement from the original replay when a real quality move shortens its generator into today', async () => {
it('replays an already-reported entitlement without inventing a transient earned balance under explicit priorities', async () => {
vi.setSystemTime(new Date('2024-05-06T12:00:00Z'));
const core = await import('./leave-planner.core');
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
@@ -201,17 +204,21 @@ describe('planner explanation and trace seam', () => {
{ date: '2024-05-03', type: 'cp' },
],
rules: [
{
id: 'producer',
type: 'if_then',
force: 'preference',
dayOfWeek: 3,
placeDays: 2,
placeType: 'cp',
placement: 'after',
rewardDays: 1,
rewardType: 'other',
},
presets[0],
...[
{
id: 'producer',
type: 'if_then',
force: 'preference',
dayOfWeek: 3,
placeDays: 2,
placeType: 'cp',
placement: 'after',
rewardDays: 1,
rewardType: 'other',
},
],
...presets.slice(1),
],
},
undefined,
@@ -226,11 +233,16 @@ describe('planner explanation and trace seam', () => {
added: false,
});
const decisions = artifacts.trace.phases.flatMap(({ decisions }) => decisions);
expect(decisions.flatMap(({ deltas }) => deltas)).toContainEqual({
kind: 'balance-removed',
balanceId: 'earned:producer:2024-05-01',
remainingBefore: 1,
});
const deltas = decisions.flatMap(({ deltas }) => deltas);
expect(deltas).toContainEqual(
expect.objectContaining({ kind: 'balance-changed', balanceId: 'cp' }),
);
expect(deltas).not.toContainEqual(
expect.objectContaining({
kind: 'balance-changed',
balanceId: 'earned:producer:2024-05-01',
}),
);
const initial = artifacts.trace.initialState;
const replay = {
balances: initial.balances.map((balance) => ({ ...balance })),
@@ -355,14 +367,16 @@ describe('planner explanation and trace seam', () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-01-08' },
balances: [balance('cp', 'cp', 2, '2024-01-07')],
rules: [],
rules: [presets[0]],
});
const outcome = await calculation.outcome;
const trace = await calculation.trace;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan' || !trace) return;
const atRisk = trace.phases.find((phase) => phase.name === 'at-risk');
const atRisk = trace.phases.find(
(phase) => phase.name === 'optimization:default-unplaced-at-risk',
);
const jan2 = atRisk?.decisions.find((decision) =>
decision.deltas.some(
(delta) => delta.kind === 'placement-added' && delta.date === '2024-01-02',
@@ -374,22 +388,17 @@ describe('planner explanation and trace seam', () => {
),
);
expect(jan2?.candidates.some((candidate) => candidate.status === 'chosen')).toBe(true);
expect(jan2?.candidates[0]).toMatchObject({
expect(jan2?.candidates.find((candidate) => candidate.status === 'chosen')).toMatchObject({
technicalComparison: [
{ name: 'obligation-validity', direction: 'minimize', before: 0, after: 0 },
{
name: 'preference-date-conflict',
name: `optimization:${presets[0].id}`,
direction: 'minimize',
before: 0,
after: 0,
},
{
name: 'placement-date',
direction: 'minimize',
before: '2024-01-02',
after: '2024-01-03',
before: 2,
after: 1,
},
],
firstDecisiveComparator: 'placement-date',
firstDecisiveComparator: `optimization:${presets[0].id}`,
});
expect(jan2?.deltas).toContainEqual(
expect.objectContaining({
@@ -399,22 +408,17 @@ describe('planner explanation and trace seam', () => {
}),
);
expect(jan3?.candidates.some((candidate) => candidate.status === 'chosen')).toBe(true);
expect(jan3?.candidates[0]).toMatchObject({
expect(jan3?.candidates.find((candidate) => candidate.status === 'chosen')).toMatchObject({
technicalComparison: [
{ name: 'obligation-validity', direction: 'minimize', before: 0, after: 0 },
{
name: 'preference-date-conflict',
name: `optimization:${presets[0].id}`,
direction: 'minimize',
before: 0,
before: 1,
after: 0,
},
{
name: 'placement-date',
direction: 'minimize',
before: '2024-01-03',
after: '2024-01-04',
},
],
firstDecisiveComparator: 'placement-date',
firstDecisiveComparator: `optimization:${presets[0].id}`,
});
expect(jan3?.deltas).toContainEqual(
expect.objectContaining({
@@ -541,29 +545,39 @@ describe('planner explanation and trace seam', () => {
?.decisions.flatMap((decision) => decision.candidates)
.find((item) => item.status === 'chosen' && item.date === '2024-01-03');
expect(candidate).toMatchObject({
technicalComparison: [
{
name: 'shared-placement-type',
direction: 'maximize',
before: 0,
after: 0,
},
{
name: 'protected-preference-type',
direction: 'maximize',
before: 1,
after: 1,
},
{
name: 'balance-expiry',
direction: 'minimize',
before: '2025-01-05',
after: '2025-01-06',
},
],
firstDecisiveComparator: 'balance-expiry',
firstDecisiveComparator: 'preference:rtt-preference',
placement: expect.objectContaining({ balanceId: 'rtt-first' }),
});
const compared = trace.phases
.find((phase) => phase.name === 'preference:any-preference')
?.decisions.flatMap((decision) => decision.candidates);
expect(compared).toContainEqual(
expect.objectContaining({
status: 'evaluated-rejected',
placement: expect.objectContaining({ balanceId: 'rtt-second' }),
technicalComparison: expect.arrayContaining([
{
name: 'preference:any-preference',
direction: 'maximize',
before: 1,
after: 1,
},
{
name: 'preference:rtt-preference',
direction: 'maximize',
before: 1,
after: 1,
},
{
name: 'balance-expiry',
direction: 'minimize',
before: '2025-01-05',
after: '2025-01-06',
},
]),
firstDecisiveComparator: 'balance-expiry',
}),
);
});
it('records actual IfThen leaf comparisons and selected branch identity', async () => {
@@ -593,8 +607,8 @@ describe('planner explanation and trace seam', () => {
.flatMap((decision) =>
decision.candidates
.filter((candidate) =>
candidate.technicalComparison?.some((item) =>
item.name.startsWith('complete-occurrences:if-then-preference'),
candidate.technicalComparison?.some(
(item) => item.name === 'preference:if-then-preference',
),
)
.map((candidate) => ({ candidate, decision })),
@@ -605,8 +619,8 @@ describe('planner explanation and trace seam', () => {
.find((phase) => phase.name === 'preference:if-then-preference')
?.decisions.flatMap((decision) => decision.candidates)
.filter((candidate) =>
candidate.technicalComparison?.some((item) =>
item.name.startsWith('complete-occurrences:if-then-preference'),
candidate.technicalComparison?.some(
(item) => item.name === 'preference:if-then-preference',
),
) ?? [];
expect(preferenceLeaves.length).toBeGreaterThan(1);
@@ -620,16 +634,14 @@ describe('planner explanation and trace seam', () => {
expect(chosen?.candidate.technicalComparison).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'complete-occurrences:if-then-preference',
name: 'preference:if-then-preference',
direction: 'maximize',
}),
expect.objectContaining({
name: 'future-pattern-conflict-score:if-then-preference',
direction: 'minimize',
}),
]),
);
expect(chosen?.decision.branch?.deltas.length).toBeGreaterThan(0);
expect(
(chosen?.decision.branch?.deltas ?? chosen?.decision.deltas ?? []).length,
).toBeGreaterThan(0);
expect(
chosen?.candidate.dates?.every((date) =>
outcome.placements.some((p) => p.date === date),
@@ -749,46 +761,52 @@ describe('planner explanation and trace seam', () => {
horizon: { start: '2024-04-25', end: '2024-05-08' },
balances: [
balance('cp1', 'cp', 1, '2025-12-31'),
balance('cp2', 'cp', 2, '2024-05-07'),
{ ...balance('cp2', 'cp', 2, '2024-05-07'), availableFrom: '2024-04-26' },
balance('rtt', 'rtt', 0, '2026-01-01'),
],
rules: [
{
id: 'anchor',
type: 'time_window',
force: 'obligation',
startDate: '2024-04-25',
endDate: '2024-04-25',
placeDays: 1,
placeType: 'cp',
},
{
id: 'producer',
type: 'if_then',
force: 'obligation',
dayOfWeek: 3,
placeDays: 1,
placeType: 'cp',
placement: 'after',
rewardDays: 1,
rewardType: 'rtt',
},
{
id: 'consumer',
type: 'time_window',
force: 'obligation',
startDate: '2024-05-06',
endDate: '2024-05-06',
placeDays: 1,
placeType: 'rtt',
},
presets[0],
...[
{
id: 'anchor',
type: 'time_window',
force: 'obligation',
startDate: '2024-04-25',
endDate: '2024-04-25',
placeDays: 1,
placeType: 'cp',
},
{
id: 'producer',
type: 'if_then',
force: 'obligation',
dayOfWeek: 3,
placeDays: 1,
placeType: 'cp',
placement: 'after',
rewardDays: 1,
rewardType: 'rtt',
},
{
id: 'consumer',
type: 'time_window',
force: 'obligation',
startDate: '2024-05-06',
endDate: '2024-05-06',
placeDays: 1,
placeType: 'rtt',
},
],
...presets.slice(1),
],
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan') return;
const atRisk = outcome.placements.find(
(placement) => placement.balanceId === 'cp2' && !placement.ruleIds.length,
(placement) =>
placement.balanceId === 'cp2' &&
placement.ruleIds.every((id) => id.startsWith('default-')),
);
expect(atRisk?.date).toBeDefined();
if (!atRisk) return;
@@ -1006,7 +1024,11 @@ describe('planner explanation and trace seam', () => {
expect((await calculation.explanation)?.kind).toBe('ConflictExplanation');
const trace = await calculation.trace;
expect(trace?.phases.some((phase) => phase.name === 'obligations')).toBe(true);
expect(trace?.phases.some((phase) => phase.name === 'holiday-week-value')).toBe(false);
expect(
trace?.phases.some(
(phase) => phase.name === 'optimization:default-holiday-week-value',
),
).toBe(false);
if (index === 1)
expect(
trace?.phases
@@ -1076,9 +1098,10 @@ describe('planner explanation and trace seam', () => {
const request = {
horizon: { start: '2024-01-01', end: '2024-01-10' },
balances: [balance('cp', 'cp', 2, '2024-01-31')],
rules: [],
rules: presets,
progress: (stage: string) => {
if (stage === 'holiday-week-value') throw new Error('interrupted after checkpoint');
if (stage === 'optimization:default-holiday-week-value')
throw new Error('interrupted after checkpoint');
},
};
const outcome = core.calculatePlanningInternal(request, (plan, state) => {
@@ -1128,8 +1151,8 @@ describe('planner explanation and trace seam', () => {
const request = {
horizon: { start: '2024-01-01', end: '2024-01-15' },
balances: [balance('cp', 'cp', 3, '2024-01-31')],
rules: [],
deadlineMs: 1_000,
rules: presets,
deadlineMs: 100_000,
};
const plain = core.calculatePlanningInternal(request);
clock = 0;
@@ -1156,6 +1179,7 @@ describe('planner explanation and trace seam', () => {
expect(artifacts?.trace?.status).toBe('ready');
expect(artifacts?.timings?.planningWorkMs).toBeLessThan(request.deadlineMs);
expect(artifacts?.timings?.traceTransferMs).toBeGreaterThanOrEqual(callbackCount * 10_000);
expect(artifacts?.timings?.traceTransferMs).toBeGreaterThan(request.deadlineMs);
const decisions = artifacts?.trace?.phases.flatMap((phase) => phase.decisions) ?? [];
const candidateUpdates = operations.filter((operation) => operation.kind === 'candidate');
expect(candidateUpdates.some((operation) => operation.mode === 'replace')).toBe(true);
@@ -1180,7 +1204,7 @@ describe('planner explanation and trace seam', () => {
const request = {
horizon: { start: '2024-01-01', end: '2024-01-08' },
balances: [balance('cp', 'cp', 2, '2024-01-07')],
rules: [],
rules: [presets[0]],
};
let plainArtifacts: import('./leave-planner.core').PlanningArtifacts | undefined;
const plain = core.calculatePlanningInternal(request, undefined, (value) => {
@@ -1210,6 +1234,7 @@ describe('planner explanation and trace seam', () => {
candidate,
})),
);
expect(candidates.length).toBeGreaterThan(0);
const candidateOperations = operations.filter(
(operation): operation is Extract<typeof operation, { kind: 'candidate' }> =>
operation.kind === 'candidate',
@@ -1232,7 +1257,7 @@ describe('planner explanation and trace seam', () => {
const early = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-01-10' },
balances: [balance('cp', 'cp', 1, '2024-01-31')],
rules: [],
rules: presets,
deadlineMs: 0,
});
const earlyOutcome = await early.outcome;
@@ -1247,16 +1272,18 @@ describe('planner explanation and trace seam', () => {
expect(earlyTrace?.phases.find((phase) => phase.name === 'obligations')?.status).toBe(
'interrupted',
);
expect(earlyTrace?.phases.find((phase) => phase.name === 'at-risk')?.status).toBe(
'not-run',
);
expect(
earlyTrace?.phases.find(
(phase) => phase.name === 'optimization:default-unplaced-at-risk',
)?.status,
).toBe('not-run');
const quality = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-02-01' },
balances: [balance('cp', 'cp', 1, '2024-01-31')],
rules: [],
rules: presets,
progress: (stage) => {
if (stage === 'holiday-week-value') {
if (stage === 'optimization:default-holiday-week-value') {
throw new Error('deliberate interruption in quality phase');
}
},
@@ -1266,13 +1293,21 @@ describe('planner explanation and trace seam', () => {
expect(qualityOutcome.kind).toBe('PlanningIncomplete');
if (qualityOutcome.kind !== 'PlanningIncomplete') return;
expect(qualityOutcome.reasons[0].code).toBe('planner-error');
expect(qualityOutcome.completedStages).toEqual(['validation', 'obligations', 'at-risk']);
expect(qualityOutcome.completedStages).toEqual([
'validation',
'obligations',
'optimization:default-unplaced-at-risk',
]);
expect(qualityTrace?.completedStages).toEqual(qualityOutcome.completedStages);
expect(
qualityTrace?.phases.find((phase) => phase.name === 'holiday-week-value')?.status,
qualityTrace?.phases.find(
(phase) => phase.name === 'optimization:default-holiday-week-value',
)?.status,
).toBe('interrupted');
expect(
qualityTrace?.phases.find((phase) => phase.name === 'non-working-day-gain')?.status,
qualityTrace?.phases.find(
(phase) => phase.name === 'optimization:default-non-working-day-gain',
)?.status,
).toBe('not-run');
});
});