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
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:
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' },
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user