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

+130 -64
View File
@@ -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);