fix(planning): complete explanation and trace behavior (#46)
This commit is contained in:
1 parent
57a39c81ce
commit
c9dbe82b79
10 files changed
+925
-140
No files matched your search
@@ -512,6 +512,10 @@ class TraceRecorder {
|
||||
private readonly phases: MutableTracePhase[] = [];
|
||||
private phase: MutableTracePhase | undefined;
|
||||
private sequence = 0;
|
||||
private readonly evaluationDecisions = new WeakMap<
|
||||
MutableTraceCandidate,
|
||||
MutableTraceDecision
|
||||
>();
|
||||
private previousPlacements = new Map<string, LeavePlacement>();
|
||||
private previousEarned = new Set<string>();
|
||||
private previousBalances: ReadonlyMap<string, number>;
|
||||
@@ -567,23 +571,27 @@ class TraceRecorder {
|
||||
return latest ?? this.decision(action);
|
||||
}
|
||||
|
||||
private latestComparison(placement: LeavePlacement): PlanningObjectiveComparison | undefined {
|
||||
for (const decision of [...(this.phase?.decisions ?? [])].reverse()) {
|
||||
const candidate = [...decision.candidates]
|
||||
.reverse()
|
||||
.find(
|
||||
(item) =>
|
||||
item.placement?.date === placement.date &&
|
||||
item.placement.balanceId === placement.balanceId &&
|
||||
item.comparison,
|
||||
);
|
||||
if (candidate?.comparison) return candidate.comparison;
|
||||
}
|
||||
return undefined;
|
||||
private candidateDecision(dates: readonly string[] = []): MutableTraceDecision {
|
||||
const latest = this.latestDecision();
|
||||
return latest.deltas.length ? this.decision('choose', dates) : latest;
|
||||
}
|
||||
|
||||
private recordChosenCandidate(placement: LeavePlacement, decision: MutableTraceDecision): void {
|
||||
const alreadyRecorded = decision.candidates.some(
|
||||
(candidate) =>
|
||||
candidate.status === 'chosen' &&
|
||||
candidate.placement?.date === placement.date &&
|
||||
candidate.placement.balanceId === placement.balanceId,
|
||||
);
|
||||
if (!alreadyRecorded)
|
||||
decision.candidates.push({ status: 'chosen', date: placement.date, placement });
|
||||
}
|
||||
|
||||
recordCandidate(candidate: PlanningTraceCandidate, copy = true): void {
|
||||
this.latestDecision().candidates.push(
|
||||
const decision = this.candidateDecision(
|
||||
candidate.dates ?? (candidate.date ? [candidate.date] : []),
|
||||
);
|
||||
decision.candidates.push(
|
||||
copy
|
||||
? {
|
||||
...candidate,
|
||||
@@ -606,14 +614,23 @@ class TraceRecorder {
|
||||
if (candidate) decision.candidates.push(candidate);
|
||||
}
|
||||
|
||||
recordDelta(delta: PlanningTraceDelta): void {
|
||||
this.latestDecision().deltas.push({ ...delta });
|
||||
recordEvaluation(candidate: MutableTraceCandidate): void {
|
||||
const decision = this.candidateDecision(
|
||||
candidate.dates ?? (candidate.date ? [candidate.date] : []),
|
||||
);
|
||||
decision.candidates.push(candidate);
|
||||
this.evaluationDecisions.set(candidate, decision);
|
||||
}
|
||||
|
||||
chooseEvaluation(candidate: MutableTraceCandidate): void {
|
||||
candidate.status = 'chosen';
|
||||
}
|
||||
|
||||
recordPlan(
|
||||
placements: readonly LeavePlacement[],
|
||||
balances: PlanningTraceState['balances'],
|
||||
earnedActivations: PlanningTraceState['earnedActivations'],
|
||||
acceptedCandidate?: MutableTraceCandidate,
|
||||
): void {
|
||||
const current = new Map(placements.map((placement) => [placement.date, placement]));
|
||||
const removed = [...this.previousPlacements.values()].filter(
|
||||
@@ -640,7 +657,7 @@ class TraceRecorder {
|
||||
!earnedChanged
|
||||
)
|
||||
return;
|
||||
this.decision(
|
||||
const action =
|
||||
removed.length && added.length
|
||||
? 'move'
|
||||
: added.length
|
||||
@@ -649,13 +666,17 @@ class TraceRecorder {
|
||||
? 'remove'
|
||||
: updated.length
|
||||
? 'share'
|
||||
: 'activate',
|
||||
[
|
||||
...removed.map((placement) => placement.date),
|
||||
...added.map((placement) => placement.date),
|
||||
...updated.map(([date]) => date),
|
||||
],
|
||||
);
|
||||
: 'activate';
|
||||
const dates = [
|
||||
...removed.map((placement) => placement.date),
|
||||
...added.map((placement) => placement.date),
|
||||
...updated.map(([date]) => date),
|
||||
];
|
||||
const decision =
|
||||
(acceptedCandidate && this.evaluationDecisions.get(acceptedCandidate)) ||
|
||||
this.decision(action, dates);
|
||||
decision.action = action;
|
||||
decision.dates = dates;
|
||||
const movedAdded = new Set<string>();
|
||||
const movedRemoved = new Set<string>();
|
||||
for (const previous of removed) {
|
||||
@@ -668,7 +689,7 @@ class TraceRecorder {
|
||||
if (!placement) continue;
|
||||
movedAdded.add(placement.date);
|
||||
movedRemoved.add(previous.date);
|
||||
this.recordDelta({
|
||||
decision.deltas.push({
|
||||
kind: 'placement-moved',
|
||||
fromDate: previous.date,
|
||||
toDate: placement.date,
|
||||
@@ -676,16 +697,11 @@ class TraceRecorder {
|
||||
previousPlacement: previous,
|
||||
placement,
|
||||
});
|
||||
this.recordCandidate({
|
||||
status: 'chosen',
|
||||
date: placement.date,
|
||||
placement,
|
||||
comparison: this.latestComparison(placement),
|
||||
});
|
||||
this.recordChosenCandidate(placement, decision);
|
||||
}
|
||||
for (const placement of removed) {
|
||||
if (movedRemoved.has(placement.date)) continue;
|
||||
this.recordDelta({
|
||||
decision.deltas.push({
|
||||
kind: 'placement-removed',
|
||||
date: placement.date,
|
||||
balanceId: placement.balanceId,
|
||||
@@ -694,46 +710,36 @@ class TraceRecorder {
|
||||
}
|
||||
for (const placement of added) {
|
||||
if (movedAdded.has(placement.date)) continue;
|
||||
this.recordDelta({
|
||||
decision.deltas.push({
|
||||
kind: 'placement-added',
|
||||
date: placement.date,
|
||||
balanceId: placement.balanceId,
|
||||
placement,
|
||||
});
|
||||
this.recordCandidate({
|
||||
status: 'chosen',
|
||||
date: placement.date,
|
||||
placement,
|
||||
comparison: this.latestComparison(placement),
|
||||
});
|
||||
this.recordChosenCandidate(placement, decision);
|
||||
}
|
||||
for (const [date, placement] of updated) {
|
||||
const previous = this.previousPlacements.get(date)!;
|
||||
this.recordDelta({
|
||||
decision.deltas.push({
|
||||
kind: 'placement-updated',
|
||||
date,
|
||||
balanceId: placement.balanceId,
|
||||
previousPlacement: previous,
|
||||
placement,
|
||||
});
|
||||
this.recordCandidate({
|
||||
status: 'chosen',
|
||||
date,
|
||||
placement,
|
||||
comparison: this.latestComparison(placement),
|
||||
});
|
||||
this.recordChosenCandidate(placement, decision);
|
||||
}
|
||||
for (const balance of balances) {
|
||||
const previousRemaining = this.previousBalances.get(balance.id);
|
||||
if (previousRemaining !== undefined && previousRemaining !== balance.remaining)
|
||||
this.recordDelta({
|
||||
decision.deltas.push({
|
||||
kind: 'balance-changed',
|
||||
balanceId: balance.id,
|
||||
remainingBefore: previousRemaining,
|
||||
remainingAfter: balance.remaining,
|
||||
});
|
||||
if (previousRemaining === undefined)
|
||||
this.recordDelta({
|
||||
decision.deltas.push({
|
||||
kind: 'balance-changed',
|
||||
balanceId: balance.id,
|
||||
remainingAfter: balance.remaining,
|
||||
@@ -743,7 +749,7 @@ class TraceRecorder {
|
||||
const activation = earnedActivations.find(
|
||||
(candidate) => candidate.id === balance.id,
|
||||
);
|
||||
this.recordDelta({
|
||||
decision.deltas.push({
|
||||
kind: 'earned-activated',
|
||||
balanceId: balance.id,
|
||||
activation: activation
|
||||
@@ -1117,11 +1123,21 @@ function planningTraceState(
|
||||
};
|
||||
}
|
||||
|
||||
function recordTraceState(context: SearchContext, state: State): void {
|
||||
function recordTraceState(
|
||||
context: SearchContext,
|
||||
state: State,
|
||||
acceptedCandidate?: MutableTraceCandidate,
|
||||
): void {
|
||||
if (!context.trace) return;
|
||||
observeTrace(context, (trace) => {
|
||||
const snapshot = planningTraceState(state, context.horizon);
|
||||
trace.recordPlan(snapshot.placements, snapshot.balances, snapshot.earnedActivations);
|
||||
if (acceptedCandidate) trace.chooseEvaluation(acceptedCandidate);
|
||||
trace.recordPlan(
|
||||
snapshot.placements,
|
||||
snapshot.balances,
|
||||
snapshot.earnedActivations,
|
||||
acceptedCandidate,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2579,11 +2595,12 @@ function recordTraceEvaluation(
|
||||
afterMetrics: PlanningMetrics,
|
||||
dates: readonly string[],
|
||||
sourceDate?: string,
|
||||
): void {
|
||||
): MutableTraceCandidate | undefined {
|
||||
let evaluation: MutableTraceCandidate | undefined;
|
||||
observeTrace(context, (trace) => {
|
||||
const date = dates[dates.length - 1];
|
||||
const placement = date ? afterState.placements.get(date) : undefined;
|
||||
trace.recordCandidate({
|
||||
evaluation = {
|
||||
status: 'evaluated-rejected',
|
||||
sourceDate,
|
||||
date,
|
||||
@@ -2595,8 +2612,10 @@ function recordTraceEvaluation(
|
||||
placement: placement
|
||||
? { ...placement, ruleIds: [...placement.ruleIds].sort() }
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
trace.recordEvaluation(evaluation);
|
||||
});
|
||||
return context.trace ? evaluation : undefined;
|
||||
}
|
||||
|
||||
function optimizeMovablePlacements(
|
||||
@@ -2678,6 +2697,7 @@ function optimizeMovablePlacements(
|
||||
let best = result;
|
||||
let bestMetrics = baseline;
|
||||
let bestDate = original.date;
|
||||
let bestEvaluation: MutableTraceCandidate | undefined;
|
||||
const placementCandidates =
|
||||
current.ruleIds.size > 0
|
||||
? context.workingDays.filter((date) => movableWindowDates.has(date))
|
||||
@@ -2709,7 +2729,7 @@ function optimizeMovablePlacements(
|
||||
if (!feasibleCandidate) continue;
|
||||
feasibleCandidate.placements.get(date)!.supporting = current.supporting;
|
||||
const metrics = qualityMetrics(feasibleCandidate, context, preferences);
|
||||
recordTraceEvaluation(
|
||||
const evaluation = recordTraceEvaluation(
|
||||
context,
|
||||
result,
|
||||
feasibleCandidate,
|
||||
@@ -2753,13 +2773,14 @@ function optimizeMovablePlacements(
|
||||
best = cloneState(feasibleCandidate);
|
||||
bestMetrics = metrics;
|
||||
bestDate = date;
|
||||
bestEvaluation = evaluation;
|
||||
}
|
||||
} finally {
|
||||
without.placements.delete(date);
|
||||
without.balances.find((item) => item.id === current.balanceId)!.remaining++;
|
||||
}
|
||||
}
|
||||
if (best !== result) recordTraceState(context, best);
|
||||
if (best !== result) recordTraceState(context, best, bestEvaluation);
|
||||
result = best;
|
||||
}
|
||||
return result;
|
||||
@@ -2859,6 +2880,7 @@ function optimizeHolidayWeeks(
|
||||
);
|
||||
let best: State | null = null;
|
||||
let bestMetrics = baseline;
|
||||
let bestEvaluation: MutableTraceCandidate | undefined;
|
||||
for (const candidate of candidates) {
|
||||
context.check();
|
||||
const protectedDates = new Set(
|
||||
@@ -2869,7 +2891,7 @@ function optimizeHolidayWeeks(
|
||||
const complete = completeHolidayWeek(result, candidate, context, protectedDates);
|
||||
if (!complete) continue;
|
||||
const metrics = qualityMetrics(complete, context, preferences);
|
||||
recordTraceEvaluation(
|
||||
const evaluation = recordTraceEvaluation(
|
||||
context,
|
||||
result,
|
||||
complete,
|
||||
@@ -2895,10 +2917,11 @@ function optimizeHolidayWeeks(
|
||||
) {
|
||||
best = complete;
|
||||
bestMetrics = metrics;
|
||||
bestEvaluation = evaluation;
|
||||
}
|
||||
}
|
||||
if (!best) return result;
|
||||
recordTraceState(context, best);
|
||||
recordTraceState(context, best, bestEvaluation);
|
||||
result = best;
|
||||
}
|
||||
}
|
||||
@@ -2938,6 +2961,7 @@ function optimizeExpiryPlacements(
|
||||
if (released) released.remaining++;
|
||||
let best = result;
|
||||
let bestExpiry = expiryTieKey(result);
|
||||
let bestEvaluation: MutableTraceCandidate | undefined;
|
||||
for (const replacement of without.balances
|
||||
.filter(
|
||||
(balance) =>
|
||||
@@ -2961,7 +2985,7 @@ function optimizeExpiryPlacements(
|
||||
if (!feasibleCandidate) continue;
|
||||
feasibleCandidate.placements.get(current.date)!.supporting = current.supporting;
|
||||
const metrics = qualityMetrics(feasibleCandidate, context, preferences);
|
||||
recordTraceEvaluation(
|
||||
const evaluation = recordTraceEvaluation(
|
||||
context,
|
||||
result,
|
||||
feasibleCandidate,
|
||||
@@ -2975,9 +2999,10 @@ function optimizeExpiryPlacements(
|
||||
if (sameHigherQuality(metrics, baseline) && expiry < bestExpiry) {
|
||||
best = feasibleCandidate;
|
||||
bestExpiry = expiry;
|
||||
bestEvaluation = evaluation;
|
||||
}
|
||||
}
|
||||
if (best !== result) recordTraceState(context, best);
|
||||
if (best !== result) recordTraceState(context, best, bestEvaluation);
|
||||
result = best;
|
||||
}
|
||||
return result;
|
||||
@@ -3536,6 +3561,7 @@ function supportingPlacements(
|
||||
const before = qualityMetrics(result, context, preferences);
|
||||
let best: State | null = null;
|
||||
let bestMetrics: PlanningMetrics | null = null;
|
||||
let bestEvaluation: MutableTraceCandidate | undefined;
|
||||
for (const balance of result.balances
|
||||
.filter(
|
||||
(item) =>
|
||||
@@ -3556,7 +3582,7 @@ function supportingPlacements(
|
||||
const feasibleCandidate = finalizeEarnedState(candidate, context);
|
||||
if (!feasibleCandidate) continue;
|
||||
const metrics = qualityMetrics(feasibleCandidate, context, preferences);
|
||||
recordTraceEvaluation(
|
||||
const evaluation = recordTraceEvaluation(
|
||||
context,
|
||||
result,
|
||||
feasibleCandidate,
|
||||
@@ -3586,11 +3612,12 @@ function supportingPlacements(
|
||||
if (!bestMetrics || compareMetrics(metrics, bestMetrics) < 0) {
|
||||
best = feasibleCandidate;
|
||||
bestMetrics = metrics;
|
||||
bestEvaluation = evaluation;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!best) return result;
|
||||
recordTraceState(context, best);
|
||||
recordTraceState(context, best, bestEvaluation);
|
||||
result = best;
|
||||
}
|
||||
}
|
||||
@@ -3613,7 +3640,7 @@ function pruneRedundantSupportingPlacements(
|
||||
const balance = candidate.balances.find((item) => item.id === placement.balanceId);
|
||||
if (balance) balance.remaining++;
|
||||
const metrics = qualityMetrics(candidate, context, preferences);
|
||||
recordTraceEvaluation(
|
||||
const evaluation = recordTraceEvaluation(
|
||||
context,
|
||||
result,
|
||||
candidate,
|
||||
@@ -3630,7 +3657,7 @@ function pruneRedundantSupportingPlacements(
|
||||
metrics.longestWorkSequence <= baseline.longestWorkSequence &&
|
||||
metrics.shortSequenceDeficit <= baseline.shortSequenceDeficit
|
||||
) {
|
||||
recordTraceState(context, candidate);
|
||||
recordTraceState(context, candidate, evaluation);
|
||||
result = candidate;
|
||||
removed = true;
|
||||
break;
|
||||
@@ -4128,6 +4155,34 @@ function counterfactualRuleValid(
|
||||
return true;
|
||||
}
|
||||
|
||||
function counterfactualObligationsValid(
|
||||
state: State,
|
||||
context: SearchContext,
|
||||
previous: State,
|
||||
): boolean {
|
||||
return context.rules
|
||||
.filter((rule) => rule.enabled && rule.force === 'obligation')
|
||||
.every((rule) => {
|
||||
if (rule.type === 'time_window') {
|
||||
const count = (candidate: State) =>
|
||||
[...candidate.placements.values()].filter((placement) =>
|
||||
placement.ruleIds.has(rule.id),
|
||||
).length;
|
||||
return count(previous) !== rule.placeDays || count(state) === rule.placeDays;
|
||||
}
|
||||
return occurrences(rule, context).every((occurrence) => {
|
||||
const patterns = patternsFor(rule, occurrence, context);
|
||||
const complete = (candidate: State) =>
|
||||
patterns.some((pattern) =>
|
||||
pattern.every(
|
||||
(date) => candidate.placements.get(date)?.ruleIds.has(rule.id) === true,
|
||||
),
|
||||
);
|
||||
return !complete(previous) || complete(state);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function preferenceResultsForState(state: State, context: SearchContext): PreferenceResult[] {
|
||||
return context.rules
|
||||
.filter((rule) => rule.enabled && rule.force === 'preference')
|
||||
@@ -4327,6 +4382,17 @@ export function analyzeCounterfactualInternal(
|
||||
continue;
|
||||
}
|
||||
candidate.placements.get(date)!.supporting = selected.supporting;
|
||||
if (!counterfactualObligationsValid(candidate, context, comparisonState)) {
|
||||
results.push({
|
||||
date,
|
||||
status: 'ineligible',
|
||||
reason: counterfactualReason(
|
||||
'rule-invalid',
|
||||
'The date would leave an obligation unsatisfied.',
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const afterPreferences = preferenceResultsForState(candidate, context);
|
||||
const afterMetrics = qualityMetrics(candidate, context, afterPreferences);
|
||||
const after = objectiveVector(afterMetrics, afterPreferences, candidate);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type {
|
||||
CounterfactualAnalysis,
|
||||
CounterfactualBaselineOptions,
|
||||
CounterfactualDateResult,
|
||||
CounterfactualSelection,
|
||||
InvalidInputOutcome,
|
||||
LeaveBalanceInput,
|
||||
@@ -18,6 +17,7 @@ import type {
|
||||
PlanningReason,
|
||||
PlanningTraceState,
|
||||
StaleCounterfactualAnalysis,
|
||||
TraceCandidateStatus,
|
||||
TraceCounterfactualSelection,
|
||||
} from './leave-planner.core';
|
||||
|
||||
@@ -281,6 +281,7 @@ type PlannerWorkerResponse =
|
||||
interface PlanningRun {
|
||||
outcome: Promise<PlanningOutcome>;
|
||||
artifacts: Promise<PlanningArtifacts>;
|
||||
cancel?: () => void;
|
||||
}
|
||||
|
||||
function traceStateFor(snapshot: TransportSnapshot): PlanningTraceState {
|
||||
@@ -337,6 +338,8 @@ function interruptedOutcome(
|
||||
};
|
||||
}
|
||||
|
||||
let latestWorkerCancel: (() => void) | undefined;
|
||||
|
||||
function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRun {
|
||||
let resolveOutcome!: (outcome: PlanningOutcome) => void;
|
||||
let resolveArtifacts!: (artifacts: PlanningArtifacts) => void;
|
||||
@@ -346,6 +349,7 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
|
||||
const artifacts = new Promise<PlanningArtifacts>((resolve) => {
|
||||
resolveArtifacts = resolve;
|
||||
});
|
||||
let cancel: (() => void) | undefined;
|
||||
try {
|
||||
const worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
|
||||
type: 'module',
|
||||
@@ -375,8 +379,25 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
|
||||
artifactsSettled = true;
|
||||
resolveArtifacts(result);
|
||||
terminate();
|
||||
if (latestWorkerCancel === cancel) latestWorkerCancel = undefined;
|
||||
}
|
||||
};
|
||||
cancel = () => {
|
||||
if (artifactsSettled) return;
|
||||
const hadOutcome = outcomeSettled;
|
||||
const reason = {
|
||||
code: 'stale-calculation',
|
||||
message: 'Planning stopped because a newer calculation started.',
|
||||
};
|
||||
if (!hadOutcome)
|
||||
finishOutcome(
|
||||
interruptedOutcome(checkpoint, deadline, reason.code, reason.message),
|
||||
);
|
||||
finishArtifacts({
|
||||
trace: fallbackTrace(snapshot, 'failed', reason),
|
||||
explanationError: hadOutcome ? undefined : reason,
|
||||
});
|
||||
};
|
||||
const fail = (code: string, message: string) => {
|
||||
const reason = { code, message };
|
||||
if (!outcomeSettled)
|
||||
@@ -453,7 +474,7 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
|
||||
explanationError: result.reasons[0],
|
||||
});
|
||||
}
|
||||
return { outcome, artifacts };
|
||||
return { outcome, artifacts, cancel };
|
||||
}
|
||||
|
||||
let calculationSequence = 0;
|
||||
@@ -480,6 +501,62 @@ function traceSelection(
|
||||
return typeof selection === 'object' && 'decisionId' in selection;
|
||||
}
|
||||
|
||||
const traceStatusPriority = new Map<TraceCandidateStatus, number>([
|
||||
['ineligible', 0],
|
||||
['not-examined', 1],
|
||||
['pruned', 2],
|
||||
['evaluated-rejected', 3],
|
||||
['chosen', 4],
|
||||
]);
|
||||
const historicalStatusCache = new WeakMap<PlanningTrace, Map<string, TraceCandidateStatus>>();
|
||||
const historicalNonWorkingCache = new WeakMap<PlanningTrace, Set<string>>();
|
||||
|
||||
function historicalStatuses(
|
||||
trace: PlanningTrace,
|
||||
decisionId?: string,
|
||||
): Map<string, TraceCandidateStatus> {
|
||||
if (!decisionId) {
|
||||
const cached = historicalStatusCache.get(trace);
|
||||
if (cached) return cached;
|
||||
}
|
||||
const statuses = new Map<string, TraceCandidateStatus>();
|
||||
const nonWorkingDates = new Set<string>();
|
||||
for (const phase of trace.phases) {
|
||||
for (const decision of phase.decisions) {
|
||||
if (decisionId && decision.id !== decisionId) continue;
|
||||
for (const candidate of decision.candidates) {
|
||||
for (const date of candidate.dates ?? (candidate.date ? [candidate.date] : [])) {
|
||||
if (
|
||||
!decisionId &&
|
||||
candidate.status === 'ineligible' &&
|
||||
candidate.reason?.code === 'non-working'
|
||||
)
|
||||
nonWorkingDates.add(date);
|
||||
const previous = statuses.get(date);
|
||||
if (
|
||||
!previous ||
|
||||
traceStatusPriority.get(candidate.status)! >
|
||||
traceStatusPriority.get(previous)!
|
||||
)
|
||||
statuses.set(date, candidate.status);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!decisionId) {
|
||||
historicalStatusCache.set(trace, statuses);
|
||||
historicalNonWorkingCache.set(trace, nonWorkingDates);
|
||||
}
|
||||
return statuses;
|
||||
}
|
||||
|
||||
function historicalNonWorkingDates(trace: PlanningTrace): ReadonlySet<string> {
|
||||
const cached = historicalNonWorkingCache.get(trace);
|
||||
if (cached) return cached;
|
||||
historicalStatuses(trace);
|
||||
return historicalNonWorkingCache.get(trace) ?? new Set();
|
||||
}
|
||||
|
||||
function stateBeforeDecision(
|
||||
trace: PlanningTrace,
|
||||
decisionId: string,
|
||||
@@ -574,31 +651,19 @@ function withHistoricalStatuses(
|
||||
decisionId?: string,
|
||||
): CounterfactualAnalysis {
|
||||
if (!trace) return analysis;
|
||||
const priority = new Map([
|
||||
['ineligible', 0],
|
||||
['not-examined', 1],
|
||||
['pruned', 2],
|
||||
['evaluated-rejected', 3],
|
||||
['chosen', 4],
|
||||
]);
|
||||
const statuses = new Map<string, CounterfactualDateResult['historicalStatus']>();
|
||||
for (const candidate of trace.phases.flatMap((phase) =>
|
||||
phase.decisions
|
||||
.filter((decision) => !decisionId || decision.id === decisionId)
|
||||
.flatMap((decision) => decision.candidates),
|
||||
)) {
|
||||
for (const date of candidate.dates ?? (candidate.date ? [candidate.date] : [])) {
|
||||
const previous = statuses.get(date);
|
||||
if (!previous || priority.get(candidate.status)! > priority.get(previous)!)
|
||||
statuses.set(date, candidate.status);
|
||||
}
|
||||
}
|
||||
const statuses = historicalStatuses(trace, decisionId);
|
||||
const nonWorkingDates = decisionId ? historicalNonWorkingDates(trace) : undefined;
|
||||
return {
|
||||
...analysis,
|
||||
results: analysis.results.map((result) => ({
|
||||
...result,
|
||||
historicalStatus:
|
||||
statuses.get(result.date) ?? (decisionId ? 'not-examined' : undefined),
|
||||
statuses.get(result.date) ??
|
||||
(decisionId
|
||||
? nonWorkingDates?.has(result.date)
|
||||
? 'ineligible'
|
||||
: 'not-examined'
|
||||
: undefined),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -783,6 +848,8 @@ export interface PlanningCalculationContext {
|
||||
}
|
||||
|
||||
export function createPlanningCalculation(request: PlanningRequest): PlanningCalculationContext {
|
||||
latestWorkerCancel?.();
|
||||
latestWorkerCancel = undefined;
|
||||
const transport = snapshotRequest(request);
|
||||
const snapshot = publicSnapshot(transport);
|
||||
const id = `calculation:${++calculationSequence}`;
|
||||
@@ -799,6 +866,10 @@ export function createPlanningCalculation(request: PlanningRequest): PlanningCal
|
||||
if (typeof window !== 'undefined') {
|
||||
if (typeof Worker !== 'undefined') {
|
||||
run = planInWorker(transport, deadline);
|
||||
latestWorkerCancel = run.cancel;
|
||||
void run.artifacts.then(() => {
|
||||
if (latestWorkerCancel === run.cancel) latestWorkerCancel = undefined;
|
||||
});
|
||||
} else {
|
||||
let resolveArtifacts!: (artifacts: PlanningArtifacts) => void;
|
||||
const artifacts = new Promise<PlanningArtifacts>(
|
||||
|
||||
Reference in new issue
Block a user