fix(planning): keep counterfactual moves off the shared final plan
CI / policy (push) Successful in 28s
CI / validate (push) Successful in 46s
CI / build (push) Successful in 25s
CI / policy (pull_request) Successful in 7s
CI / validate (pull_request) Successful in 26s
CI / benchmark (push) Successful in 1m28s
CI / security (push) Successful in 1m11s
CI / build (pull_request) Successful in 23s
CI / test (push) Successful in 2m27s
CI / benchmark (pull_request) Successful in 1m33s
CI / security (pull_request) Successful in 1m0s
CI / images (push) Successful in 1m0s
CI / required (push) Successful in 1s
CI / test (pull_request) Successful in 2m6s
CI / images (pull_request) Successful in 54s
CI / required (pull_request) Successful in 1s

Since assigning a date shares unchanged activations with the parent
state, counterfactual analysis edited an activation it shared with the
final plan, so a later alternative date could inherit an earlier one's
dates and be judged eligible. It now replaces the moved activation.
Every counterfactual over seeds 280000-289999 matches the engine before
the sharing step again.

When every one of several obligation solutions fails earned-entitlement
chronology, the planner again reports the obligation conflict, as when
every preview failed. No seeded request reaches that path, so the
frozen engine is unchanged.

Also marks the ranking's known limit as a shortcut and fixes stale
documentation found in review.

Part of #302.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nessarandClaude Opus 5.5 committed 2026-10-18 21:49:55 +02:00
1 parent 676ea24410
commit fe705fe618
6 files changed
+78 -11

No files matched your search

@@ -495,6 +495,53 @@ describe('planner explanation and alternative dates', () => {
).toBeUndefined();
});
it('judges each alternative producer date against the unchanged final plan', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2026-04-20', end: '2026-05-31' },
balances: [balance('cp', 'cp', 2, '2026-12-31', '2026-04-20')],
rules: [
{
id: 'friday',
type: 'if_then',
force: 'preference',
dayOfWeek: 5,
placeDays: 1,
placeType: 'cp',
placement: 'auto',
rewardDays: 1,
rewardType: 'rtt',
},
{
id: 'consumer',
type: 'time_window',
force: 'obligation',
startDate: '2026-05-12',
endDate: '2026-05-12',
placeDays: 1,
placeType: 'rtt',
},
],
});
const outcome = await calculation.outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
const producer = outcome.placements.find(
(placement) => placement.date === '2026-04-30' && placement.ruleIds.includes('friday'),
);
expect(producer).toBeDefined();
if (!producer) return;
const analysis = await calculation.analyzeCounterfactual(producer);
if (analysis.kind !== 'Counterfactual') throw new Error('Expected a counterfactual.');
// 2026-05-04 is evaluated first; the move to 2026-05-11 must not inherit its dates.
expect(analysis.results.find((result) => result.date === '2026-05-04')?.status).toBe(
'eligible',
);
expect(analysis.results.find((result) => result.date === '2026-05-11')).toMatchObject({
status: 'ineligible',
reason: { code: 'unavailable' },
});
});
it('rejects an at-risk move that would delay already-earned leave past its consumer', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-04-25', end: '2024-05-08' },
+15 -7
View File
@@ -4523,7 +4523,7 @@ function* calculatePlanningSteps(
);
let rankedSolutions = obligationSolutions;
if (obligationSolutions.length > 1) {
// Rank obligation-only plans; the preference stages run once, on the best-ranked.
// shortcut: preferences run on the best-ranked obligation-only plan only, so a lower-ranked one that would end better is missed (ADR 0010); revisit if the comparison record grows.
const ranked: { state: State; objectives: PlanningObjective[] }[] = [];
for (const candidate of obligationSolutions) {
yield;
@@ -4585,6 +4585,9 @@ function* calculatePlanningSteps(
yield true;
return finish(checkpoint!, state);
}
// As when every preview failed, several failing solutions report the obligation conflict.
if (rankedSolutions.length > 1)
return finish(obligationConflict(obligationRequirements, context));
return finish({
kind: 'PlanningConflict',
conflictingRuleIds: state.earnedActivations.map((activation) => activation.ruleId),
@@ -5109,13 +5112,18 @@ export function analyzeCounterfactualInternal(
continue;
}
candidate.placements.get(date)!.supporting = selected.supporting;
// The candidate shares its activations with the baseline, so replace them rather than edit them.
if (removeDate && removeDate !== date)
for (const activation of candidate.earnedActivations) {
if (!selected.ruleIds.includes(activation.ruleId)) continue;
activation.dates = activation.dates.map((activationDate) =>
activationDate === removeDate ? date : activationDate,
);
}
candidate.earnedActivations = candidate.earnedActivations.map((activation) =>
selected.ruleIds.includes(activation.ruleId)
? {
...activation,
dates: activation.dates.map((activationDate) =>
activationDate === removeDate ? date : activationDate,
),
}
: activation,
);
const finalized = finalizeEarnedState(candidate, context);
if (!finalized) {
results.push({