fix(planning): complete explanation and trace behavior (#46)

This commit is contained in:
nessar committed 2026-10-02 15:05:30 +02:00
1 parent 57a39c81ce
commit c9dbe82b79
10 files changed
+925 -140

No files matched your search

+330 -4
View File
@@ -1410,6 +1410,123 @@ describe('planning calculation context', () => {
}
});
it('keeps every mandatory IF_THEN occurrence satisfied 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, '2024-12-31')],
rules: [
{
id: 'wednesday-holidays',
type: 'if_then',
force: 'obligation',
dayOfWeek: 3,
placeDays: 1,
placeType: 'cp',
placement: 'auto',
rewardDays: 0,
},
],
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan') return;
const firstOccurrence = outcome.placements.find(
(placement) => placement.date < '2024-05-05',
);
const secondOccurrence = outcome.placements.find(
(placement) => placement.date > '2024-05-05',
);
expect(firstOccurrence).toBeDefined();
expect(secondOccurrence).toBeDefined();
if (!firstOccurrence || !secondOccurrence) return;
const alternateSecondDate =
secondOccurrence.date === '2024-05-07' ? '2024-05-09' : '2024-05-07';
const analysis = await calculation.analyzeCounterfactual(firstOccurrence);
expect(analysis.kind).toBe('Counterfactual');
if (analysis.kind !== 'Counterfactual') return;
expect(
analysis.results.find((result) => result.date === alternateSecondDate),
).toMatchObject({
status: 'ineligible',
reason: { code: 'rule-invalid' },
});
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('allows trace counterfactuals while later obligations are still pending', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
const calculation = createPlanningCalculation({
horizon: { start: '2024-05-01', end: '2024-05-10' },
balances: [balance('cp', 'cp', 2, '2024-12-31')],
rules: [
{
id: 'early-window',
type: 'time_window',
force: 'obligation',
startDate: '2024-05-02',
endDate: '2024-05-03',
placeDays: 1,
placeType: 'cp',
},
{
id: 'late-window',
type: 'time_window',
force: 'obligation',
startDate: '2024-05-06',
endDate: '2024-05-07',
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 earlyPlacement = outcome.placements.find((placement) =>
placement.ruleIds.includes('early-window'),
);
expect(earlyPlacement).toBeDefined();
if (!earlyPlacement) return;
const decision = trace.phases
.flatMap((phase) => phase.decisions)
.find((candidate) =>
candidate.deltas.some(
(delta) =>
delta.kind === 'placement-added' &&
delta.placement?.ruleIds.includes('early-window'),
),
);
expect(decision).toBeDefined();
if (!decision) return;
const analysis = await calculation.analyzeCounterfactual({
decisionId: decision.id,
placement: earlyPlacement,
});
expect(analysis.kind).toBe('Counterfactual');
if (analysis.kind !== 'Counterfactual') return;
expect(
['2024-05-02', '2024-05-03'].map(
(date) => analysis.results.find((result) => result.date === date)?.status,
),
).toEqual(['eligible', 'eligible']);
expect(
analysis.results.find((result) => result.date === earlyPlacement.date)?.status,
).toBe('eligible');
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('preserves supporting provenance in counterfactual placements', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
@@ -1440,6 +1557,97 @@ describe('planning calculation context', () => {
}
});
it('cancels superseded planning and trace workers without changing the latest result', async () => {
const browserWindow = globalThis.window;
const expected = isPlan(
await runInNonBrowser({
horizon: { start: '2024-01-01', end: '2024-01-10' },
balances: [balance('cp', 'cp', 1)],
rules: [],
}),
);
const trace = {
status: 'ready' as const,
initialState: {
horizon: expected.horizon,
balances: [],
placements: [],
earnedActivations: [],
},
phases: [],
completedStages: [],
};
const workers: Array<{ terminated: boolean }> = [];
class SupersededWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
terminated = false;
constructor() {
workers.push(this);
}
postMessage(): void {
if (workers.length === 2) {
this.onmessage?.({
data: { type: 'result', outcome: expected },
} as MessageEvent);
return;
}
if (workers.length !== 3) return;
queueMicrotask(() => {
this.onmessage?.({
data: { type: 'result', outcome: expected },
} as MessageEvent);
this.onmessage?.({
data: { type: 'artifacts', artifacts: { trace } },
} as MessageEvent);
});
}
terminate(): void {
this.terminated = true;
}
}
vi.stubGlobal('window', {});
vi.stubGlobal('Worker', SupersededWorker);
try {
const pending = createPlanningCalculation({
horizon: expected.horizon,
balances: [balance('cp', 'cp', 1)],
rules: [],
});
const materializing = createPlanningCalculation({
horizon: expected.horizon,
balances: [balance('cp', 'cp', 1)],
rules: [],
});
const latest = createPlanningCalculation({
horizon: expected.horizon,
balances: [balance('cp', 'cp', 1)],
rules: [],
});
expect(workers).toHaveLength(3);
expect(workers[0].terminated).toBe(true);
expect(workers[1].terminated).toBe(true);
const pendingOutcome = await pending.outcome;
expect(pendingOutcome.kind).toBe('PlanningIncomplete');
if (pendingOutcome.kind === 'PlanningIncomplete')
expect(pendingOutcome.reasons[0].code).toBe('stale-calculation');
expect((await pending.trace)?.error?.code).toBe('stale-calculation');
expect(await materializing.outcome).toEqual(expected);
expect((await materializing.trace)?.error?.code).toBe('stale-calculation');
expect(await materializing.explanationError).toBeUndefined();
expect(await latest.outcome).toEqual(expected);
expect((await latest.trace)?.status).toBe('ready');
expect(workers[2].terminated).toBe(true);
} finally {
vi.unstubAllGlobals();
vi.stubGlobal('window', browserWindow);
}
});
it('discards counterfactual work from an older calculation', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
@@ -1572,6 +1780,89 @@ describe('planning calculation context', () => {
expect(
analysis.results.find((result) => result.date === laterPlacement.date)?.status,
).toBe('eligible');
expect(
analysis.results.find((result) => result.date === '2024-01-06')?.historicalStatus,
).toBe('ineligible');
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('does not inherit unrelated rule-invalid history into a trace decision', 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-01-31')],
rules: [
{
id: 'early-window',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-02',
endDate: '2024-01-04',
placeDays: 1,
placeType: 'cp',
},
{
id: 'late-window',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-03',
endDate: '2024-01-05',
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 candidates = trace.phases.flatMap((phase) =>
phase.decisions.flatMap((item) => item.candidates),
);
const decisionDates = new Set(
decision.candidates.flatMap(
(candidate) => candidate.dates ?? (candidate.date ? [candidate.date] : []),
),
);
const unrelatedIneligible = candidates.find(
(candidate) =>
candidate.status === 'ineligible' &&
candidate.reason?.code === 'rule-invalid' &&
candidate.date &&
!decisionDates.has(candidate.date),
);
expect(unrelatedIneligible).toBeDefined();
if (!unrelatedIneligible?.date) 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 === unrelatedIneligible.date)
?.historicalStatus,
).toBe('not-examined');
expect(
analysis.results.find((result) => result.date === '2024-01-06')?.historicalStatus,
).toBe('ineligible');
} finally {
vi.stubGlobal('window', browserWindow);
}
@@ -1638,10 +1929,8 @@ describe('planning calculation context', () => {
});
expect((await calculation.outcome).kind).toBe('Plan');
const trace = await calculation.trace;
const candidates =
trace?.phases.flatMap((phase) =>
phase.decisions.flatMap((decision) => decision.candidates),
) ?? [];
const decisions = trace?.phases.flatMap((phase) => phase.decisions) ?? [];
const candidates = decisions.flatMap((decision) => decision.candidates);
expect(
candidates.some(
(candidate) =>
@@ -1655,6 +1944,43 @@ describe('planning calculation context', () => {
expect(
candidates.some((candidate) => candidate.comparison && candidate.sourceDate),
).toBe(true);
const chosenComparisons = decisions.flatMap((decision) =>
decision.candidates
.filter((candidate) => candidate.status === 'chosen' && candidate.comparison)
.map((candidate) => ({ decision, candidate })),
);
expect(chosenComparisons.length).toBeGreaterThan(0);
for (const { decision, candidate: chosen } of chosenComparisons) {
expect(
decision.deltas.some(
(delta) =>
(delta.kind === 'placement-moved' &&
delta.toDate === chosen.date &&
delta.balanceId === chosen.placement?.balanceId) ||
(delta.kind === 'placement-added' &&
delta.date === chosen.date &&
delta.balanceId === chosen.placement?.balanceId) ||
(delta.kind === 'placement-removed' && delta.date === chosen.date),
),
).toBe(true);
}
const phasesWithMultipleAcceptedMutations = trace?.phases.filter((phase) => {
const acceptedMutations = phase.decisions.filter(
(decision) =>
decision.candidates.some(
(candidate) => candidate.status === 'chosen' && candidate.comparison,
) &&
decision.deltas.some(
(delta) =>
delta.kind === 'placement-moved' ||
delta.kind === 'placement-added' ||
delta.kind === 'placement-removed' ||
delta.kind === 'placement-updated',
),
);
return acceptedMutations.length > 1;
});
expect(phasesWithMultipleAcceptedMutations?.length).toBeGreaterThan(0);
} finally {
vi.stubGlobal('window', browserWindow);
}