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
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user