refactor(planning): remove the original decision trace
CI / policy (push) Successful in 7s
CI / validate (push) Successful in 1m15s
CI / test (push) Successful in 3m18s
CI / benchmark (push) Failing after 5m53s
CI / build (push) Successful in 1m23s
CI / security (push) Successful in 7m6s
CI / policy (pull_request) Successful in 7s
CI / validate (pull_request) Successful in 1m9s
CI / build (pull_request) Successful in 43s
CI / test (pull_request) Successful in 3m19s
CI / images (push) Successful in 3m7s
CI / required (push) Successful in 2s
CI / benchmark (pull_request) Failing after 5m28s
CI / security (pull_request) Successful in 6m37s
CI / images (pull_request) Successful in 3m18s
CI / required (pull_request) Successful in 1s
CI / policy (push) Successful in 7s
CI / validate (push) Successful in 1m15s
CI / test (push) Successful in 3m18s
CI / benchmark (push) Failing after 5m53s
CI / build (push) Successful in 1m23s
CI / security (push) Successful in 7m6s
CI / policy (pull_request) Successful in 7s
CI / validate (pull_request) Successful in 1m9s
CI / build (pull_request) Successful in 43s
CI / test (pull_request) Successful in 3m19s
CI / images (push) Successful in 3m7s
CI / required (push) Successful in 2s
CI / benchmark (pull_request) Failing after 5m28s
CI / security (pull_request) Successful in 6m37s
CI / images (pull_request) Successful in 3m18s
CI / required (pull_request) Successful in 1s
This commit is contained in:
1 parent
ea4256107f
commit
d9d9befa12
43 files changed
+729
-8880
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { createOptimizationPresetRules } from './rule-service';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPlanningCalculation, applyReplayDeltas } from './leave-planner.service';
|
||||
import { createPlanningCalculation } from './leave-planner.service';
|
||||
|
||||
const balance = (
|
||||
id: string,
|
||||
@@ -18,8 +18,92 @@ 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 () => {
|
||||
describe('planner explanation and alternative dates', () => {
|
||||
it('delivers only the displayed plan, explanation and state needed for alternative dates', async () => {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-02', end: '2024-01-10' },
|
||||
balances: [balance('cp', 'cp', 1, '2024-01-09')],
|
||||
rules: [presets[0]],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
|
||||
expect(Object.keys(calculation).sort()).toEqual([
|
||||
'analyzeCounterfactual',
|
||||
'cancel',
|
||||
'explanation',
|
||||
'explanationError',
|
||||
'id',
|
||||
'outcome',
|
||||
'snapshot',
|
||||
'timings',
|
||||
]);
|
||||
const core = await import('./leave-planner.core');
|
||||
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
|
||||
expect(
|
||||
core.calculatePlanningInternal(
|
||||
calculation.snapshot,
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
),
|
||||
).toEqual(outcome);
|
||||
expect(Object.keys(artifacts).sort()).toEqual([
|
||||
'explanation',
|
||||
'explanationError',
|
||||
'finalState',
|
||||
'timings',
|
||||
]);
|
||||
expect(artifacts.finalState?.placements).toEqual(outcome.placements);
|
||||
expect(Object.keys(artifacts.timings ?? {})).toEqual(['planningWorkMs']);
|
||||
const original = structuredClone(outcome);
|
||||
const analysis = await calculation.analyzeCounterfactual(outcome.placements[0]);
|
||||
expect(analysis.results).toHaveLength(8);
|
||||
for (const result of analysis.results) {
|
||||
expect(result).not.toHaveProperty('historicalStatus');
|
||||
expect(result).not.toHaveProperty('historicalReason');
|
||||
}
|
||||
expect(outcome).toEqual(original);
|
||||
});
|
||||
|
||||
it('excludes observers from the technical budget and keeps the outcome after explanation delivery fails', async () => {
|
||||
const core = await import('./leave-planner.core');
|
||||
let elapsed = 0;
|
||||
const clock = vi.spyOn(performance, 'now').mockImplementation(() => elapsed);
|
||||
let delivered = false;
|
||||
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
|
||||
try {
|
||||
const outcome = core.calculatePlanningInternal(
|
||||
{
|
||||
horizon: { start: '2024-01-02', end: '2024-01-10' },
|
||||
balances: [],
|
||||
rules: [],
|
||||
deadlineMs: 10,
|
||||
progress: () => (elapsed += 100),
|
||||
},
|
||||
() => (elapsed += 500),
|
||||
(value) => (artifacts = value),
|
||||
() => (delivered = true),
|
||||
() => {
|
||||
expect(delivered).toBe(true);
|
||||
elapsed += 1000;
|
||||
throw new Error('Explanation delivery failed.');
|
||||
},
|
||||
);
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
expect(artifacts.explanationError).toMatchObject({
|
||||
code: 'explanation-error',
|
||||
message: 'Explanation delivery failed.',
|
||||
});
|
||||
expect(artifacts.finalState?.horizon).toEqual({
|
||||
start: '2024-01-02',
|
||||
end: '2024-01-10',
|
||||
});
|
||||
expect(artifacts.timings?.planningWorkMs).toBe(0);
|
||||
} finally {
|
||||
clock.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps earned availability after a reported occurrence is extended', async () => {
|
||||
vi.setSystemTime(new Date('2024-05-03T12:00:00Z'));
|
||||
const core = await import('./leave-planner.core');
|
||||
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
|
||||
@@ -67,42 +151,25 @@ describe('planner explanation and trace seam', () => {
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
if (outcome.kind !== 'Plan' || !artifacts.trace || !artifacts.finalState)
|
||||
throw new Error('Expected the extended generator to finance a traced plan.');
|
||||
const replay = {
|
||||
balances: artifacts.trace.initialState.balances.map((item) => ({ ...item })),
|
||||
placements: new Map(
|
||||
artifacts.trace.initialState.placements.map((item) => [item.date, item]),
|
||||
),
|
||||
earnedActivations: [...artifacts.trace.initialState.earnedActivations],
|
||||
};
|
||||
if (outcome.kind !== 'Plan' || !artifacts.finalState)
|
||||
throw new Error('Expected the extended generator to finance a plan.');
|
||||
const earnedId = 'earned:producer:2024-05-01';
|
||||
let recognized = false;
|
||||
for (const decision of artifacts.trace.phases.flatMap(({ decisions }) => decisions)) {
|
||||
applyReplayDeltas(replay, decision.deltas, artifacts.finalState);
|
||||
if (
|
||||
decision.deltas.some(
|
||||
({ kind, balanceId }) => kind === 'earned-activated' && balanceId === earnedId,
|
||||
)
|
||||
) {
|
||||
recognized = true;
|
||||
expect(replay.earnedActivations.map(({ id }) => id)).toContain(earnedId);
|
||||
expect(replay.balances.map(({ id }) => id)).not.toContain(earnedId);
|
||||
}
|
||||
}
|
||||
expect(recognized).toBe(true);
|
||||
expect(replay.balances).toEqual(artifacts.finalState.balances);
|
||||
expect([...replay.placements.values()]).toEqual(artifacts.finalState.placements);
|
||||
expect(replay.earnedActivations).toEqual(artifacts.finalState.earnedActivations);
|
||||
expect(replay.balances.find(({ id }) => id === earnedId)).toMatchObject({
|
||||
remaining: 0,
|
||||
availableFrom: '2024-05-06',
|
||||
expiryDate: '2025-05-06',
|
||||
});
|
||||
expect(artifacts.finalState.balances).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: earnedId,
|
||||
availableFrom: '2024-05-06',
|
||||
expiryDate: '2025-05-06',
|
||||
remaining: 0,
|
||||
}),
|
||||
);
|
||||
expect(outcome.placements.find(({ balanceId }) => balanceId === earnedId)?.date).toBe(
|
||||
'2024-05-07',
|
||||
);
|
||||
expect(artifacts.finalState.placements).toEqual(outcome.placements);
|
||||
});
|
||||
|
||||
it.each([5, 8])(
|
||||
'keeps %s unrelated obligation branches linear when a declared entitlement is already reported',
|
||||
'keeps %s unrelated obligations valid without duplicating a reported entitlement',
|
||||
async (count) => {
|
||||
vi.setSystemTime(new Date('2024-05-06T12:00:00Z'));
|
||||
const core = await import('./leave-planner.core');
|
||||
@@ -116,74 +183,58 @@ describe('planner explanation and trace seam', () => {
|
||||
'2024-04-25',
|
||||
'2024-04-26',
|
||||
].slice(0, count);
|
||||
const branchCounts: number[] = [];
|
||||
for (const rewardDays of [0, 1]) {
|
||||
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
|
||||
const outcome = core.calculatePlanningInternal(
|
||||
{
|
||||
horizon: { start: '2024-04-17', end: '2024-05-08' },
|
||||
declaredDays: [
|
||||
{ date: '2024-04-29', type: 'cp' },
|
||||
{ date: '2024-04-30', type: 'cp' },
|
||||
],
|
||||
balances: [
|
||||
{
|
||||
id: 'other',
|
||||
type: 'other',
|
||||
quantity: dates.length,
|
||||
availableFrom: '2024-04-17',
|
||||
expiryDate: '2025-12-31',
|
||||
},
|
||||
],
|
||||
rules: [
|
||||
...dates.map((date) => ({
|
||||
id: date,
|
||||
type: 'time_window' as const,
|
||||
force: 'obligation' as const,
|
||||
startDate: date,
|
||||
endDate: date,
|
||||
placeDays: 1,
|
||||
placeType: 'other' as const,
|
||||
})),
|
||||
{
|
||||
id: 'producer',
|
||||
type: 'if_then',
|
||||
force: 'preference',
|
||||
dayOfWeek: 3,
|
||||
placement: 'before',
|
||||
placeDays: 2,
|
||||
placeType: 'cp',
|
||||
rewardDays,
|
||||
rewardType: 'other',
|
||||
},
|
||||
],
|
||||
},
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
if (outcome.kind !== 'Plan' || !artifacts.trace)
|
||||
throw new Error('Expected a traced completed plan.');
|
||||
const outcome = core.calculatePlanningInternal({
|
||||
horizon: { start: '2024-04-17', end: '2024-05-08' },
|
||||
declaredDays: [
|
||||
{ date: '2024-04-29', type: 'cp' },
|
||||
{ date: '2024-04-30', type: 'cp' },
|
||||
],
|
||||
balances: [
|
||||
{
|
||||
id: 'other',
|
||||
type: 'other',
|
||||
quantity: dates.length,
|
||||
availableFrom: '2024-04-17',
|
||||
expiryDate: '2025-12-31',
|
||||
},
|
||||
],
|
||||
rules: [
|
||||
...dates.map((date) => ({
|
||||
id: date,
|
||||
type: 'time_window' as const,
|
||||
force: 'obligation' as const,
|
||||
startDate: date,
|
||||
endDate: date,
|
||||
placeDays: 1,
|
||||
placeType: 'other' as const,
|
||||
})),
|
||||
{
|
||||
id: 'producer',
|
||||
type: 'if_then',
|
||||
force: 'preference',
|
||||
dayOfWeek: 3,
|
||||
placement: 'before',
|
||||
placeDays: 2,
|
||||
placeType: 'cp',
|
||||
rewardDays,
|
||||
rewardType: 'other',
|
||||
},
|
||||
],
|
||||
});
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a completed plan.');
|
||||
expect(outcome.placements.map(({ date }) => date)).toEqual(dates);
|
||||
expect(outcome.remainingBalances).toEqual([]);
|
||||
branchCounts.push(
|
||||
artifacts.trace.phases
|
||||
.find(({ name }) => name === 'obligations')
|
||||
?.decisions.filter(({ deltas }) =>
|
||||
deltas.some(({ placement }) => placement !== undefined),
|
||||
).length ?? 0,
|
||||
);
|
||||
if (rewardDays)
|
||||
expect(outcome.ifThenContributions?.[0].earnedEntitlement).toMatchObject({
|
||||
availableFrom: '2024-05-02',
|
||||
added: false,
|
||||
});
|
||||
}
|
||||
expect(branchCounts[0]).toBeGreaterThan(0);
|
||||
expect(branchCounts[1]).toBe(branchCounts[0]);
|
||||
},
|
||||
);
|
||||
|
||||
it('replays an already-reported entitlement without inventing a transient earned balance under explicit priorities', async () => {
|
||||
it('keeps reported entitlements out of the final state 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 = {};
|
||||
@@ -224,69 +275,17 @@ describe('planner explanation and trace seam', () => {
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
if (outcome.kind !== 'Plan' || !artifacts.trace || !artifacts.finalState)
|
||||
throw new Error('Expected a traced completed plan.');
|
||||
if (outcome.kind !== 'Plan' || !artifacts.finalState)
|
||||
throw new Error('Expected a completed plan.');
|
||||
expect(outcome.placements).toMatchObject([{ date: '2024-05-07', balanceId: 'cp' }]);
|
||||
expect(outcome.remainingBalances).toEqual([]);
|
||||
expect(outcome.ifThenContributions?.[0].earnedEntitlement).toMatchObject({
|
||||
availableFrom: '2024-05-06',
|
||||
added: false,
|
||||
});
|
||||
const decisions = artifacts.trace.phases.flatMap(({ decisions }) => decisions);
|
||||
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 })),
|
||||
placements: new Map(
|
||||
initial.placements.map((placement) => [
|
||||
placement.date,
|
||||
{ ...placement, ruleIds: [...placement.ruleIds] },
|
||||
]),
|
||||
),
|
||||
earnedActivations: initial.earnedActivations.map((activation) => ({
|
||||
...activation,
|
||||
dates: [...activation.dates],
|
||||
})),
|
||||
};
|
||||
for (const decision of decisions) applyReplayDeltas(replay, decision.deltas);
|
||||
expect(replay.balances).toEqual(artifacts.finalState.balances);
|
||||
expect(replay.earnedActivations).toEqual(artifacts.finalState.earnedActivations);
|
||||
expect([...replay.placements.values()]).toEqual(outcome.placements);
|
||||
});
|
||||
|
||||
it('records only actual evaluations and publishes non-working dates as metadata', async () => {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2024-01-07' },
|
||||
balances: [],
|
||||
rules: [],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
const trace = await calculation.trace;
|
||||
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
expect(trace?.status).toBe('ready');
|
||||
expect(trace?.nonWorkingDates?.map(({ date }) => date)).toEqual([
|
||||
'2024-01-01',
|
||||
'2024-01-06',
|
||||
]);
|
||||
expect(
|
||||
trace?.phases
|
||||
.flatMap((phase) => phase.decisions)
|
||||
.flatMap((decision) => decision.candidates),
|
||||
).toEqual([]);
|
||||
expect(trace?.phases.every((phase) => phase.status === 'completed')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps joint obligation branches atomic and associates the selected shared placement', async () => {
|
||||
it('associates every joint obligation with its shared placement', async () => {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2024-01-08' },
|
||||
balances: [balance('cp', 'cp', 2, '2024-12-31')],
|
||||
@@ -312,151 +311,13 @@ describe('planner explanation and trace seam', () => {
|
||||
],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
const trace = await calculation.trace;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
if (outcome.kind !== 'Plan' || !trace) return;
|
||||
|
||||
const obligations = trace.phases.find((phase) => phase.name === 'obligations');
|
||||
const branch = obligations?.decisions
|
||||
.flatMap((decision) => decision.candidates)
|
||||
.find((candidate) => candidate.status === 'chosen' && candidate.dates?.length === 2);
|
||||
expect(branch?.dates).toHaveLength(2);
|
||||
expect(
|
||||
branch?.dates?.every((date) => outcome.placements.some((item) => item.date === date)),
|
||||
).toBe(true);
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
|
||||
const sharedPlacement = outcome.placements.find(
|
||||
(placement) =>
|
||||
placement.ruleIds.includes('joint') && placement.ruleIds.includes('shared'),
|
||||
);
|
||||
expect(sharedPlacement).toBeDefined();
|
||||
const acceptedMutation = obligations?.decisions.find((decision) =>
|
||||
decision.deltas.some(
|
||||
(delta) =>
|
||||
(delta.kind === 'placement-added' || delta.kind === 'placement-updated') &&
|
||||
delta.placement !== undefined &&
|
||||
delta.placement.date === sharedPlacement?.date &&
|
||||
delta.placement.ruleIds.includes('joint') &&
|
||||
delta.placement.ruleIds.includes('shared'),
|
||||
),
|
||||
);
|
||||
expect(acceptedMutation).toBeDefined();
|
||||
expect(acceptedMutation?.branch?.deltas).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'placement-updated',
|
||||
placement: expect.objectContaining({
|
||||
date: sharedPlacement?.date,
|
||||
ruleIds: ['joint', 'shared'],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const sameDatesOnRejectedParent = obligations?.decisions.filter(
|
||||
(decision) =>
|
||||
decision.id !== acceptedMutation?.id &&
|
||||
decision.candidates.some(
|
||||
(candidate) =>
|
||||
candidate.status === 'evaluated-rejected' &&
|
||||
candidate.dates?.includes(sharedPlacement?.date ?? ''),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
sameDatesOnRejectedParent?.some((decision) => decision.branch?.parentDecisionId),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('records at-risk assignments as sequential decisions with exact prior-state deltas', async () => {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2024-01-08' },
|
||||
balances: [balance('cp', 'cp', 2, '2024-01-07')],
|
||||
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 === 'optimization:default-unplaced-at-risk',
|
||||
);
|
||||
const jan2 = atRisk?.decisions.find((decision) =>
|
||||
decision.deltas.some(
|
||||
(delta) => delta.kind === 'placement-added' && delta.date === '2024-01-02',
|
||||
),
|
||||
);
|
||||
const jan3 = atRisk?.decisions.find((decision) =>
|
||||
decision.deltas.some(
|
||||
(delta) => delta.kind === 'placement-added' && delta.date === '2024-01-03',
|
||||
),
|
||||
);
|
||||
expect(jan2?.candidates.some((candidate) => candidate.status === 'chosen')).toBe(true);
|
||||
expect(jan2?.candidates.find((candidate) => candidate.status === 'chosen')).toMatchObject({
|
||||
technicalComparison: [
|
||||
{ name: 'obligation-validity', direction: 'minimize', before: 0, after: 0 },
|
||||
{
|
||||
name: `optimization:${presets[0].id}`,
|
||||
direction: 'minimize',
|
||||
before: 2,
|
||||
after: 1,
|
||||
},
|
||||
],
|
||||
firstDecisiveComparator: `optimization:${presets[0].id}`,
|
||||
});
|
||||
expect(jan2?.deltas).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'balance-changed',
|
||||
remainingBefore: 2,
|
||||
remainingAfter: 1,
|
||||
}),
|
||||
);
|
||||
expect(jan3?.candidates.some((candidate) => candidate.status === 'chosen')).toBe(true);
|
||||
expect(jan3?.candidates.find((candidate) => candidate.status === 'chosen')).toMatchObject({
|
||||
technicalComparison: [
|
||||
{ name: 'obligation-validity', direction: 'minimize', before: 0, after: 0 },
|
||||
{
|
||||
name: `optimization:${presets[0].id}`,
|
||||
direction: 'minimize',
|
||||
before: 1,
|
||||
after: 0,
|
||||
},
|
||||
],
|
||||
firstDecisiveComparator: `optimization:${presets[0].id}`,
|
||||
});
|
||||
expect(jan3?.deltas).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'balance-changed',
|
||||
remainingBefore: 1,
|
||||
remainingAfter: 0,
|
||||
}),
|
||||
);
|
||||
for (const [date, decision] of [
|
||||
['2024-01-02', jan2],
|
||||
['2024-01-03', jan3],
|
||||
] as const) {
|
||||
expect(
|
||||
decision?.candidates.filter(
|
||||
(candidate) => candidate.status === 'chosen' && candidate.date === date,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
decision?.deltas.filter(
|
||||
(delta) => delta.kind === 'placement-added' && delta.date === date,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
}
|
||||
const selected = jan3?.deltas.find(
|
||||
(delta) => delta.kind === 'placement-added' && delta.date === '2024-01-03',
|
||||
)?.placement;
|
||||
expect(selected).toBeDefined();
|
||||
if (!jan3 || !selected) return;
|
||||
const analysis = await calculation.analyzeCounterfactual({
|
||||
decisionId: jan3.id,
|
||||
placement: selected,
|
||||
});
|
||||
expect(analysis.kind).toBe('Counterfactual');
|
||||
if (analysis.kind === 'Counterfactual')
|
||||
expect(analysis.results.find((item) => item.date === '2024-01-02')).toMatchObject({
|
||||
status: 'ineligible',
|
||||
reason: { code: 'occupied' },
|
||||
});
|
||||
});
|
||||
|
||||
it('puts actual obligation validity first in stable plan explanations', async () => {
|
||||
@@ -478,6 +339,7 @@ describe('planner explanation and trace seam', () => {
|
||||
const outcome = await calculation.outcome;
|
||||
const explanation = await calculation.explanation;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
|
||||
expect(explanation?.kind).toBe('PlanExplanation');
|
||||
if (explanation?.kind !== 'PlanExplanation') return;
|
||||
|
||||
@@ -498,7 +360,7 @@ describe('planner explanation and trace seam', () => {
|
||||
expect(placement.firstDecisiveObjective).toBe('obligation-validity');
|
||||
});
|
||||
|
||||
it('records the balance order used for a shared ANY preference placement', async () => {
|
||||
it('uses the earlier-expiring balance for shared ANY preferences', async () => {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2024-01-08' },
|
||||
balances: [
|
||||
@@ -528,9 +390,8 @@ describe('planner explanation and trace seam', () => {
|
||||
],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
const trace = await calculation.trace;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
if (outcome.kind !== 'Plan' || !trace) return;
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
|
||||
|
||||
const sharedPlacement = outcome.placements.find((placement) =>
|
||||
placement.ruleIds.includes('any-preference'),
|
||||
@@ -540,116 +401,9 @@ describe('planner explanation and trace seam', () => {
|
||||
balanceId: 'rtt-first',
|
||||
ruleIds: ['any-preference', 'rtt-preference'],
|
||||
});
|
||||
const candidate = trace.phases
|
||||
.find((phase) => phase.name === 'preference:any-preference')
|
||||
?.decisions.flatMap((decision) => decision.candidates)
|
||||
.find((item) => item.status === 'chosen' && item.date === '2024-01-03');
|
||||
expect(candidate).toMatchObject({
|
||||
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 () => {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2025-01-01' },
|
||||
balances: [balance('cp', 'cp', 2, '2024-12-31')],
|
||||
rules: [
|
||||
{
|
||||
id: 'if-then-preference',
|
||||
type: 'if_then',
|
||||
force: 'preference',
|
||||
dayOfWeek: 3,
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
placement: 'auto',
|
||||
rewardDays: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
const trace = await calculation.trace;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
if (outcome.kind !== 'Plan' || !trace) return;
|
||||
|
||||
const candidates = trace.phases
|
||||
.flatMap((phase) => phase.decisions)
|
||||
.flatMap((decision) =>
|
||||
decision.candidates
|
||||
.filter((candidate) =>
|
||||
candidate.technicalComparison?.some(
|
||||
(item) => item.name === 'preference:if-then-preference',
|
||||
),
|
||||
)
|
||||
.map((candidate) => ({ candidate, decision })),
|
||||
);
|
||||
const chosen = candidates.find(({ candidate }) => candidate.status === 'chosen');
|
||||
const preferenceLeaves =
|
||||
trace.phases
|
||||
.find((phase) => phase.name === 'preference:if-then-preference')
|
||||
?.decisions.flatMap((decision) => decision.candidates)
|
||||
.filter((candidate) =>
|
||||
candidate.technicalComparison?.some(
|
||||
(item) => item.name === 'preference:if-then-preference',
|
||||
),
|
||||
) ?? [];
|
||||
expect(preferenceLeaves.length).toBeGreaterThan(1);
|
||||
expect(preferenceLeaves.some((candidate) => candidate.status === 'chosen')).toBe(true);
|
||||
expect(
|
||||
preferenceLeaves.some((candidate) => candidate.status === 'evaluated-rejected'),
|
||||
).toBe(true);
|
||||
expect(candidates.some(({ candidate }) => candidate.status === 'evaluated-rejected')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(chosen?.candidate.technicalComparison).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'preference:if-then-preference',
|
||||
direction: 'maximize',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
(chosen?.decision.branch?.deltas ?? chosen?.decision.deltas ?? []).length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
chosen?.candidate.dates?.every((date) =>
|
||||
outcome.placements.some((p) => p.date === date),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('records earned obligation branches with the activated entitlement and its consumer', async () => {
|
||||
it('keeps an earned obligation entitlement and its consumer chronological', async () => {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-04-25', end: '2024-05-08' },
|
||||
balances: [
|
||||
@@ -680,29 +434,13 @@ describe('planner explanation and trace seam', () => {
|
||||
],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
const trace = await calculation.trace;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
if (outcome.kind !== 'Plan' || !trace) return;
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
|
||||
|
||||
const earned = outcome.placements.find((placement) =>
|
||||
placement.balanceId.startsWith('earned:'),
|
||||
);
|
||||
expect(earned?.ruleIds).toContain('consumer');
|
||||
const decisions = trace.phases.flatMap((phase) => phase.decisions);
|
||||
const activation = decisions
|
||||
.flatMap((decision) => decision.deltas)
|
||||
.find(
|
||||
(delta) =>
|
||||
delta.kind === 'earned-activated' && delta.balanceId === earned?.balanceId,
|
||||
);
|
||||
expect(activation?.activation).toMatchObject({ ruleId: 'producer', dates: ['2024-05-02'] });
|
||||
const branch = decisions
|
||||
.flatMap((decision) => decision.candidates)
|
||||
.find(
|
||||
(candidate) =>
|
||||
candidate.status === 'chosen' && candidate.dates?.includes(earned?.date ?? ''),
|
||||
);
|
||||
expect(branch).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects a producer move that delays its earned entitlement past the consumer', async () => {
|
||||
@@ -737,7 +475,7 @@ describe('planner explanation and trace seam', () => {
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
if (outcome.kind !== 'Plan') return;
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
|
||||
const producer = outcome.placements.find((placement) =>
|
||||
placement.ruleIds.includes('producer'),
|
||||
);
|
||||
@@ -802,7 +540,7 @@ describe('planner explanation and trace seam', () => {
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
if (outcome.kind !== 'Plan') return;
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
|
||||
const atRisk = outcome.placements.find(
|
||||
(placement) =>
|
||||
placement.balanceId === 'cp2' &&
|
||||
@@ -823,7 +561,7 @@ describe('planner explanation and trace seam', () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('records earned balance availability metadata changes when a shared period extends', async () => {
|
||||
it('keeps earned balance metadata consistent when a shared period extends', async () => {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-04-25', end: '2024-05-15' },
|
||||
balances: [
|
||||
@@ -863,30 +601,13 @@ describe('planner explanation and trace seam', () => {
|
||||
],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
const trace = await calculation.trace;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
if (outcome.kind !== 'Plan' || !trace) return;
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
|
||||
|
||||
const earnedBalanceId = outcome.placements.find((placement) =>
|
||||
placement.ruleIds.includes('rtt-preference'),
|
||||
)?.balanceId;
|
||||
expect(earnedBalanceId).toBeDefined();
|
||||
const changes = trace.phases
|
||||
.flatMap((phase) => phase.decisions)
|
||||
.flatMap((decision) => decision.deltas)
|
||||
.filter(
|
||||
(delta) => delta.kind === 'balance-changed' && delta.balanceId === earnedBalanceId,
|
||||
);
|
||||
expect(changes).toContainEqual(
|
||||
expect.objectContaining({
|
||||
remainingBefore: 1,
|
||||
remainingAfter: 1,
|
||||
balance: expect.objectContaining({
|
||||
availableFrom: '2024-05-06',
|
||||
expiryDate: '2025-05-06',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('compares every horizon date while preserving the selected placement provenance', async () => {
|
||||
@@ -907,7 +628,7 @@ describe('planner explanation and trace seam', () => {
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
if (outcome.kind !== 'Plan') return;
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
|
||||
const selected = outcome.placements.find((placement) =>
|
||||
placement.ruleIds.includes('exact-date'),
|
||||
);
|
||||
@@ -1022,27 +743,6 @@ describe('planner explanation and trace seam', () => {
|
||||
if (outcome.kind !== 'PlanningConflict') continue;
|
||||
expect(outcome.reasons[0].code).toBe(conflictCodes[index]);
|
||||
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 === 'optimization:default-holiday-week-value',
|
||||
),
|
||||
).toBe(false);
|
||||
if (index === 1)
|
||||
expect(
|
||||
trace?.phases
|
||||
.find((phase) => phase.name === 'obligations')
|
||||
?.decisions.flatMap((decision) => decision.candidates).length,
|
||||
).toBeGreaterThan(0);
|
||||
if (index === 2)
|
||||
expect(
|
||||
trace?.phases
|
||||
.find((phase) => phase.name === 'obligations')
|
||||
?.decisions.some((decision) =>
|
||||
decision.candidates.some((candidate) => candidate.dates?.length),
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
const invalid = createPlanningCalculation({
|
||||
@@ -1052,7 +752,6 @@ describe('planner explanation and trace seam', () => {
|
||||
});
|
||||
expect((await invalid.outcome).kind).toBe('InvalidInput');
|
||||
expect(await invalid.explanation).toBeUndefined();
|
||||
expect(await invalid.trace).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not classify a joint-date conflict as earned when only a preference generates leave', async () => {
|
||||
@@ -1094,7 +793,7 @@ describe('planner explanation and trace seam', () => {
|
||||
it('recovers an incumbent explanation from the exact delivered checkpoint state', async () => {
|
||||
const core = await import('./leave-planner.core');
|
||||
let checkpointPlan: import('./leave-planner.core').PlanOutcome | undefined;
|
||||
let checkpointState: import('./leave-planner.core').PlanningTraceState | undefined;
|
||||
let checkpointState: import('./leave-planner.core').PlanningStateSnapshot | undefined;
|
||||
const request = {
|
||||
horizon: { start: '2024-01-01', end: '2024-01-10' },
|
||||
balances: [balance('cp', 'cp', 2, '2024-01-31')],
|
||||
@@ -1124,7 +823,7 @@ describe('planner explanation and trace seam', () => {
|
||||
expect(recovered.explanation.incomplete?.reason.code).toBe('worker-error');
|
||||
expect(recovered.explanation.placements).toHaveLength(checkpointPlan.placements.length);
|
||||
|
||||
let completedState: import('./leave-planner.core').PlanningTraceState | undefined;
|
||||
let completedState: import('./leave-planner.core').PlanningStateSnapshot | undefined;
|
||||
const completed = core.calculatePlanningInternal(
|
||||
{ ...request, progress: undefined },
|
||||
(plan, state) => {
|
||||
@@ -1144,116 +843,7 @@ describe('planner explanation and trace seam', () => {
|
||||
expect(completedRecovery.explanation.incomplete).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not charge a deliberately slow incremental trace consumer to planning work', async () => {
|
||||
const core = await import('./leave-planner.core');
|
||||
let clock = 0;
|
||||
vi.stubGlobal('performance', { now: () => ++clock });
|
||||
const request = {
|
||||
horizon: { start: '2024-01-01', end: '2024-01-15' },
|
||||
balances: [balance('cp', 'cp', 3, '2024-01-31')],
|
||||
rules: presets,
|
||||
deadlineMs: 100_000,
|
||||
};
|
||||
const plain = core.calculatePlanningInternal(request);
|
||||
clock = 0;
|
||||
let callbackCount = 0;
|
||||
let artifacts: import('./leave-planner.core').PlanningArtifacts | undefined;
|
||||
const operations: import('./leave-planner.core').PlanningTraceOperation[] = [];
|
||||
const traced = core.calculatePlanningInternal(
|
||||
{
|
||||
...request,
|
||||
traceProgress: (
|
||||
_totalMs: number,
|
||||
update: import('./leave-planner.core').PlanningTraceUpdate,
|
||||
) => {
|
||||
callbackCount++;
|
||||
clock += 10_000;
|
||||
operations.push(...update.operations);
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
expect(traced).toEqual(plain);
|
||||
expect(callbackCount).toBeGreaterThan(0);
|
||||
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);
|
||||
const streamedCandidates = new Map<string, unknown>();
|
||||
for (const operation of candidateUpdates)
|
||||
if (operation.kind === 'candidate')
|
||||
streamedCandidates.set(
|
||||
`${operation.phaseId}/${operation.decisionId}/${operation.index}`,
|
||||
operation.candidate,
|
||||
);
|
||||
expect(streamedCandidates.size).toBe(
|
||||
decisions.flatMap((decision) => decision.candidates).length,
|
||||
);
|
||||
expect(operations.filter((operation) => operation.kind === 'delta')).toHaveLength(
|
||||
decisions.flatMap((decision) => decision.deltas).length,
|
||||
);
|
||||
});
|
||||
|
||||
it('coalesces pending candidate snapshots without losing later flushed replacements', async () => {
|
||||
const core = await import('./leave-planner.core');
|
||||
vi.stubGlobal('performance', { now: () => 0 });
|
||||
const request = {
|
||||
horizon: { start: '2024-01-01', end: '2024-01-08' },
|
||||
balances: [balance('cp', 'cp', 2, '2024-01-07')],
|
||||
rules: [presets[0]],
|
||||
};
|
||||
let plainArtifacts: import('./leave-planner.core').PlanningArtifacts | undefined;
|
||||
const plain = core.calculatePlanningInternal(request, undefined, (value) => {
|
||||
plainArtifacts = value;
|
||||
});
|
||||
const operations: import('./leave-planner.core').PlanningTraceOperation[] = [];
|
||||
let streamedArtifacts: import('./leave-planner.core').PlanningArtifacts | undefined;
|
||||
const streamed = core.calculatePlanningInternal(
|
||||
{
|
||||
...request,
|
||||
traceProgress: (
|
||||
_totalMs: number,
|
||||
update: import('./leave-planner.core').PlanningTraceUpdate,
|
||||
) => operations.push(...update.operations),
|
||||
},
|
||||
undefined,
|
||||
(value) => (streamedArtifacts = value),
|
||||
);
|
||||
|
||||
expect(streamed).toEqual(plain);
|
||||
expect(streamedArtifacts?.trace).toEqual(plainArtifacts?.trace);
|
||||
const decisions =
|
||||
streamedArtifacts?.trace?.phases.flatMap((phase) => phase.decisions) ?? [];
|
||||
const candidates = decisions.flatMap((decision) =>
|
||||
decision.candidates.map((candidate, index) => ({
|
||||
key: `${decision.id}/${index}`,
|
||||
candidate,
|
||||
})),
|
||||
);
|
||||
expect(candidates.length).toBeGreaterThan(0);
|
||||
const candidateOperations = operations.filter(
|
||||
(operation): operation is Extract<typeof operation, { kind: 'candidate' }> =>
|
||||
operation.kind === 'candidate',
|
||||
);
|
||||
expect(candidateOperations).toHaveLength(candidates.length);
|
||||
const finalStreamedCandidates = new Map(
|
||||
candidateOperations.map((operation) => [
|
||||
`${operation.decisionId}/${operation.index}`,
|
||||
operation,
|
||||
]),
|
||||
);
|
||||
for (const { key, candidate } of candidates) {
|
||||
expect(finalStreamedCandidates.get(key)?.candidate).toEqual(candidate);
|
||||
if (candidate.status === 'chosen')
|
||||
expect(finalStreamedCandidates.get(key)?.mode).toBe('append');
|
||||
}
|
||||
});
|
||||
|
||||
it('reports matching completed, interrupted, and not-run phases at both interruption boundaries', async () => {
|
||||
it('explains incomplete calculations before and after a valid plan', async () => {
|
||||
const early = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2024-01-10' },
|
||||
balances: [balance('cp', 'cp', 1, '2024-01-31')],
|
||||
@@ -1261,22 +851,9 @@ describe('planner explanation and trace seam', () => {
|
||||
deadlineMs: 0,
|
||||
});
|
||||
const earlyOutcome = await early.outcome;
|
||||
const earlyTrace = await early.trace;
|
||||
expect(earlyOutcome.kind).toBe('PlanningIncomplete');
|
||||
if (earlyOutcome.kind === 'PlanningIncomplete')
|
||||
expect(earlyOutcome.completedStages).toEqual(['validation']);
|
||||
expect(earlyTrace?.completedStages).toEqual(['validation']);
|
||||
expect(earlyTrace?.phases.find((phase) => phase.name === 'validation')?.status).toBe(
|
||||
'completed',
|
||||
);
|
||||
expect(earlyTrace?.phases.find((phase) => phase.name === 'obligations')?.status).toBe(
|
||||
'interrupted',
|
||||
);
|
||||
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' },
|
||||
@@ -1289,7 +866,6 @@ describe('planner explanation and trace seam', () => {
|
||||
},
|
||||
});
|
||||
const qualityOutcome = await quality.outcome;
|
||||
const qualityTrace = await quality.trace;
|
||||
expect(qualityOutcome.kind).toBe('PlanningIncomplete');
|
||||
if (qualityOutcome.kind !== 'PlanningIncomplete') return;
|
||||
expect(qualityOutcome.reasons[0].code).toBe('planner-error');
|
||||
@@ -1298,16 +874,5 @@ describe('planner explanation and trace seam', () => {
|
||||
'obligations',
|
||||
'optimization:default-unplaced-at-risk',
|
||||
]);
|
||||
expect(qualityTrace?.completedStages).toEqual(qualityOutcome.completedStages);
|
||||
expect(
|
||||
qualityTrace?.phases.find(
|
||||
(phase) => phase.name === 'optimization:default-holiday-week-value',
|
||||
)?.status,
|
||||
).toBe('interrupted');
|
||||
expect(
|
||||
qualityTrace?.phases.find(
|
||||
(phase) => phase.name === 'optimization:default-non-working-day-gain',
|
||||
)?.status,
|
||||
).toBe('not-run');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user