feat(planning): explain and trace planning decisions

This commit is contained in:
nessar committed 2026-08-31 00:02:45 +02:00
1 parent 609b3dece9
commit d7a13b0b1e
24 files changed
+6073 -342

No files matched your search

+642 -1
View File
@@ -1,5 +1,10 @@
import { vi } from 'vitest';
import { PlanningOutcome, PlanningRequest, planLeave } from './leave-planner.service';
import {
PlanningOutcome,
PlanningRequest,
createPlanningCalculation,
planLeave,
} from './leave-planner.service';
const horizon = { start: '2024-01-01', end: '2025-01-01' };
const balance = (
@@ -1113,6 +1118,43 @@ describe('planLeave transport', () => {
}
});
it('does not charge reported trace instrumentation time to the planning deadline', async () => {
const expected = isPlan(
await runInNonBrowser({ horizon, balances: [balance('cp', 'cp', 1)], rules: [] }),
);
class TraceAwareWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
postMessage(): void {
this.onmessage?.({ data: { type: 'trace-overhead', totalMs: 30 } } as MessageEvent);
setTimeout(() => {
this.onmessage?.({
data: { type: 'result', outcome: expected },
} as MessageEvent);
this.onmessage?.({
data: { type: 'artifacts', artifacts: {} },
} as MessageEvent);
}, 15);
}
terminate(): void {}
}
vi.stubGlobal('Worker', TraceAwareWorker);
try {
expect(
await planLeave({
horizon,
balances: [balance('cp', 'cp', 1)],
rules: [],
deadlineMs: 10,
}),
).toEqual(expected);
} finally {
vi.unstubAllGlobals();
}
});
it('sends a detached, callback-free request to the worker transport', async () => {
const sourceDate = new Date('2024-01-01T00:00:00Z');
const request = {
@@ -1197,3 +1239,602 @@ describe('planLeave transport', () => {
}
});
});
describe('planning calculation context', () => {
it('keeps the planning outcome identical when trace instrumentation is enabled', async () => {
const core = await import('./leave-planner.core');
const request = {
horizon: { start: '2024-01-01', end: '2024-02-01' },
balances: [balance('cp', 'cp', 3, '2024-01-31')],
rules: [],
};
const withoutTrace = core.calculatePlanningInternal(request);
let traceStatus: string | undefined;
const withTrace = core.calculatePlanningInternal(request, undefined, (artifacts) => {
traceStatus = artifacts.trace?.status;
});
expect(withTrace).toEqual(withoutTrace);
expect(traceStatus).toBe('ready');
});
it('keeps an immutable submitted snapshot beside the outcome', async () => {
const request = {
horizon,
balances: [balance('cp', 'cp', 1)],
rules: [],
labels: { cp: 'Paid leave' },
};
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
const calculation = createPlanningCalculation(request);
request.balances[0].quantity = 99;
request.labels.cp = 'Changed later';
expect(calculation.snapshot.balances[0].quantity).toBe(1);
expect(calculation.snapshot.labels['cp']).toBe('Paid leave');
expect((await calculation.outcome).kind).toBe('Plan');
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('materializes a chronological explanation after the unchanged outcome', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-01-15' },
balances: [
{
...balance('cp', 'cp', 1, '2026-01-01'),
name: 'Paid leave entitlement',
},
],
rules: [
{
id: 'winter',
name: 'Winter break',
type: 'time_window',
startDate: '2024-01-02',
endDate: '2024-01-12',
placeDays: 1,
placeType: 'cp',
},
],
labels: { winter: 'Winter break' },
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan') return;
expect('explanation' in outcome).toBe(false);
const explanation = await calculation.explanation;
expect(explanation?.kind).toBe('PlanExplanation');
if (explanation?.kind !== 'PlanExplanation') return;
expect(explanation.placements).toHaveLength(outcome.placements.length);
expect(explanation.placements[0].balance?.name).toBe('Paid leave entitlement');
expect(explanation.placements[0].ruleIds).toContain('winter');
expect(explanation.placements[0].objectiveVector.objectives.length).toBeGreaterThan(1);
expect('score' in explanation.placements[0].objectiveVector).toBe(false);
expect(explanation.placements[0].firstDecisiveObjective).toBe(
explanation.placements[0].comparison.firstDecisiveObjective,
);
expect(explanation.placements[0].firstDecisiveObjective).toBe('preference:winter');
const alternatives = await calculation.analyzeCounterfactual(outcome.placements[0]);
expect(alternatives.kind).toBe('Counterfactual');
if (alternatives.kind === 'Counterfactual')
expect(
alternatives.results.find(
(result) => result.date === outcome.placements[0].date,
)?.comparison?.firstDecisiveObjective,
).toBe('preference:winter');
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('reports every rule contribution when one placement is shared', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-01-06' },
balances: [balance('cp', 'cp', 1)],
rules: [
{
id: 'wide',
name: 'Wide window',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-02',
endDate: '2024-01-05',
placeDays: 1,
placeType: 'cp',
},
{
id: 'exact',
name: 'Exact day',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-05',
endDate: '2024-01-05',
placeDays: 1,
placeType: 'cp',
},
],
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('Plan');
const explanation = await calculation.explanation;
expect(explanation?.kind).toBe('PlanExplanation');
if (explanation?.kind !== 'PlanExplanation') return;
expect(
explanation.placements[0].contributions
.filter((contribution) => contribution.kind === 'rule')
.map((contribution) => contribution.ruleId),
).toEqual(['exact', 'wide']);
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('compares the selected placement across the half-open horizon without changing the plan', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-01-10' },
balances: [balance('cp', 'cp', 1, '2024-12-31')],
rules: [],
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan') return;
const before = structuredClone(outcome);
const analysis = await calculation.analyzeCounterfactual(outcome.placements[0]);
expect(analysis.kind).toBe('Counterfactual');
if (analysis.kind !== 'Counterfactual') return;
expect(analysis.results).toHaveLength(9);
expect(analysis.results.some((result) => result.reason?.code === 'non-working')).toBe(
true,
);
expect(
analysis.results.some(
(result) => result.status === 'eligible' && result.comparison,
),
).toBe(true);
expect(outcome).toEqual(before);
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('preserves supporting provenance in counterfactual placements', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-05', end: '2024-01-10' },
balances: [balance('cp', 'cp', 1, '2025-01-01')],
rules: [],
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan') return;
const selected = outcome.placements.find((placement) => placement.supporting);
expect(selected).toBeDefined();
if (!selected) return;
const analysis = await calculation.analyzeCounterfactual(selected);
expect(analysis.kind).toBe('Counterfactual');
if (analysis.kind !== 'Counterfactual') return;
const restored = analysis.results.find((result) => result.date === selected.date);
expect(
restored?.comparison?.after.objectives.find(
(objective) => objective.id === 'supporting-placements',
)?.value,
).toBe(1);
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('discards counterfactual work from an older calculation', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
const older = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-01-10' },
balances: [balance('cp', 'cp', 1)],
rules: [],
});
const olderOutcome = await older.outcome;
expect(olderOutcome.kind).toBe('Plan');
if (olderOutcome.kind !== 'Plan') return;
createPlanningCalculation({ horizon, balances: [], rules: [] });
const analysis = await older.analyzeCounterfactual(olderOutcome.placements[0]);
expect(analysis.kind).toBe('CounterfactualStale');
expect(analysis.results).toEqual([]);
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('keeps earned-entitlement availability and provenance in counterfactuals', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
const calculation = createPlanningCalculation({
horizon: { start: '2024-04-25', end: '2024-05-15' },
balances: [balance('cp', 'cp', 2), balance('rtt', 'rtt', 0, '2026-01-01')],
rules: [
{
id: 'wednesday-holidays',
type: 'if_then',
force: 'obligation',
dayOfWeek: 3,
placeDays: 1,
placeType: 'cp',
placement: 'after',
rewardDays: 1,
rewardType: 'rtt',
},
{
id: 'thursday-consumer',
type: 'if_then',
force: 'obligation',
dayOfWeek: 4,
placeDays: 1,
placeType: 'rtt',
placement: 'before',
rewardDays: 0,
},
],
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan') return;
const earned = outcome.placements.find((placement) =>
placement.balanceId.startsWith('earned:'),
);
expect(earned).toBeDefined();
if (!earned) return;
const analysis = await calculation.analyzeCounterfactual(earned);
expect(analysis.kind).toBe('Counterfactual');
if (analysis.kind !== 'Counterfactual') return;
expect(analysis.placement).toEqual(earned);
expect(
analysis.results.find((result) => result.date === '2024-04-25')?.reason?.code,
).toBe('unavailable');
const trace = await calculation.trace;
const earnedBalanceDelta = trace?.phases
.flatMap((phase) => phase.decisions)
.flatMap((decision) => decision.deltas)
.find((delta) => delta.balance?.id === earned.balanceId);
expect(earnedBalanceDelta?.balance).toMatchObject({
id: earned.balanceId,
type: earned.type,
earned: true,
});
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('replays a trace decision from its pre-decision state', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-02-01' },
balances: [balance('cp', 'cp', 2, '2024-01-31')],
rules: [
{
id: 'wide',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-01',
endDate: '2024-01-31',
placeDays: 1,
placeType: 'cp',
},
],
});
const outcome = await calculation.outcome;
const trace = await calculation.trace;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan' || !trace) return;
const decision = trace.phases
.flatMap((phase) => phase.decisions)
.find((candidate) =>
candidate.deltas.some((delta) => delta.kind === 'placement-added'),
);
const placement = decision?.deltas.find(
(delta) => delta.kind === 'placement-added',
)?.placement;
expect(decision).toBeDefined();
expect(placement).toBeDefined();
if (!decision || !placement) return;
const laterPlacement = outcome.placements.find(
(candidate) => candidate.date !== placement.date,
);
expect(laterPlacement).toBeDefined();
if (!laterPlacement) return;
const analysis = await calculation.analyzeCounterfactual({
decisionId: decision.id,
placement,
});
expect(analysis.kind).toBe('Counterfactual');
if (analysis.kind !== 'Counterfactual') return;
expect(
analysis.results.find((result) => result.date === laterPlacement.date)?.status,
).toBe('eligible');
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('reconstructs the returned plan from the original trace deltas', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-02-01' },
balances: [balance('cp', 'cp', 4, '2024-01-31')],
rules: [],
});
const outcome = await calculation.outcome;
const trace = await calculation.trace;
expect(outcome.kind).toBe('Plan');
expect(trace?.status).toBe('ready');
if (outcome.kind !== 'Plan' || !trace) return;
const placements = new Map(
trace.initialState.placements.map((placement) => [placement.date, placement]),
);
for (const delta of trace.phases.flatMap((phase) =>
phase.decisions.flatMap((decision) => decision.deltas),
)) {
if (delta.previousPlacement) placements.delete(delta.previousPlacement.date);
if (delta.placement) placements.set(delta.placement.date, delta.placement);
}
expect([...placements.values()].sort((a, b) => a.date.localeCompare(b.date))).toEqual(
outcome.placements,
);
expect(trace.phases.map((phase) => phase.name)).toEqual(
expect.arrayContaining([
'obligations',
'at-risk',
'holiday-week-value',
'non-working-day-gain',
'longest-work-sequence',
'short-sequence-deficit',
'supporting-placements',
'expiry',
'dates',
]),
);
expect(
trace.phases
.flatMap((phase) => phase.decisions)
.flatMap((decision) => decision.candidates).length,
).toBeGreaterThan(20);
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('records loaded-plan pruning and full quality comparisons without truncation', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-04-01' },
balances: [balance('cp', 'cp', 25, '2024-03-31')],
rules: [],
});
expect((await calculation.outcome).kind).toBe('Plan');
const trace = await calculation.trace;
const candidates =
trace?.phases.flatMap((phase) =>
phase.decisions.flatMap((decision) => decision.candidates),
) ?? [];
expect(
candidates.some(
(candidate) =>
candidate.status === 'not-examined' &&
candidate.reason?.code === 'loaded-plan-sampling',
),
).toBe(true);
const comparison = candidates.find((candidate) => candidate.comparison)?.comparison;
expect(comparison?.before.objectives.length).toBeGreaterThan(1);
expect(comparison?.after.objectives.length).toBe(comparison?.before.objectives.length);
expect(
candidates.some((candidate) => candidate.comparison && candidate.sourceDate),
).toBe(true);
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('offers no explanation trace for invalid input and explains conflicts', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
const invalidCalculation = createPlanningCalculation({
horizon: { start: '2025-01-01', end: '2024-01-01' },
balances: [],
rules: [],
});
expect((await invalidCalculation.outcome).kind).toBe('InvalidInput');
expect(await invalidCalculation.explanation).toBeUndefined();
expect(await invalidCalculation.trace).toBeUndefined();
const conflictCalculation = createPlanningCalculation({
horizon,
balances: [balance('rtt', 'rtt', 1)],
rules: [
{
id: 'cp-only',
name: 'Paid leave only',
type: 'time_window',
force: 'obligation',
startDate: '2024-04-01',
endDate: '2024-04-05',
placeDays: 1,
placeType: 'cp',
},
],
});
expect((await conflictCalculation.outcome).kind).toBe('PlanningConflict');
const explanation = await conflictCalculation.explanation;
expect(explanation?.kind).toBe('ConflictExplanation');
if (explanation?.kind === 'ConflictExplanation')
expect(explanation.conflictingRules).toEqual([
{ id: 'cp-only', label: 'Paid leave only' },
]);
const trace = await conflictCalculation.trace;
expect(trace?.phases.some((phase) => phase.name === 'holiday-week-value')).toBe(false);
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('marks interrupted and unexecuted phases when no incumbent exists', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
const calculation = createPlanningCalculation({
horizon,
balances: [balance('cp', 'cp', 5)],
rules: [],
deadlineMs: 0,
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('PlanningIncomplete');
const explanation = await calculation.explanation;
expect(explanation?.kind).toBe('IncompleteExplanation');
const trace = await calculation.trace;
expect(trace?.phases.some((phase) => phase.status === 'interrupted')).toBe(true);
expect(trace?.phases.some((phase) => phase.status === 'not-run')).toBe(true);
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('explains an incomplete incumbent without reusing the expired planning deadline', async () => {
const core = await import('./leave-planner.core');
let artifacts: import('./leave-planner.core').PlanningArtifacts | undefined;
const outcome = core.calculatePlanningInternal(
{
horizon,
balances: Array.from({ length: 12 }, (_, index) =>
balance(`balance-${index}`, index % 2 ? 'rtt' : 'cp', 12),
),
rules: [],
deadlineMs: 20,
},
undefined,
(value) => (artifacts = value),
);
expect(outcome.kind).toBe('PlanningIncomplete');
if (outcome.kind !== 'PlanningIncomplete') return;
expect(outcome.incumbent).toBeDefined();
expect(artifacts?.explanation?.kind).toBe('PlanExplanation');
if (artifacts?.explanation?.kind !== 'PlanExplanation') return;
expect(artifacts.explanation.incomplete?.reason.code).toBe('deadline');
expect(artifacts.explanation.placements).toHaveLength(
outcome.incumbent?.placements.length ?? 0,
);
});
it('publishes the worker outcome before delayed explanation artifacts', async () => {
const expected = isPlan(
await runInNonBrowser({ horizon, balances: [balance('cp', 'cp', 1)], rules: [] }),
);
const trace = {
status: 'ready' as const,
initialState: {
horizon,
balances: [],
placements: [],
earnedActivations: [],
},
phases: [],
completedStages: [],
};
class DelayedArtifactsWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
postMessage(): void {
queueMicrotask(() =>
this.onmessage?.({
data: { type: 'result', outcome: expected },
} as MessageEvent),
);
setTimeout(
() =>
this.onmessage?.({
data: { type: 'artifacts', artifacts: { trace } },
} as MessageEvent),
20,
);
}
terminate(): void {}
}
vi.stubGlobal('Worker', DelayedArtifactsWorker);
try {
const calculation = createPlanningCalculation({
horizon,
balances: [balance('cp', 'cp', 1)],
rules: [],
});
let traceReady = false;
void calculation.trace.then(() => (traceReady = true));
expect((await calculation.outcome).kind).toBe('Plan');
expect(traceReady).toBe(false);
expect((await calculation.trace)?.status).toBe('ready');
} finally {
vi.unstubAllGlobals();
}
});
it('keeps a valid outcome when trace delivery fails afterward', async () => {
const expected = isPlan(
await runInNonBrowser({ horizon, balances: [balance('cp', 'cp', 1)], rules: [] }),
);
class FailingArtifactsWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
postMessage(): void {
queueMicrotask(() => {
this.onmessage?.({
data: { type: 'result', outcome: expected },
} as MessageEvent);
queueMicrotask(() => this.onerror?.());
});
}
terminate(): void {}
}
vi.stubGlobal('Worker', FailingArtifactsWorker);
try {
const calculation = createPlanningCalculation({
horizon,
balances: [balance('cp', 'cp', 1)],
rules: [],
});
expect(await calculation.outcome).toEqual(expected);
expect((await calculation.trace)?.status).toBe('failed');
expect((await calculation.explanationError)?.code).toBe('worker-error');
} finally {
vi.unstubAllGlobals();
}
});
});