@@ -1106,6 +1314,12 @@
)
}}
+ @if (dateStatuses()[result.date]?.reason; as historicalReason) {
+
+ {{ tr().planning.reason }}:
+ {{ historicalReason }}
+
+ }
@if (dateComparison(); as comparison) {
} @else {
@@ -1136,11 +1351,17 @@
diff --git a/src/app/components/calendar-year/calendar-year.scss b/src/app/components/calendar-year/calendar-year.scss
index 12f61ab..f743b0c 100644
--- a/src/app/components/calendar-year/calendar-year.scss
+++ b/src/app/components/calendar-year/calendar-year.scss
@@ -67,6 +67,10 @@ button {
opacity: 0.45;
cursor: not-allowed;
}
+ &[aria-disabled='true'] {
+ opacity: 0.45;
+ cursor: not-allowed;
+ }
}
summary {
@@ -353,12 +357,27 @@ output {
margin: 0;
}
.inline-error,
+.counterfactual-inline-error,
.conflict-explanation {
padding: 0.75rem;
border-inline-start: 4px solid var(--ui-error-text);
background: var(--ui-error-bg);
color: var(--ui-error-text);
}
+.counterfactual-inline-error {
+ display: grid;
+ justify-items: start;
+ gap: 0.75rem;
+}
+.counterfactual-progress {
+ color: var(--ui-text-secondary);
+ font-size: 0.875rem;
+}
+.decisive-summary {
+ padding: 0.75rem;
+ border-inline-start: 3px solid var(--mat-sys-primary);
+ background: var(--ui-panel-bg);
+}
.incomplete-explanation,
.incomplete-incumbent {
padding: 1rem;
@@ -461,6 +480,8 @@ output {
}
small {
color: var(--ui-text-secondary);
+ flex-shrink: 0;
+ white-space: nowrap;
}
}
.trace-phase-stepper {
diff --git a/src/app/components/calendar-year/calendar-year.ts b/src/app/components/calendar-year/calendar-year.ts
index 3296bd3..0c82ba2 100644
--- a/src/app/components/calendar-year/calendar-year.ts
+++ b/src/app/components/calendar-year/calendar-year.ts
@@ -33,6 +33,7 @@ import { RuleService } from '../../services/rule-service';
import { TranslationService } from '../../services/translation-service';
import { UserInputService } from '../../services/user-input-service';
import {
+ applyReplayDeltas,
CounterfactualAnalysis,
CounterfactualSelection,
LeavePlacement,
@@ -52,6 +53,7 @@ import {
type PlacementExplanation = PlanExplanation['placements'][number];
type TraceMoment = 'before' | 'after';
+type TraceTransition = Readonly<{ before: PlanningTraceState; after: PlanningTraceState }>;
@Component({
selector: 'app-calendar-year',
@@ -110,12 +112,24 @@ export class CalendarYear {
protected readonly traceReplay = signal(false);
protected readonly selectedTraceDate = signal
(null);
protected readonly liveMessage = signal('');
+ private readonly historicalIneligibility = signal>(
+ new Map(),
+ );
+ protected readonly historicalEligibilityLoading = signal(false);
+ protected readonly historicalEligibilityError = signal(null);
protected readonly planningHorizon = signal<{ start: string; end: string } | null>(null);
protected readonly selectedDates = signal(new SelectedDates());
private calculationVersion = 0;
private counterfactualVersion = 0;
+ private historicalEligibilityVersion = 0;
private activeCalculation: PlanningCalculationContext | null = null;
+ private readonly traceDecisionIndices = new WeakMap<
+ PlanningTrace,
+ ReadonlyMap
+ >();
+ private displayDateLocale = '';
+ private displayDateFormatter?: Intl.DateTimeFormat;
get tr() {
return this.translationService.tr;
@@ -146,6 +160,11 @@ export class CalendarYear {
protected readonly selectedTraceDecision = computed(
() => this.selectedTracePhase()?.decisions[this.traceDecisionIndex()],
);
+ protected readonly selectedTraceTransition = computed(() =>
+ this.traceReplay()
+ ? this.replayTraceTransition(this.planningTrace(), this.selectedTraceDecision())
+ : undefined,
+ );
protected readonly visibleTraceCandidates = computed(() => {
const start = this.traceCandidatePage() * 50;
@@ -227,12 +246,7 @@ export class CalendarYear {
);
protected readonly traceState = computed(() => {
- if (!this.traceReplay()) return undefined;
- return this.replayTraceState(
- this.planningTrace(),
- this.selectedTraceDecision()?.id,
- this.traceMoment(),
- );
+ return this.selectedTraceTransition()?.[this.traceMoment()];
});
protected readonly calendarSelectedDates = computed(() => {
@@ -250,7 +264,7 @@ export class CalendarYear {
return this.selectedCounterfactualDate() ?? this.counterfactual()!.placement.date;
if (this.traceReplay())
return this.selectedTraceDate() ?? this.selectedTraceDecision()?.dates[0] ?? null;
- return this.planDetailsOpen() ? (this.selectedExplanation()?.placement.date ?? null) : null;
+ return this.selectedExplanation()?.placement.date ?? null;
});
protected readonly dateStatuses = computed>>(() => {
@@ -307,6 +321,14 @@ export class CalendarYear {
}
}
}
+ for (const [date, reason] of this.historicalIneligibility()) {
+ if (statuses[date]?.kind !== 'not-examined') continue;
+ statuses[date] = {
+ kind: 'ineligible',
+ label: this.traceStatusLabel('ineligible'),
+ reason: this.reasonLabel(reason),
+ };
+ }
const decision = this.selectedTraceDecision();
if (decision) {
const changedDates = new Set();
@@ -325,7 +347,7 @@ export class CalendarYear {
changeLabel: this.traceChangeLabel(change),
};
};
- for (const delta of decision.deltas) {
+ for (const delta of decision.branch?.deltas ?? decision.deltas) {
if (delta.kind === 'placement-added') setChange(delta.date, 'added');
else if (delta.kind === 'placement-removed') setChange(delta.date, 'removed');
else if (delta.kind === 'placement-moved') {
@@ -333,11 +355,10 @@ export class CalendarYear {
setChange(delta.toDate, 'moved-to');
} else if (delta.kind === 'placement-updated') setChange(delta.date, 'shared');
}
- const before = this.replayTraceState(trace, decision.id, 'before');
- const after = this.replayTraceState(trace, decision.id, 'after');
+ const transition = this.selectedTraceTransition();
for (const placement of [
- ...(before?.placements ?? []),
- ...(after?.placements ?? []),
+ ...(transition?.before.placements ?? []),
+ ...(transition?.after.placements ?? []),
]) {
if (!changedDates.has(placement.date)) setChange(placement.date, 'unchanged');
}
@@ -351,6 +372,9 @@ export class CalendarYear {
...status,
kind: historicalStatus,
label: this.traceStatusLabel(historicalStatus),
+ reason: result.historicalReason
+ ? this.reasonLabel(result.historicalReason)
+ : status?.reason,
hypotheticalLabel:
result.status === 'eligible'
? this.tr().planning.eligible
@@ -367,7 +391,11 @@ export class CalendarYear {
statuses[result.date] = {
kind: historicalStatus,
label: this.traceStatusLabel(historicalStatus),
- reason: historicalStatus === 'ineligible' ? nonWorkingReason : undefined,
+ reason: result.historicalReason
+ ? this.reasonLabel(result.historicalReason)
+ : historicalStatus === 'ineligible'
+ ? nonWorkingReason
+ : undefined,
hypotheticalLabel:
result.status === 'eligible'
? this.tr().planning.eligible
@@ -381,11 +409,18 @@ export class CalendarYear {
protected readonly months = computed(() => {
const horizon = this.planningHorizon();
if (!horizon) return [];
- const start = new Date(`${horizon.start}T00:00:00`);
- const last = new Date(`${horizon.end}T00:00:00`);
+ let first = new Date(`${horizon.start}T00:00:00`);
+ let last = new Date(`${horizon.end}T00:00:00`);
last.setDate(last.getDate() - 1);
+ for (const period of this.displayedPlan()?.timeOffPeriods ?? []) {
+ for (const date of period.nonWorkingDates) {
+ const contextDate = new Date(`${date}T00:00:00`);
+ if (date < horizon.start && contextDate < first) first = contextDate;
+ else if (date >= horizon.end && contextDate > last) last = contextDate;
+ }
+ }
const result: { monthIndex: number; year: number }[] = [];
- const cursor = new Date(start.getFullYear(), start.getMonth(), 1);
+ const cursor = new Date(first.getFullYear(), first.getMonth(), 1);
while (cursor <= last) {
result.push({ monthIndex: cursor.getMonth(), year: cursor.getFullYear() });
cursor.setMonth(cursor.getMonth() + 1);
@@ -406,6 +441,7 @@ export class CalendarYear {
const version = ++this.calculationVersion;
this.isLoading.set(true);
this.loadingProgress.set(0);
+ this.liveMessage.set(this.tr().loading);
queueMicrotask(() => {
let calculation: PlanningCalculationContext;
try {
@@ -494,26 +530,57 @@ export class CalendarYear {
this.traceCandidatePage.set(0);
this.traceMoment.set('before');
this.traceReplay.set(false);
+ this.clearHistoricalEligibility();
this.selectedTraceDate.set(null);
this.selectedDates.set(this.calendarService.getSelectedDatesForYear());
this.userInput.remainingVacationDaysSignal.set([]);
}
private loadArtifacts(calculation: PlanningCalculationContext, version: number): void {
- void calculation.explanation.then((explanation) => {
- if (version !== this.calculationVersion) return;
- this.planningExplanation.set(explanation ?? null);
- if (explanation?.kind === 'PlanExplanation' && this.planDetailsOpen())
- this.selectPlacement(0);
- });
- void calculation.explanationError.then((reason) => {
- if (version === this.calculationVersion) this.explanationError.set(reason ?? null);
- });
+ void calculation.explanation
+ .then((explanation) => {
+ if (version !== this.calculationVersion) return;
+ this.planningExplanation.set(explanation ?? null);
+ if (explanation?.kind === 'PlanExplanation') {
+ this.planDetailsOpen.set(true);
+ this.selectPlacement(0, false, false);
+ this.liveMessage.set(
+ `${this.planningOutcome() ? this.outcomeLabel(this.planningOutcome()!) : ''} ${this.placementAnnouncement()}`.trim(),
+ );
+ }
+ })
+ .catch((error: unknown) => {
+ if (version !== this.calculationVersion) return;
+ const reason = {
+ code: 'explanation-error',
+ message: error instanceof Error ? error.message : String(error),
+ };
+ this.explanationError.set(reason);
+ this.liveMessage.set(`${this.tr().planning.explanationError} ${reason.message}`);
+ });
+ void calculation.explanationError
+ .then((reason) => {
+ if (version === this.calculationVersion) this.explanationError.set(reason ?? null);
+ })
+ .catch((error: unknown) => {
+ if (version !== this.calculationVersion) return;
+ const reason = {
+ code: 'explanation-error',
+ message: error instanceof Error ? error.message : String(error),
+ };
+ this.explanationError.set(reason);
+ this.liveMessage.set(`${this.tr().planning.explanationError} ${reason.message}`);
+ });
void calculation.trace
.then((trace) => {
if (version !== this.calculationVersion) return;
this.planningTrace.set(trace ?? null);
this.traceResolved.set(true);
+ this.liveMessage.set(
+ trace?.status === 'failed'
+ ? this.tr().planning.traceUnavailable
+ : this.tr().planning.traceReady,
+ );
})
.catch((error: unknown) => {
if (version !== this.calculationVersion) return;
@@ -533,6 +600,7 @@ export class CalendarYear {
},
});
this.traceResolved.set(true);
+ this.liveMessage.set(this.tr().planning.traceUnavailable);
})
.finally(() => {
if (version === this.calculationVersion) this.traceLoading.set(false);
@@ -553,7 +621,7 @@ export class CalendarYear {
this.userInput.remainingVacationDaysSignal.set([]);
}
- protected selectPlacement(index: number): void {
+ protected selectPlacement(index: number, analyze = true, announce = true): void {
const explanation = this.planExplanation();
if (!explanation?.placements.length) return;
const selected = Math.max(0, Math.min(index, explanation.placements.length - 1));
@@ -561,14 +629,27 @@ export class CalendarYear {
this.traceReplay.set(false);
this.clearCounterfactual();
this.selectedCounterfactualDate.set(explanation.placements[selected].placement.date);
- this.liveMessage.set(
- `${this.tr().planning.placement} ${selected + 1} ${this.tr().planning.of} ${explanation.placements.length}`,
- );
+ if (announce) this.liveMessage.set(this.placementAnnouncement());
+ if (analyze) void this.analyzeSelection(explanation.placements[selected].placement);
}
protected compareDates(): void {
const placement = this.selectedExplanation()?.placement;
- if (placement) void this.analyzeSelection(placement);
+ if (!placement) return;
+ this.openComparisonDialog();
+ if (this.counterfactual()) {
+ if (this.selectedCounterfactualDate() === placement.date) {
+ const alternative = this.nearbyComparisonDates()[0];
+ if (alternative) this.selectCalendarDate(alternative.date);
+ }
+ } else if (!this.counterfactualLoading()) {
+ void this.analyzeSelection(placement, true);
+ }
+ }
+
+ protected retryCounterfactual(): void {
+ if (this.counterfactualSelection)
+ void this.analyzeSelection(this.counterfactualSelection, !!this.comparisonDialog);
}
protected togglePlanDetails(event: Event): void {
@@ -592,15 +673,15 @@ export class CalendarYear {
protected stepPlacement(step: 'first' | 'previous' | 'next' | 'last'): void {
const last = (this.planExplanation()?.placements.length ?? 1) - 1;
const current = this.selectedPlacementIndex();
- this.selectPlacement(
+ const target =
step === 'first'
? 0
: step === 'last'
? last
: step === 'previous'
? current - 1
- : current + 1,
- );
+ : current + 1;
+ if (target !== current) this.selectPlacement(target);
}
protected selectCalendarDate(date: string): void {
@@ -616,10 +697,6 @@ export class CalendarYear {
this.planDetailsOpen.set(true);
this.traceOpen.set(false);
this.selectPlacement(index);
- this.planDetails()?.nativeElement.scrollIntoView?.({ block: 'start' });
- this.planDetails()
- ?.nativeElement.querySelector('summary')
- ?.focus({ preventScroll: true });
return;
}
}
@@ -661,25 +738,24 @@ export class CalendarYear {
this.traceCandidatePage.set(0);
this.traceMoment.set('before');
this.traceReplay.set(false);
+ this.clearHistoricalEligibility();
this.selectedTraceDate.set(null);
this.clearCounterfactual();
- this.liveMessage.set(
- `${this.tr().planning.phases} ${index + 1} ${this.tr().planning.of} ${this.planningTrace()?.phases.length ?? 0}`,
- );
+ this.liveMessage.set(this.tracePhaseAnnouncement());
}
protected stepTracePhase(step: 'first' | 'previous' | 'next' | 'last'): void {
const last = (this.planningTrace()?.phases.length ?? 1) - 1;
const current = this.tracePhaseIndex();
- this.selectTracePhase(
+ const target =
step === 'first'
? 0
: step === 'last'
? last
: step === 'previous'
? current - 1
- : current + 1,
- );
+ : current + 1;
+ if (target !== current) this.selectTracePhase(target);
}
protected selectTraceDecision(index: number): void {
@@ -691,28 +767,94 @@ export class CalendarYear {
this.traceReplay.set(true);
this.selectedTraceDate.set(this.selectedTraceDecision()?.dates[0] ?? null);
this.clearCounterfactual();
- this.liveMessage.set(
- `${this.tr().planning.decision} ${index + 1} ${this.tr().planning.of} ${this.selectedTracePhase()?.decisions.length ?? 0}`,
- );
+ this.liveMessage.set(this.traceDecisionAnnouncement());
+ void this.loadHistoricalEligibility();
+ }
+
+ private selectedDecisionPlacement(decision: PlanningTraceDecision): LeavePlacement | undefined {
+ const placements = [
+ ...decision.candidates.flatMap((candidate) =>
+ candidate.placement ? [candidate.placement] : [],
+ ),
+ ...(decision.branch?.deltas ?? decision.deltas).flatMap((delta) =>
+ delta.placement ? [delta.placement] : [],
+ ),
+ ];
+ const selectedDate = this.selectedTraceDate() ?? decision.dates[0];
+ return placements.find((placement) => placement.date === selectedDate) ?? placements[0];
+ }
+
+ private clearHistoricalEligibility(): void {
+ this.historicalEligibilityVersion++;
+ this.historicalIneligibility.set(new Map());
+ this.historicalEligibilityLoading.set(false);
+ this.historicalEligibilityError.set(null);
+ }
+
+ private async loadHistoricalEligibility(): Promise {
+ const version = ++this.historicalEligibilityVersion;
+ this.historicalIneligibility.set(new Map());
+ this.historicalEligibilityLoading.set(false);
+ this.historicalEligibilityError.set(null);
+ const calculation = this.activeCalculation;
+ const decision = this.selectedTraceDecision();
+ const placement = decision && this.selectedDecisionPlacement(decision);
+ if (!this.traceReplay() || !calculation || !decision || !placement) return;
+ this.historicalEligibilityLoading.set(true);
+ this.liveMessage.set(this.tr().planning.historicalEligibilityLoading);
+ try {
+ const reasons = await calculation.historicalEligibility({
+ decisionId: decision.id,
+ placement,
+ });
+ if (
+ version !== this.historicalEligibilityVersion ||
+ calculation !== this.activeCalculation
+ )
+ return;
+ this.historicalIneligibility.set(
+ new Map(reasons.map(({ date, reason }) => [date, reason])),
+ );
+ this.liveMessage.set(this.tr().planning.historicalEligibilityReady);
+ } catch (error) {
+ if (version !== this.historicalEligibilityVersion) return;
+ this.historicalIneligibility.set(new Map());
+ const message = error instanceof Error ? error.message : String(error);
+ this.historicalEligibilityError.set(message);
+ this.liveMessage.set(`${this.tr().planning.historicalEligibilityError} ${message}`);
+ } finally {
+ if (version === this.historicalEligibilityVersion)
+ this.historicalEligibilityLoading.set(false);
+ }
+ }
+
+ protected retryHistoricalEligibility(): void {
+ void this.loadHistoricalEligibility();
}
protected stepTraceDecision(step: 'first' | 'previous' | 'next' | 'last'): void {
const last = (this.selectedTracePhase()?.decisions.length ?? 1) - 1;
const current = this.traceDecisionIndex();
- this.selectTraceDecision(
+ const target =
step === 'first'
? 0
: step === 'last'
? last
: step === 'previous'
? current - 1
- : current + 1,
- );
+ : current + 1;
+ if (target !== current) this.selectTraceDecision(target);
}
protected stepTraceCandidatePage(direction: -1 | 1): void {
- this.traceCandidatePage.update((page) =>
- Math.max(0, Math.min(page + direction, this.traceCandidatePages() - 1)),
+ const page = Math.max(
+ 0,
+ Math.min(this.traceCandidatePage() + direction, this.traceCandidatePages() - 1),
+ );
+ if (page === this.traceCandidatePage()) return;
+ this.traceCandidatePage.set(page);
+ this.liveMessage.set(
+ `${this.tr().planning.page} ${this.traceCandidatePage() + 1} ${this.tr().planning.of} ${this.traceCandidatePages()}`,
);
}
@@ -720,13 +862,13 @@ export class CalendarYear {
this.clearCounterfactual();
this.traceMoment.set(moment);
this.traceReplay.set(true);
- this.liveMessage.set(
- `${this.tr().planning[moment]} ${this.tr().planning.decision} ${this.traceDecisionIndex() + 1} ${this.tr().planning.of} ${this.selectedTracePhase()?.decisions.length ?? 0}`,
- );
+ this.liveMessage.set(`${this.tr().planning[moment]} ${this.traceDecisionAnnouncement()}`);
+ void this.loadHistoricalEligibility();
}
protected showFinalPlan(): void {
this.traceReplay.set(false);
+ this.clearHistoricalEligibility();
this.clearCounterfactual();
this.liveMessage.set(this.tr().planning.finalPlan);
}
@@ -737,7 +879,10 @@ export class CalendarYear {
this.traceMoment.set('before');
this.traceReplay.set(true);
this.selectedTraceDate.set(candidate.placement.date);
- void this.analyzeSelection({ decisionId: decision.id, placement: candidate.placement });
+ void this.analyzeSelection(
+ { decisionId: decision.id, placement: candidate.placement },
+ true,
+ );
}
private nonWorkingDateReason(
@@ -750,59 +895,81 @@ export class CalendarYear {
return undefined;
}
- private async analyzeSelection(selection: CounterfactualSelection): Promise {
+ private counterfactualSelection: CounterfactualSelection | null = null;
+
+ private async analyzeSelection(
+ selection: CounterfactualSelection,
+ openDialog = false,
+ ): Promise {
const calculation = this.activeCalculation;
if (!calculation) return;
+ this.counterfactualSelection = selection;
const version = ++this.counterfactualVersion;
this.counterfactual.set(null);
this.counterfactualError.set(null);
this.counterfactualLoading.set(true);
this.invalidComparisonDate.set(false);
- if (!this.comparisonDialog) {
- const dialog = this.dialog.open(this.comparisonTemplate(), {
- width: '48rem',
- maxWidth: 'calc(100vw - 2rem)',
- maxHeight: 'calc(100dvh - 2rem)',
- autoFocus: 'first-heading',
- ariaLabelledBy: 'counterfactual-title',
- ariaDescribedBy: 'comparison-intro',
- });
- this.comparisonDialog = dialog;
- dialog.beforeClosed().subscribe(() => {
- if (this.comparisonDialog === dialog) {
- this.comparisonDialog = null;
- this.clearCounterfactual();
- }
- });
- }
+ if (openDialog) this.openComparisonDialog();
+ const placement =
+ typeof selection === 'string'
+ ? selection
+ : 'placement' in selection
+ ? selection.placement
+ : selection;
+ this.liveMessage.set(
+ `${this.tr().planning.counterfactualLoading} ${typeof placement === 'string' ? this.displayDate(placement) : this.displayDate(placement.date)}`,
+ );
try {
const result = await calculation.analyzeCounterfactual(selection);
if (version !== this.counterfactualVersion || calculation !== this.activeCalculation)
return;
if (result.kind === 'Counterfactual') {
this.counterfactual.set(result);
- this.selectCalendarDate(
- this.traceReplay()
- ? result.placement.date
- : (this.nearbyComparisonDates()[0]?.date ?? result.placement.date),
- );
+ if (openDialog || this.comparisonDialog)
+ this.selectCalendarDate(
+ this.traceReplay()
+ ? result.placement.date
+ : (this.nearbyComparisonDates()[0]?.date ?? result.placement.date),
+ );
this.liveMessage.set(this.tr().planning.counterfactualReady);
}
} catch (error) {
if (version !== this.counterfactualVersion || calculation !== this.activeCalculation)
return;
- this.counterfactualError.set(error instanceof Error ? error.message : String(error));
+ const message = error instanceof Error ? error.message : String(error);
+ this.counterfactualError.set(message);
+ this.liveMessage.set(`${this.tr().planning.counterfactualError} ${message}`);
} finally {
if (version === this.counterfactualVersion) this.counterfactualLoading.set(false);
}
}
+ private openComparisonDialog(): void {
+ if (this.comparisonDialog) return;
+ const dialog = this.dialog.open(this.comparisonTemplate(), {
+ width: '48rem',
+ maxWidth: 'calc(100vw - 2rem)',
+ maxHeight: 'calc(100dvh - 2rem)',
+ autoFocus: 'first-heading',
+ ariaLabelledBy: 'counterfactual-title',
+ ariaDescribedBy: 'comparison-intro',
+ });
+ this.comparisonDialog = dialog;
+ dialog.beforeClosed().subscribe(() => {
+ if (this.comparisonDialog === dialog) {
+ this.comparisonDialog = null;
+ this.clearCounterfactual();
+ }
+ });
+ }
+
protected clearCounterfactual(): void {
const dialog = this.comparisonDialog;
this.comparisonDialog = null;
dialog?.close();
this.counterfactualVersion++;
this.counterfactual.set(null);
+ this.counterfactualSelection = null;
this.selectedCounterfactualDate.set(null);
this.counterfactualLoading.set(false);
this.counterfactualError.set(null);
@@ -810,70 +977,130 @@ export class CalendarYear {
this.comparisonDateInput.set(null);
}
- private replayTraceState(
+ private replayTraceTransition(
trace: PlanningTrace | null,
- decisionId: string | undefined,
- moment: TraceMoment,
- ): PlanningTraceState | undefined {
- if (!trace || !decisionId) return undefined;
- const balances = trace.initialState.balances.map((balance) => ({ ...balance }));
- const placements = new Map(
- trace.initialState.placements.map((placement) => [placement.date, { ...placement }]),
- );
- const earnedActivations = trace.initialState.earnedActivations.map((activation) => ({
- ...activation,
- dates: [...activation.dates],
- }));
+ decision: PlanningTraceDecision | undefined,
+ ): TraceTransition | undefined {
+ if (!trace || !decision) return undefined;
+ const state = {
+ balances: trace.initialState.balances.map((balance) => ({ ...balance })),
+ placements: new Map(
+ trace.initialState.placements.map((placement) => [
+ placement.date,
+ { ...placement, ruleIds: [...placement.ruleIds] },
+ ]),
+ ),
+ earnedActivations: trace.initialState.earnedActivations.map((activation) => ({
+ ...activation,
+ dates: [...activation.dates],
+ })),
+ };
const snapshot = (): PlanningTraceState => ({
horizon: { ...trace.initialState.horizon },
- balances,
- placements: [...placements.values()],
- earnedActivations,
+ balances: state.balances.map((balance) => ({ ...balance })),
+ placements: [...state.placements.values()].map((placement) => ({
+ ...placement,
+ ruleIds: [...placement.ruleIds],
+ })),
+ earnedActivations: state.earnedActivations.map((activation) => ({
+ ...activation,
+ dates: [...activation.dates],
+ })),
});
- for (const decision of trace.phases.flatMap((phase) => phase.decisions)) {
- if (decision.id === decisionId && moment === 'before') return snapshot();
- this.applyTraceDecision(decision, placements, balances, earnedActivations);
- if (decision.id === decisionId) return snapshot();
+ if (decision.branch) {
+ const decisionIndex = this.getTraceDecisionIndex(trace);
+ const target = decisionIndex.get(decision.id);
+ if (!target) return undefined;
+ const branchAncestors: PlanningTraceDecision[] = [];
+ const visited = new Set([decision.id]);
+ let current = decision;
+ let committedParentOrdinal: number | undefined;
+ while (current.branch) {
+ branchAncestors.unshift(current);
+ const parentId = current.branch.parentDecisionId;
+ if (!parentId) break;
+ if (visited.has(parentId)) return undefined;
+ visited.add(parentId);
+ const parent = decisionIndex.get(parentId);
+ if (!parent) return undefined;
+ if (!parent.decision.branch) {
+ committedParentOrdinal = parent.ordinal;
+ break;
+ }
+ current = parent.decision;
+ }
+ const branchRoot = branchAncestors[0];
+ const branchRootIndex = branchRoot && decisionIndex.get(branchRoot.id)?.ordinal;
+ if (branchRootIndex === undefined || target.ordinal < branchRootIndex) return undefined;
+ const replayThrough = committedParentOrdinal ?? branchRootIndex - 1;
+ if (replayThrough >= branchRootIndex) return undefined;
+ let ordinal = 0;
+ for (const phase of trace.phases) {
+ for (const prior of phase.decisions) {
+ if (ordinal++ > replayThrough) break;
+ applyReplayDeltas(state, prior.deltas);
+ }
+ if (ordinal > replayThrough) break;
+ }
+ for (const ancestor of branchAncestors.slice(0, -1))
+ applyReplayDeltas(state, ancestor.branch!.deltas);
+ const before = snapshot();
+ applyReplayDeltas(state, decision.branch.deltas);
+ return { before, after: snapshot() };
+ }
+ for (const phase of trace.phases) {
+ for (const prior of phase.decisions) {
+ if (prior.id === decision.id) {
+ const before = snapshot();
+ applyReplayDeltas(state, prior.deltas);
+ return { before, after: snapshot() };
+ }
+ applyReplayDeltas(state, prior.deltas);
+ }
}
return undefined;
}
- private applyTraceDecision(
- decision: PlanningTraceDecision,
- placements: Map,
- balances: PlanningTraceState['balances'][number][],
- earnedActivations: Array<{
- id: string;
- ruleId: string;
- occurrence: string;
- dates: string[];
- }>,
- ): void {
- for (const delta of decision.deltas) {
- if (delta.previousPlacement) placements.delete(delta.previousPlacement.date);
- if (delta.placement)
- placements.set(delta.placement.date, {
- ...delta.placement,
- ruleIds: [...delta.placement.ruleIds],
- });
- const balanceIndex = balances.findIndex(
- (candidate) => candidate.id === delta.balanceId,
- );
- if (balanceIndex >= 0 && delta.remainingAfter !== undefined)
- balances[balanceIndex] = {
- ...balances[balanceIndex],
- remaining: delta.remainingAfter,
- };
- else if (balanceIndex < 0 && delta.balance) balances.push({ ...delta.balance });
- if (
- delta.activation &&
- !earnedActivations.some((activation) => activation.id === delta.activation!.id)
- )
- earnedActivations.push({
- ...delta.activation,
- dates: [...delta.activation.dates],
- });
+ private getTraceDecisionIndex(
+ trace: PlanningTrace,
+ ): ReadonlyMap {
+ let index = this.traceDecisionIndices.get(trace);
+ if (!index) {
+ const decisions = new Map<
+ string,
+ { decision: PlanningTraceDecision; ordinal: number }
+ >();
+ let ordinal = 0;
+ for (const phase of trace.phases)
+ for (const decision of phase.decisions)
+ decisions.set(decision.id, { decision, ordinal: ordinal++ });
+ index = decisions;
+ this.traceDecisionIndices.set(trace, index);
}
+ return index;
+ }
+
+ protected placementAnnouncement(): string {
+ const selected = this.selectedExplanation();
+ const count = this.planExplanation()?.placements.length ?? 0;
+ if (!selected || !count) return '';
+ return `${this.tr().planning.placement} ${this.selectedPlacementIndex() + 1} ${this.tr().planning.of} ${count}: ${this.displayDate(selected.placement.date)}`;
+ }
+
+ protected hasAtRiskContribution(explanation: PlacementExplanation): boolean {
+ return explanation.contributions.some((contribution) => contribution.kind === 'at-risk');
+ }
+
+ protected tracePhaseAnnouncement(): string {
+ const phases = this.planningTrace()?.phases ?? [];
+ const phase = phases[this.tracePhaseIndex()];
+ return `${this.tr().planning.phases} ${this.tracePhaseIndex() + 1} ${this.tr().planning.of} ${phases.length}${phase ? `: ${this.phaseLabel(phase.name)}` : ''}`;
+ }
+
+ protected traceDecisionAnnouncement(): string {
+ const decisions = this.selectedTracePhase()?.decisions ?? [];
+ const decision = decisions[this.traceDecisionIndex()];
+ return `${this.tr().planning.decision} ${this.traceDecisionIndex() + 1} ${this.tr().planning.of} ${decisions.length}${decision?.dates.length ? `: ${decision.dates.join(', ')}` : ''}`;
}
protected hasPlannerError(): boolean {
@@ -974,10 +1201,15 @@ export class CalendarYear {
}
protected displayDate(value: string): string {
- return new Intl.DateTimeFormat(this.translationService.locale(), {
- dateStyle: 'medium',
- timeZone: 'UTC',
- }).format(new Date(value));
+ const locale = this.translationService.locale();
+ if (!this.displayDateFormatter || locale !== this.displayDateLocale) {
+ this.displayDateLocale = locale;
+ this.displayDateFormatter = new Intl.DateTimeFormat(locale, {
+ dateStyle: 'medium',
+ timeZone: 'UTC',
+ });
+ }
+ return this.displayDateFormatter.format(new Date(value));
}
protected planningDate(value: string | Date | undefined): string {
@@ -989,6 +1221,10 @@ export class CalendarYear {
return this.tr().planning.traceStatuses[status];
}
+ protected reasonLabel(reason: PlanningReason): string {
+ return this.tr().planning.explanation.unavailable[reason.code] ?? reason.message;
+ }
+
protected tracePhaseStatusLabel(status: 'completed' | 'interrupted' | 'not-run'): string {
return this.tr().planning.tracePhaseStatuses[status];
}
diff --git a/src/app/services/leave-planner-explanation.spec.ts b/src/app/services/leave-planner-explanation.spec.ts
new file mode 100644
index 0000000..0bba11c
--- /dev/null
+++ b/src/app/services/leave-planner-explanation.spec.ts
@@ -0,0 +1,1040 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { createPlanningCalculation } from './leave-planner.service';
+
+const balance = (
+ id: string,
+ type: 'cp' | 'rtt',
+ quantity: number,
+ expiryDate: string,
+ availableFrom = '2024-01-01',
+) => ({ id, type, quantity, expiryDate, availableFrom });
+
+beforeEach(() => vi.stubGlobal('window', undefined));
+afterEach(() => vi.unstubAllGlobals());
+
+describe('planner explanation and trace seam', () => {
+ 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 () => {
+ const calculation = createPlanningCalculation({
+ horizon: { start: '2024-01-01', end: '2024-01-08' },
+ balances: [balance('cp', 'cp', 2, '2024-12-31')],
+ rules: [
+ {
+ id: 'joint',
+ type: 'time_window',
+ force: 'obligation',
+ startDate: '2024-01-02',
+ endDate: '2024-01-05',
+ placeDays: 2,
+ placeType: 'cp',
+ },
+ {
+ id: 'shared',
+ type: 'time_window',
+ force: 'obligation',
+ startDate: '2024-01-03',
+ endDate: '2024-01-03',
+ 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 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);
+ 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: [],
+ });
+ 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 === '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[0]).toMatchObject({
+ technicalComparison: [
+ {
+ name: 'preference-date-conflict',
+ direction: 'minimize',
+ before: 0,
+ after: 0,
+ },
+ {
+ name: 'placement-date',
+ direction: 'minimize',
+ before: '2024-01-02',
+ after: '2024-01-03',
+ },
+ ],
+ firstDecisiveComparator: 'placement-date',
+ });
+ 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[0]).toMatchObject({
+ technicalComparison: [
+ {
+ name: 'preference-date-conflict',
+ direction: 'minimize',
+ before: 0,
+ after: 0,
+ },
+ {
+ name: 'placement-date',
+ direction: 'minimize',
+ before: '2024-01-03',
+ after: '2024-01-04',
+ },
+ ],
+ firstDecisiveComparator: 'placement-date',
+ });
+ 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 () => {
+ const calculation = createPlanningCalculation({
+ horizon: { start: '2024-01-01', end: '2024-01-08' },
+ balances: [balance('cp', 'cp', 1, '2025-01-01')],
+ rules: [
+ {
+ id: 'mandatory',
+ type: 'time_window',
+ force: 'obligation',
+ startDate: '2024-01-03',
+ endDate: '2024-01-03',
+ placeDays: 1,
+ placeType: 'cp',
+ },
+ ],
+ });
+ const outcome = await calculation.outcome;
+ const explanation = await calculation.explanation;
+ expect(outcome.kind).toBe('Plan');
+ expect(explanation?.kind).toBe('PlanExplanation');
+ if (explanation?.kind !== 'PlanExplanation') return;
+
+ const placement = explanation.placements[0];
+ expect(placement.objectiveVector.objectives[0]).toMatchObject({
+ id: 'obligation-validity',
+ direction: 'minimize',
+ value: 0,
+ });
+ expect(placement.comparison.before.objectives[0]).toMatchObject({
+ id: 'obligation-validity',
+ value: 1,
+ });
+ expect(placement.comparison.after.objectives[0]).toMatchObject({
+ id: 'obligation-validity',
+ value: 0,
+ });
+ expect(placement.firstDecisiveObjective).toBe('obligation-validity');
+ });
+
+ it('records the balance order used for a shared ANY preference placement', async () => {
+ const calculation = createPlanningCalculation({
+ horizon: { start: '2024-01-01', end: '2024-01-08' },
+ balances: [
+ balance('cp', 'cp', 1, '2025-01-04'),
+ balance('rtt-first', 'rtt', 1, '2025-01-05'),
+ balance('rtt-second', 'rtt', 1, '2025-01-06'),
+ ],
+ rules: [
+ {
+ id: 'any-preference',
+ type: 'time_window',
+ force: 'preference',
+ startDate: '2024-01-03',
+ endDate: '2024-01-03',
+ placeDays: 1,
+ placeType: 'any',
+ },
+ {
+ id: 'rtt-preference',
+ type: 'time_window',
+ force: 'preference',
+ startDate: '2024-01-03',
+ endDate: '2024-01-03',
+ placeDays: 1,
+ placeType: 'rtt',
+ },
+ ],
+ });
+ const outcome = await calculation.outcome;
+ const trace = await calculation.trace;
+ expect(outcome.kind).toBe('Plan');
+ if (outcome.kind !== 'Plan' || !trace) return;
+
+ const sharedPlacement = outcome.placements.find((placement) =>
+ placement.ruleIds.includes('any-preference'),
+ );
+ expect(sharedPlacement).toMatchObject({
+ date: '2024-01-03',
+ 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({
+ technicalComparison: [
+ {
+ name: 'shared-placement-type',
+ direction: 'maximize',
+ before: 0,
+ after: 0,
+ },
+ {
+ name: 'protected-preference-type',
+ direction: 'maximize',
+ before: 1,
+ after: 1,
+ },
+ {
+ name: 'balance-expiry',
+ direction: 'minimize',
+ before: '2025-01-05',
+ after: '2025-01-06',
+ },
+ ],
+ firstDecisiveComparator: 'balance-expiry',
+ placement: expect.objectContaining({ balanceId: 'rtt-first' }),
+ });
+ });
+
+ 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.startsWith('complete-occurrences: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.startsWith('complete-occurrences: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: 'complete-occurrences:if-then-preference',
+ direction: 'maximize',
+ }),
+ expect.objectContaining({
+ name: 'future-pattern-conflict-score:if-then-preference',
+ direction: 'minimize',
+ }),
+ ]),
+ );
+ expect(chosen?.decision.branch?.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 () => {
+ const calculation = createPlanningCalculation({
+ horizon: { start: '2024-04-25', end: '2024-05-08' },
+ balances: [
+ balance('cp', 'cp', 1, '2024-12-31'),
+ balance('rtt', 'rtt', 0, '2026-01-01'),
+ ],
+ rules: [
+ {
+ id: 'producer',
+ type: 'if_then',
+ force: 'obligation',
+ dayOfWeek: 3,
+ placeDays: 1,
+ placeType: 'cp',
+ placement: 'after',
+ rewardDays: 1,
+ rewardType: 'rtt',
+ },
+ {
+ id: 'consumer',
+ type: 'time_window',
+ force: 'obligation',
+ startDate: '2024-05-07',
+ endDate: '2024-05-07',
+ placeDays: 1,
+ placeType: 'rtt',
+ },
+ ],
+ });
+ const outcome = await calculation.outcome;
+ const trace = await calculation.trace;
+ expect(outcome.kind).toBe('Plan');
+ if (outcome.kind !== 'Plan' || !trace) return;
+
+ 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 () => {
+ const calculation = createPlanningCalculation({
+ horizon: { start: '2024-04-25', end: '2024-05-08' },
+ balances: [
+ balance('cp', 'cp', 1, '2025-12-31'),
+ balance('rtt', 'rtt', 0, '2026-01-01'),
+ ],
+ rules: [
+ {
+ id: 'producer',
+ type: 'if_then',
+ force: 'obligation',
+ dayOfWeek: 3,
+ placeDays: 1,
+ placeType: 'cp',
+ placement: 'auto',
+ rewardDays: 1,
+ rewardType: 'rtt',
+ },
+ {
+ id: 'consumer',
+ type: 'time_window',
+ force: 'obligation',
+ startDate: '2024-05-03',
+ endDate: '2024-05-03',
+ placeDays: 1,
+ placeType: 'rtt',
+ },
+ ],
+ });
+ const outcome = await calculation.outcome;
+ expect(outcome.kind).toBe('Plan');
+ if (outcome.kind !== 'Plan') return;
+ const producer = outcome.placements.find((placement) =>
+ placement.ruleIds.includes('producer'),
+ );
+ expect(producer?.date).toBe('2024-04-30');
+ if (!producer) return;
+
+ const analysis = await calculation.analyzeCounterfactual(producer);
+ expect(analysis.kind).toBe('Counterfactual');
+ if (analysis.kind !== 'Counterfactual') return;
+ expect(analysis.results.find((result) => result.date === '2024-05-02')).toMatchObject({
+ status: 'ineligible',
+ reason: { code: 'unavailable' },
+ });
+ expect(
+ analysis.results.find((result) => result.date === '2024-05-02')?.comparison,
+ ).toBeUndefined();
+ });
+
+ 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' },
+ balances: [
+ balance('cp1', 'cp', 1, '2025-12-31'),
+ balance('cp2', 'cp', 2, '2024-05-07'),
+ balance('rtt', 'rtt', 0, '2026-01-01'),
+ ],
+ rules: [
+ {
+ id: 'anchor',
+ type: 'time_window',
+ force: 'obligation',
+ startDate: '2024-04-25',
+ endDate: '2024-04-25',
+ placeDays: 1,
+ placeType: 'cp',
+ },
+ {
+ id: 'producer',
+ type: 'if_then',
+ force: 'obligation',
+ dayOfWeek: 3,
+ placeDays: 1,
+ placeType: 'cp',
+ placement: 'after',
+ rewardDays: 1,
+ rewardType: 'rtt',
+ },
+ {
+ id: 'consumer',
+ type: 'time_window',
+ force: 'obligation',
+ startDate: '2024-05-06',
+ endDate: '2024-05-06',
+ placeDays: 1,
+ placeType: 'rtt',
+ },
+ ],
+ });
+ const outcome = await calculation.outcome;
+ expect(outcome.kind).toBe('Plan');
+ if (outcome.kind !== 'Plan') return;
+ const atRisk = outcome.placements.find(
+ (placement) => placement.balanceId === 'cp2' && !placement.ruleIds.length,
+ );
+ expect(atRisk?.date).toBeDefined();
+ if (!atRisk) return;
+
+ const analysis = await calculation.analyzeCounterfactual(atRisk);
+ expect(analysis.kind).toBe('Counterfactual');
+ if (analysis.kind !== 'Counterfactual') return;
+ expect(analysis.results.find((result) => result.date === '2024-05-03')).toMatchObject({
+ status: 'ineligible',
+ reason: { code: 'unavailable' },
+ });
+ expect(
+ analysis.results.find((result) => result.date === '2024-05-03')?.comparison,
+ ).toBeUndefined();
+ });
+
+ it('records earned balance availability metadata changes when a shared period extends', async () => {
+ const calculation = createPlanningCalculation({
+ horizon: { start: '2024-04-25', end: '2024-05-15' },
+ balances: [
+ balance('cp', 'cp', 3, '2024-12-31'),
+ balance('rtt', 'rtt', 0, '2026-01-01'),
+ ],
+ rules: [
+ {
+ id: 'producer',
+ type: 'if_then',
+ force: 'obligation',
+ dayOfWeek: 3,
+ placeDays: 1,
+ placeType: 'cp',
+ placement: 'after',
+ rewardDays: 1,
+ rewardType: 'rtt',
+ },
+ {
+ id: 'cp-preference',
+ type: 'time_window',
+ force: 'preference',
+ startDate: '2024-05-03',
+ endDate: '2024-05-03',
+ placeDays: 1,
+ placeType: 'cp',
+ },
+ {
+ id: 'rtt-preference',
+ type: 'time_window',
+ force: 'preference',
+ startDate: '2024-05-07',
+ endDate: '2024-05-07',
+ placeDays: 1,
+ placeType: 'rtt',
+ },
+ ],
+ });
+ const outcome = await calculation.outcome;
+ const trace = await calculation.trace;
+ expect(outcome.kind).toBe('Plan');
+ if (outcome.kind !== 'Plan' || !trace) return;
+
+ 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 () => {
+ const calculation = createPlanningCalculation({
+ horizon: { start: '2024-12-27', end: '2025-01-07' },
+ balances: [balance('cp', 'cp', 2, '2025-01-03', '2024-12-30')],
+ rules: [
+ {
+ id: 'exact-date',
+ type: 'time_window',
+ force: 'preference',
+ startDate: '2024-12-30',
+ endDate: '2024-12-30',
+ placeDays: 1,
+ placeType: 'cp',
+ },
+ ],
+ });
+ const outcome = await calculation.outcome;
+ expect(outcome.kind).toBe('Plan');
+ if (outcome.kind !== 'Plan') return;
+ const selected = outcome.placements.find((placement) =>
+ placement.ruleIds.includes('exact-date'),
+ );
+ expect(selected).toBeDefined();
+ if (!selected) return;
+ const before = structuredClone(outcome);
+ const analysis = await calculation.analyzeCounterfactual(selected);
+
+ expect(analysis.kind).toBe('Counterfactual');
+ if (analysis.kind !== 'Counterfactual') return;
+ expect(analysis.results.map(({ date }) => date)).toEqual([
+ '2024-12-27',
+ '2024-12-28',
+ '2024-12-29',
+ '2024-12-30',
+ '2024-12-31',
+ '2025-01-01',
+ '2025-01-02',
+ '2025-01-03',
+ '2025-01-04',
+ '2025-01-05',
+ '2025-01-06',
+ ]);
+ expect(analysis.placement).toEqual(selected);
+ expect(analysis.results.find((item) => item.date === '2024-12-27')?.reason?.code).toBe(
+ 'unavailable',
+ );
+ expect(analysis.results.find((item) => item.date === '2025-01-01')?.reason?.code).toBe(
+ 'non-working',
+ );
+ expect(analysis.results.find((item) => item.date === '2025-01-06')?.reason?.code).toBe(
+ 'expired',
+ );
+ for (const item of analysis.results.filter((result) => result.status === 'ineligible'))
+ expect(item.comparison).toBeUndefined();
+ expect(outcome).toEqual(before);
+ });
+
+ it('keeps typed, joint-date, and earned-order conflicts separate from invalid input', async () => {
+ const scenarios = [
+ {
+ horizon: { start: '2024-04-01', end: '2024-04-06' },
+ balances: [balance('rtt', 'rtt', 1, '2025-01-01')],
+ rules: [
+ {
+ id: 'cp-only',
+ type: 'time_window' as const,
+ force: 'obligation' as const,
+ startDate: '2024-04-02',
+ endDate: '2024-04-02',
+ placeDays: 1,
+ placeType: 'cp',
+ },
+ ],
+ },
+ {
+ horizon: { start: '2024-04-01', end: '2024-04-06' },
+ balances: [
+ balance('cp', 'cp', 1, '2025-01-01'),
+ balance('rtt', 'rtt', 1, '2025-01-01'),
+ ],
+ rules: ['cp', 'rtt'].map((type) => ({
+ id: `${type}-same-day`,
+ type: 'time_window' as const,
+ force: 'obligation' as const,
+ startDate: '2024-04-02',
+ endDate: '2024-04-02',
+ placeDays: 1,
+ placeType: type,
+ })),
+ },
+ {
+ horizon: { start: '2024-04-25', end: '2024-05-15' },
+ balances: [
+ balance('cp', 'cp', 2, '2025-01-01'),
+ balance('rtt', 'rtt', 3, '2026-01-01'),
+ ],
+ rules: [
+ {
+ id: 'producer',
+ type: 'if_then' as const,
+ force: 'obligation' as const,
+ dayOfWeek: 3,
+ placeDays: 1,
+ placeType: 'cp',
+ placement: 'before',
+ rewardDays: 1,
+ rewardType: 'rtt',
+ },
+ {
+ id: 'consumer',
+ type: 'if_then' as const,
+ force: 'obligation' as const,
+ dayOfWeek: 4,
+ placeDays: 4,
+ placeType: 'rtt',
+ placement: 'before',
+ rewardDays: 0,
+ },
+ ],
+ },
+ ];
+ const conflictCodes = [
+ 'typed-capacity',
+ 'joint-date-conflict',
+ 'earned-entitlement-bottleneck',
+ ];
+ for (const [index, scenario] of scenarios.entries()) {
+ const calculation = createPlanningCalculation(scenario);
+ const outcome = await calculation.outcome;
+ expect(outcome.kind).toBe('PlanningConflict');
+ 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 === '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({
+ horizon: { start: '2025-01-01', end: '2024-01-01' },
+ balances: [],
+ rules: [],
+ });
+ 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 () => {
+ const calculation = createPlanningCalculation({
+ horizon: { start: '2024-04-01', end: '2024-04-06' },
+ balances: [
+ balance('cp', 'cp', 1, '2025-01-01'),
+ balance('rtt', 'rtt', 1, '2025-01-01'),
+ ],
+ rules: [
+ ...(['cp', 'rtt'] as const).map((type) => ({
+ id: `${type}-required`,
+ type: 'time_window' as const,
+ force: 'obligation' as const,
+ startDate: '2024-04-02',
+ endDate: '2024-04-02',
+ placeDays: 1,
+ placeType: type,
+ })),
+ {
+ id: 'unrelated-reward-preference',
+ type: 'if_then',
+ force: 'preference',
+ dayOfWeek: 3,
+ placeDays: 1,
+ placeType: 'cp',
+ placement: 'auto',
+ rewardDays: 1,
+ rewardType: 'rtt',
+ },
+ ],
+ });
+ const outcome = await calculation.outcome;
+ expect(outcome.kind).toBe('PlanningConflict');
+ if (outcome.kind === 'PlanningConflict')
+ expect(outcome.reasons[0].code).toBe('joint-date-conflict');
+ });
+
+ 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;
+ const request = {
+ horizon: { start: '2024-01-01', end: '2024-01-10' },
+ balances: [balance('cp', 'cp', 2, '2024-01-31')],
+ rules: [],
+ progress: (stage: string) => {
+ if (stage === 'holiday-week-value') throw new Error('interrupted after checkpoint');
+ },
+ };
+ const outcome = core.calculatePlanningInternal(request, (plan, state) => {
+ checkpointPlan = plan;
+ checkpointState = state;
+ });
+ expect(outcome.kind).toBe('PlanningIncomplete');
+ expect(checkpointPlan).toBeDefined();
+ expect(checkpointState).toBeDefined();
+ if (!checkpointPlan || !checkpointState) return;
+
+ const recovered = core.explainIncompleteCheckpoint(
+ request,
+ checkpointPlan,
+ checkpointState,
+ { code: 'worker-error', message: 'The worker stopped after its checkpoint.' },
+ );
+ expect(recovered.explanation?.kind).toBe('PlanExplanation');
+ if (recovered.explanation?.kind !== 'PlanExplanation') return;
+ 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;
+ const completed = core.calculatePlanningInternal(
+ { ...request, progress: undefined },
+ (plan, state) => {
+ if (plan.completedStages.at(-1) === 'dates') completedState = state;
+ },
+ );
+ expect(completed.kind).toBe('Plan');
+ if (completed.kind !== 'Plan' || !completedState) return;
+ const completedRecovery = core.explainIncompleteCheckpoint(
+ { ...request, progress: undefined },
+ completed,
+ completedState,
+ { code: 'worker-error', message: 'The result arrived before the worker stopped.' },
+ );
+ expect(completedRecovery.explanation?.kind).toBe('PlanExplanation');
+ if (completedRecovery.explanation?.kind === 'PlanExplanation')
+ 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: [],
+ deadlineMs: 1_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);
+ 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();
+ 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: [],
+ };
+ 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,
+ })),
+ );
+ const candidateOperations = operations.filter(
+ (operation): operation is Extract =>
+ 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 () => {
+ const early = createPlanningCalculation({
+ horizon: { start: '2024-01-01', end: '2024-01-10' },
+ balances: [balance('cp', 'cp', 1, '2024-01-31')],
+ rules: [],
+ 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 === 'at-risk')?.status).toBe(
+ 'not-run',
+ );
+
+ const quality = createPlanningCalculation({
+ horizon: { start: '2024-01-01', end: '2024-02-01' },
+ balances: [balance('cp', 'cp', 1, '2024-01-31')],
+ rules: [],
+ progress: (stage) => {
+ if (stage === 'holiday-week-value') {
+ throw new Error('deliberate interruption in quality phase');
+ }
+ },
+ });
+ 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');
+ expect(qualityOutcome.completedStages).toEqual(['validation', 'obligations', 'at-risk']);
+ expect(qualityTrace?.completedStages).toEqual(qualityOutcome.completedStages);
+ expect(
+ qualityTrace?.phases.find((phase) => phase.name === 'holiday-week-value')?.status,
+ ).toBe('interrupted');
+ expect(
+ qualityTrace?.phases.find((phase) => phase.name === 'non-working-day-gain')?.status,
+ ).toBe('not-run');
+ });
+});
diff --git a/src/app/services/leave-planner-quality-trace.spec.ts b/src/app/services/leave-planner-quality-trace.spec.ts
new file mode 100644
index 0000000..820408a
--- /dev/null
+++ b/src/app/services/leave-planner-quality-trace.spec.ts
@@ -0,0 +1,87 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { createPlanningCalculation } from './leave-planner.service';
+
+beforeEach(() => vi.stubGlobal('window', undefined));
+afterEach(() => vi.unstubAllGlobals());
+
+describe('quality calculation trace', () => {
+ it('separates source effects from the actual incumbent comparison', async () => {
+ const calculation = createPlanningCalculation({
+ horizon: { start: '2024-01-01', end: '2024-02-01' },
+ balances: [
+ {
+ id: 'cp',
+ type: 'cp',
+ quantity: 2,
+ availableFrom: '2024-01-01',
+ expiryDate: '2024-01-31',
+ },
+ ],
+ rules: [],
+ });
+ const outcome = await calculation.outcome;
+ const trace = await calculation.trace;
+ expect(outcome.kind).toBe('Plan');
+ expect(trace?.status).toBe('ready');
+ if (outcome.kind !== 'Plan' || !trace) return;
+
+ const phase = trace.phases.find((item) => item.name === 'non-working-day-gain');
+ const jan3Decision = phase?.decisions.find((decision) =>
+ decision.candidates.some((candidate) => candidate.sourceDate === '2024-01-03'),
+ );
+ const jan2Decision = phase?.decisions.find((decision) =>
+ decision.candidates.some((candidate) => candidate.sourceDate === '2024-01-02'),
+ );
+ const jan5 = jan3Decision?.candidates.find(
+ (candidate) => candidate.sourceDate === '2024-01-03' && candidate.date === '2024-01-05',
+ );
+ const jan8 = jan3Decision?.candidates.find(
+ (candidate) => candidate.sourceDate === '2024-01-03' && candidate.date === '2024-01-08',
+ );
+
+ expect(jan2Decision).toBeDefined();
+ expect(jan3Decision).toBeDefined();
+ expect(jan3Decision).not.toBe(jan2Decision);
+ expect(jan3Decision?.dates).toEqual(['2024-01-03', '2024-01-05']);
+ expect(jan5?.status).toBe('chosen');
+ expect(jan8?.status).toBe('evaluated-rejected');
+ expect(jan3Decision?.deltas).toContainEqual(
+ expect.objectContaining({
+ kind: 'placement-moved',
+ fromDate: '2024-01-03',
+ toDate: '2024-01-05',
+ }),
+ );
+
+ const sourceValue = (
+ candidate: typeof jan5,
+ state: 'before' | 'after',
+ objective: string,
+ ) => candidate?.comparison?.[state].objectives.find((item) => item.id === objective)?.value;
+ expect(sourceValue(jan5, 'before', 'non-working-day-gain')).toBe(3);
+ expect(sourceValue(jan5, 'after', 'non-working-day-gain')).toBe(5);
+ expect(sourceValue(jan5, 'before', 'longest-work-sequence')).toBe(20);
+ expect(sourceValue(jan5, 'after', 'longest-work-sequence')).toBe(18);
+ expect(sourceValue(jan8, 'before', 'non-working-day-gain')).toBe(3);
+ expect(sourceValue(jan8, 'after', 'non-working-day-gain')).toBe(5);
+ expect(sourceValue(jan8, 'before', 'longest-work-sequence')).toBe(20);
+ expect(sourceValue(jan8, 'after', 'longest-work-sequence')).toBe(17);
+ expect(jan8?.comparison?.firstDecisiveObjective).toBe('non-working-day-gain');
+
+ expect(jan8?.technicalComparison).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ name: 'non-working-day-gain',
+ direction: 'maximize',
+ before: 5,
+ after: 5,
+ }),
+ ]),
+ );
+ expect(
+ jan8?.technicalComparison?.some((item) => item.name === 'longest-work-sequence'),
+ ).toBe(false);
+ expect(jan8?.firstDecisiveComparator).toBeUndefined();
+ expect(jan8?.reason?.message).toContain('did not improve the current best result');
+ });
+});
diff --git a/src/app/services/leave-planner.core.ts b/src/app/services/leave-planner.core.ts
index 548cab2..44822ab 100644
--- a/src/app/services/leave-planner.core.ts
+++ b/src/app/services/leave-planner.core.ts
@@ -121,6 +121,7 @@ export interface PreferenceResult {
}
export type PlanningObjectiveId =
+ | 'obligation-validity'
| 'unplaced-at-risk'
| `preference:${string}`
| 'holiday-week-value'
@@ -274,6 +275,14 @@ export interface PlanningTraceCandidate {
dates?: readonly string[];
reason?: PlanningReason;
comparison?: PlanningObjectiveComparison;
+ /** Raw non-objective comparator values used by search branches. */
+ technicalComparison?: readonly Readonly<{
+ name: string;
+ direction: 'minimize' | 'maximize';
+ before: number | string;
+ after: number | string;
+ }>[];
+ firstDecisiveComparator?: string;
placement?: LeavePlacement;
}
@@ -283,6 +292,11 @@ export interface PlanningTraceDecision {
dates: readonly string[];
candidates: readonly PlanningTraceCandidate[];
deltas: readonly PlanningTraceDelta[];
+ /** Local transition for an explored alternative branch, separate from committed history. */
+ branch?: Readonly<{
+ parentDecisionId?: string;
+ deltas: readonly PlanningTraceDelta[];
+ }>;
reason?: PlanningReason;
}
@@ -299,9 +313,52 @@ export interface PlanningTrace {
initialState: PlanningTraceState;
phases: readonly PlanningTracePhase[];
completedStages: readonly string[];
+ nonWorkingDates?: readonly Readonly<{ date: string; reason: PlanningReason }>[];
error?: PlanningReason;
}
+export type PlanningTraceOperation =
+ | {
+ kind: 'phase';
+ phase: Pick;
+ }
+ | {
+ kind: 'decision';
+ phaseId: string;
+ decision: Pick;
+ }
+ | {
+ kind: 'candidate';
+ phaseId: string;
+ decisionId: string;
+ index: number;
+ mode: 'append' | 'replace';
+ candidate: PlanningTraceCandidate;
+ }
+ | {
+ kind: 'delta';
+ phaseId: string;
+ decisionId: string;
+ index: number;
+ delta: PlanningTraceDelta;
+ };
+
+/** Ordered incremental trace updates; decision and candidate indices are stable within a phase. */
+export interface PlanningTraceUpdate {
+ initialState?: PlanningTraceState;
+ nonWorkingDates?: readonly Readonly<{ date: string; reason: PlanningReason }>[];
+ operations: readonly PlanningTraceOperation[];
+}
+
+export type PlanningTraceProgressCallback = (totalMs: number, update: PlanningTraceUpdate) => void;
+
+export interface PlanningArtifactTimings {
+ planningWorkMs: number;
+ traceCaptureMs: number;
+ traceMaterializationMs: number;
+ traceTransferMs: number;
+}
+
export type CounterfactualReasonCode =
| 'non-working'
| 'occupied'
@@ -314,10 +371,16 @@ export interface CounterfactualDateResult {
date: string;
status: 'eligible' | 'ineligible';
historicalStatus?: TraceCandidateStatus;
+ historicalReason?: PlanningReason & { code: CounterfactualReasonCode };
reason?: PlanningReason & { code: CounterfactualReasonCode };
comparison?: PlanningObjectiveComparison;
}
+export interface HistoricalCounterfactualReason {
+ date: string;
+ reason: PlanningReason & { code: CounterfactualReasonCode };
+}
+
export interface CounterfactualAnalysis {
kind: 'Counterfactual';
calculationId: string;
@@ -486,6 +549,8 @@ interface Requirement {
candidates?: string[];
}
+const obligationRequirementCache = new WeakMap();
+
type MutableTraceCandidate = PlanningTraceCandidate & { candidates?: never };
type MutableTraceDecision = {
id: string;
@@ -493,6 +558,7 @@ type MutableTraceDecision = {
dates: string[];
candidates: MutableTraceCandidate[];
deltas: PlanningTraceDelta[];
+ branch?: { parentDecisionId?: string; deltas: PlanningTraceDelta[] };
reason?: PlanningReason;
};
type MutableTracePhase = {
@@ -510,23 +576,37 @@ type MutableTracePhase = {
*/
class TraceRecorder {
private readonly phases: MutableTracePhase[] = [];
+ private readonly nonWorkingDates: readonly Readonly<{ date: string; reason: PlanningReason }>[];
private phase: MutableTracePhase | undefined;
private sequence = 0;
- private readonly evaluationDecisions = new WeakMap<
+ private readonly evaluationLocations = new WeakMap<
MutableTraceCandidate,
- MutableTraceDecision
+ { phase: MutableTracePhase; decision: MutableTraceDecision; index: number }
>();
+ private pendingOperations: PlanningTraceOperation[] = [];
+ private readonly pendingCandidateOperations = new Map<
+ PlanningTraceCandidate,
+ Extract
+ >();
+ private initialStateSent = false;
private previousPlacements = new Map();
private previousEarned = new Set();
- private previousBalances: ReadonlyMap;
+ private previousBalances: ReadonlyMap;
constructor(
private readonly initialState: PlanningTraceState,
private readonly plannedPhases: readonly string[],
private readonly workingDays: ReadonlySet,
+ private readonly streaming: boolean,
) {
+ this.nonWorkingDates = dateRange(initialState.horizon.start, initialState.horizon.end)
+ .filter((date) => !workingDays.has(date))
+ .map((date) => ({
+ date,
+ reason: reason('non-working', 'The date is a weekend or public holiday.'),
+ }));
this.previousBalances = new Map(
- initialState.balances.map((balance) => [balance.id, balance.remaining]),
+ initialState.balances.map((balance) => [balance.id, { ...balance }]),
);
}
@@ -535,6 +615,7 @@ class TraceRecorder {
if (this.phase) {
this.phase.completed = true;
this.phase.status = 'completed';
+ this.recordPhase(this.phase);
}
this.phase = {
id: `phase:${this.phases.length + 1}`,
@@ -544,6 +625,121 @@ class TraceRecorder {
decisions: [],
};
this.phases.push(this.phase);
+ this.recordPhase(this.phase);
+ }
+
+ private recordPhase(phase: MutableTracePhase): void {
+ if (!this.streaming) return;
+ this.pendingOperations.push({
+ kind: 'phase',
+ phase: {
+ id: phase.id,
+ name: phase.name,
+ completed: phase.completed,
+ status: phase.status,
+ },
+ });
+ }
+
+ private recordDecisionUpdate(phase: MutableTracePhase, decision: MutableTraceDecision): void {
+ if (!this.streaming) return;
+ this.pendingOperations.push({
+ kind: 'decision',
+ phaseId: phase.id,
+ decision: {
+ id: decision.id,
+ action: decision.action,
+ dates: [...decision.dates],
+ reason: decision.reason ? { ...decision.reason } : undefined,
+ branch: decision.branch
+ ? {
+ parentDecisionId: decision.branch.parentDecisionId,
+ deltas: decision.branch.deltas.map((delta) => ({ ...delta })),
+ }
+ : undefined,
+ },
+ });
+ }
+
+ private recordCandidateUpdate(
+ phase: MutableTracePhase,
+ decision: MutableTraceDecision,
+ index: number,
+ mode: 'append' | 'replace',
+ candidate: PlanningTraceCandidate,
+ ): void {
+ if (!this.streaming) return;
+ const snapshot = {
+ ...candidate,
+ dates: candidate.dates ? [...candidate.dates] : undefined,
+ reason: candidate.reason ? { ...candidate.reason } : undefined,
+ technicalComparison: candidate.technicalComparison?.map((item) => ({ ...item })),
+ placement: candidate.placement
+ ? { ...candidate.placement, ruleIds: [...candidate.placement.ruleIds] }
+ : undefined,
+ };
+ const pending = this.pendingCandidateOperations.get(candidate);
+ if (pending) {
+ pending.candidate = snapshot;
+ return;
+ }
+ const operation: Extract = {
+ kind: 'candidate',
+ phaseId: phase.id,
+ decisionId: decision.id,
+ index,
+ mode,
+ candidate: snapshot,
+ };
+ this.pendingOperations.push(operation);
+ this.pendingCandidateOperations.set(candidate, operation);
+ }
+
+ private recordDelta(decision: MutableTraceDecision, delta: PlanningTraceDelta): void {
+ const index = decision.deltas.push(delta) - 1;
+ if (!this.streaming) return;
+ this.pendingOperations.push({
+ kind: 'delta',
+ phaseId: this.phase!.id,
+ decisionId: decision.id,
+ index,
+ delta: { ...delta },
+ });
+ }
+
+ private recordNewCandidate(
+ decision: MutableTraceDecision,
+ candidate: MutableTraceCandidate,
+ ): number {
+ const index = decision.candidates.push(candidate) - 1;
+ this.recordCandidateUpdate(this.phase!, decision, index, 'append', candidate);
+ return index;
+ }
+
+ drainUpdate(): PlanningTraceUpdate | undefined {
+ if (!this.pendingOperations.length && this.initialStateSent) return undefined;
+ const initialState = this.initialStateSent
+ ? undefined
+ : {
+ horizon: { ...this.initialState.horizon },
+ balances: this.initialState.balances.map((balance) => ({ ...balance })),
+ placements: this.initialState.placements.map((placement) => ({
+ ...placement,
+ ruleIds: [...placement.ruleIds],
+ })),
+ earnedActivations: this.initialState.earnedActivations.map((activation) => ({
+ ...activation,
+ dates: [...activation.dates],
+ })),
+ };
+ this.initialStateSent = true;
+ const operations = this.pendingOperations.splice(0);
+ this.pendingCandidateOperations.clear();
+ return {
+ initialState,
+ nonWorkingDates: initialState ? this.nonWorkingDates : undefined,
+ operations,
+ };
}
private decision(
@@ -558,8 +754,10 @@ class TraceRecorder {
dates: [...dates],
candidates: [],
deltas: [],
+ branch: undefined,
};
current.decisions.push(decision);
+ this.recordDecisionUpdate(current, decision);
return decision;
}
@@ -573,7 +771,7 @@ class TraceRecorder {
private candidateDecision(dates: readonly string[] = []): MutableTraceDecision {
const latest = this.latestDecision();
- return latest.deltas.length ? this.decision('choose', dates) : latest;
+ return latest.deltas.length || latest.branch ? this.decision('choose', dates) : latest;
}
private recordChosenCandidate(placement: LeavePlacement, decision: MutableTraceDecision): void {
@@ -584,46 +782,101 @@ class TraceRecorder {
candidate.placement.balanceId === placement.balanceId,
);
if (!alreadyRecorded)
- decision.candidates.push({ status: 'chosen', date: placement.date, placement });
+ this.recordNewCandidate(decision, {
+ status: 'chosen',
+ date: placement.date,
+ placement,
+ });
}
- recordCandidate(candidate: PlanningTraceCandidate, copy = true): void {
+ recordCandidate(candidate: PlanningTraceCandidate, copy = true): MutableTraceCandidate {
const decision = this.candidateDecision(
candidate.dates ?? (candidate.date ? [candidate.date] : []),
);
- decision.candidates.push(
- copy
- ? {
- ...candidate,
- dates: candidate.dates ? [...candidate.dates] : undefined,
- reason: candidate.reason ? { ...candidate.reason } : undefined,
- placement: candidate.placement
- ? { ...candidate.placement, ruleIds: [...candidate.placement.ruleIds] }
- : undefined,
- }
- : candidate,
- );
+ const mutable = copy
+ ? {
+ ...candidate,
+ dates: candidate.dates ? [...candidate.dates] : undefined,
+ reason: candidate.reason ? { ...candidate.reason } : undefined,
+ technicalComparison: candidate.technicalComparison?.map((item) => ({
+ ...item,
+ })),
+ placement: candidate.placement
+ ? { ...candidate.placement, ruleIds: [...candidate.placement.ruleIds] }
+ : undefined,
+ }
+ : (candidate as MutableTraceCandidate);
+ const index = this.recordNewCandidate(decision, mutable);
+ this.evaluationLocations.set(mutable, { phase: this.phase!, decision, index });
+ return mutable;
}
recordDecision(
action: MutableTraceDecision['action'],
dates: readonly string[],
candidate?: PlanningTraceCandidate,
- ): void {
+ branch?: PlanningTraceDecision['branch'],
+ ): MutableTraceCandidate | undefined {
const decision = this.decision(action, dates);
- if (candidate) decision.candidates.push(candidate);
+ if (branch) {
+ decision.branch = {
+ parentDecisionId: branch.parentDecisionId,
+ deltas: branch.deltas.map((delta) => ({ ...delta })),
+ };
+ this.recordDecisionUpdate(this.phase!, decision);
+ }
+ if (!candidate) return undefined;
+ const mutable = candidate as MutableTraceCandidate;
+ const index = this.recordNewCandidate(decision, mutable);
+ this.evaluationLocations.set(mutable, { phase: this.phase!, decision, index });
+ return mutable;
+ }
+
+ recordBranchTransition(
+ dates: readonly string[],
+ parentDecisionId: string | undefined,
+ deltas: readonly PlanningTraceDelta[],
+ candidate?: MutableTraceCandidate,
+ ): string {
+ const decision = candidate
+ ? (this.evaluationLocations.get(candidate)?.decision ?? this.decision('choose', dates))
+ : this.decision('choose', dates);
+ decision.branch = {
+ parentDecisionId,
+ deltas: deltas.map((delta) => ({ ...delta })),
+ };
+ this.recordDecisionUpdate(this.phase!, decision);
+ if (candidate) this.chooseEvaluation(candidate);
+ return decision.id;
+ }
+
+ decisionIdFor(candidate: MutableTraceCandidate): string | undefined {
+ return this.evaluationLocations.get(candidate)?.decision.id;
}
recordEvaluation(candidate: MutableTraceCandidate): void {
const decision = this.candidateDecision(
candidate.dates ?? (candidate.date ? [candidate.date] : []),
);
- decision.candidates.push(candidate);
- this.evaluationDecisions.set(candidate, decision);
+ const index = this.recordNewCandidate(decision, candidate);
+ this.evaluationLocations.set(candidate, { phase: this.phase!, decision, index });
+ }
+
+ updateEvaluation(candidate: MutableTraceCandidate): void {
+ const location = this.evaluationLocations.get(candidate);
+ if (location)
+ this.recordCandidateUpdate(
+ location.phase,
+ location.decision,
+ location.index,
+ 'replace',
+ candidate,
+ );
}
chooseEvaluation(candidate: MutableTraceCandidate): void {
candidate.status = 'chosen';
+ this.updateEvaluation(candidate);
}
recordPlan(
@@ -643,9 +896,19 @@ class TraceRecorder {
const previous = this.previousPlacements.get(date);
return previous && JSON.stringify(previous) !== JSON.stringify(placement);
});
- const balancesChanged = balances.some(
- (balance) => this.previousBalances.get(balance.id) !== balance.remaining,
- );
+ const balancesChanged = balances.some((balance) => {
+ const previous = this.previousBalances.get(balance.id);
+ return (
+ !previous ||
+ previous.remaining !== balance.remaining ||
+ previous.availableFrom !== balance.availableFrom ||
+ previous.expiryDate !== balance.expiryDate ||
+ previous.quantity !== balance.quantity ||
+ previous.type !== balance.type ||
+ previous.earned !== balance.earned ||
+ previous.sourceRuleId !== balance.sourceRuleId
+ );
+ });
const earnedChanged = balances.some(
(balance) => balance.earned && !this.previousEarned.has(balance.id),
);
@@ -673,10 +936,11 @@ class TraceRecorder {
...updated.map(([date]) => date),
];
const decision =
- (acceptedCandidate && this.evaluationDecisions.get(acceptedCandidate)) ||
+ (acceptedCandidate && this.evaluationLocations.get(acceptedCandidate)?.decision) ||
this.decision(action, dates);
decision.action = action;
decision.dates = dates;
+ this.recordDecisionUpdate(this.phase!, decision);
const movedAdded = new Set();
const movedRemoved = new Set();
for (const previous of removed) {
@@ -689,7 +953,7 @@ class TraceRecorder {
if (!placement) continue;
movedAdded.add(placement.date);
movedRemoved.add(previous.date);
- decision.deltas.push({
+ this.recordDelta(decision, {
kind: 'placement-moved',
fromDate: previous.date,
toDate: placement.date,
@@ -701,7 +965,7 @@ class TraceRecorder {
}
for (const placement of removed) {
if (movedRemoved.has(placement.date)) continue;
- decision.deltas.push({
+ this.recordDelta(decision, {
kind: 'placement-removed',
date: placement.date,
balanceId: placement.balanceId,
@@ -710,7 +974,7 @@ class TraceRecorder {
}
for (const placement of added) {
if (movedAdded.has(placement.date)) continue;
- decision.deltas.push({
+ this.recordDelta(decision, {
kind: 'placement-added',
date: placement.date,
balanceId: placement.balanceId,
@@ -720,7 +984,7 @@ class TraceRecorder {
}
for (const [date, placement] of updated) {
const previous = this.previousPlacements.get(date)!;
- decision.deltas.push({
+ this.recordDelta(decision, {
kind: 'placement-updated',
date,
balanceId: placement.balanceId,
@@ -730,18 +994,21 @@ class TraceRecorder {
this.recordChosenCandidate(placement, decision);
}
for (const balance of balances) {
- const previousRemaining = this.previousBalances.get(balance.id);
- if (previousRemaining !== undefined && previousRemaining !== balance.remaining)
- decision.deltas.push({
- kind: 'balance-changed',
- balanceId: balance.id,
- remainingBefore: previousRemaining,
- remainingAfter: balance.remaining,
- });
- if (previousRemaining === undefined)
- decision.deltas.push({
+ const previous = this.previousBalances.get(balance.id);
+ if (
+ !previous ||
+ previous.remaining !== balance.remaining ||
+ previous.availableFrom !== balance.availableFrom ||
+ previous.expiryDate !== balance.expiryDate ||
+ previous.quantity !== balance.quantity ||
+ previous.type !== balance.type ||
+ previous.earned !== balance.earned ||
+ previous.sourceRuleId !== balance.sourceRuleId
+ )
+ this.recordDelta(decision, {
kind: 'balance-changed',
balanceId: balance.id,
+ remainingBefore: previous?.remaining,
remainingAfter: balance.remaining,
balance: { ...balance },
});
@@ -749,7 +1016,7 @@ class TraceRecorder {
const activation = earnedActivations.find(
(candidate) => candidate.id === balance.id,
);
- decision.deltas.push({
+ this.recordDelta(decision, {
kind: 'earned-activated',
balanceId: balance.id,
activation: activation
@@ -759,7 +1026,7 @@ class TraceRecorder {
}
}
this.previousPlacements = current;
- this.previousBalances = new Map(balances.map((balance) => [balance.id, balance.remaining]));
+ this.previousBalances = new Map(balances.map((balance) => [balance.id, { ...balance }]));
this.previousEarned = new Set(
balances.filter((balance) => balance.earned).map((balance) => balance.id),
);
@@ -770,56 +1037,30 @@ class TraceRecorder {
completedStages: readonly string[],
error?: PlanningReason,
): PlanningTrace {
- const seenDates = new Set(
- this.phases.flatMap((phase) =>
- phase.decisions.flatMap((decision) =>
- decision.candidates.flatMap(
- (candidate) => candidate.dates ?? (candidate.date ? [candidate.date] : []),
- ),
- ),
- ),
- );
- if (status !== 'failed') {
- const decision = this.decision('reject');
- for (const date of dateRange(
- this.initialState.horizon.start,
- this.initialState.horizon.end,
- )) {
- if (seenDates.has(date)) continue;
- decision.candidates.push(
- this.workingDays.has(date)
- ? { status: 'not-examined', date }
- : {
- status: 'ineligible',
- date,
- reason: reason(
- 'non-working',
- 'The date is a weekend or public holiday.',
- ),
- },
- );
- }
- }
if (this.phase && status === 'ready') {
this.phase.completed = true;
this.phase.status = 'completed';
+ this.recordPhase(this.phase);
}
if (status === 'incomplete') {
const existing = new Set(this.phases.map((phase) => phase.name));
for (const name of this.plannedPhases) {
if (existing.has(name)) continue;
- this.phases.push({
+ const phase = {
id: `phase:${this.phases.length + 1}`,
name,
completed: false,
status: 'not-run',
decisions: [],
- });
+ } satisfies MutableTracePhase;
+ this.phases.push(phase);
+ this.recordPhase(phase);
}
}
return {
status,
initialState: this.initialState,
+ nonWorkingDates: this.nonWorkingDates,
phases: this.phases.map((phase) => ({
...phase,
decisions: phase.decisions.map((decision) => ({
@@ -860,10 +1101,30 @@ interface SearchContext {
anyPreferenceDemandByDate: Map;
trace?: TraceRecorder;
traceTime: number;
+ traceCaptureTime: number;
+ traceTransferTime: number;
traceReportedTime: number;
+ observerTime: number;
+ obligationSolutionBranches: WeakMap;
+ traceBranchParentId?: string;
+ traceBranchActive?: boolean;
traceError?: PlanningReason;
}
+interface ObligationBranchReference {
+ candidate: MutableTraceCandidate;
+ decisionId: string;
+ stateAfter: State;
+}
+
+interface ObligationPreview {
+ obligationState: State;
+ state: State;
+ preferences: PreferenceResult[];
+ metrics: PlanningMetrics;
+ previewBranch?: ObligationBranchReference;
+}
+
function now(): number {
return typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();
}
@@ -880,29 +1141,64 @@ function observeTrace(context: SearchContext, observe: (trace: TraceRecorder) =>
);
context.trace = undefined;
} finally {
- context.traceTime += now() - started;
+ const elapsed = now() - started;
+ context.traceTime += elapsed;
+ context.traceCaptureTime += elapsed;
if (
context.trace &&
- context.traceTime - context.traceReportedTime >= 25 &&
+ context.traceCaptureTime - context.traceReportedTime >= 25 &&
typeof context.request.traceProgress === 'function'
) {
- try {
- context.request.traceProgress(context.traceTime);
- } catch (error) {
- context.traceError = reason(
- 'trace-error',
- error instanceof Error ? error.message : String(error),
- );
- context.trace = undefined;
+ const updateStarted = now();
+ const update = context.trace.drainUpdate();
+ const updateTime = now() - updateStarted;
+ context.traceTime += updateTime;
+ context.traceCaptureTime += updateTime;
+ if (update) {
+ const transferStarted = now();
+ try {
+ (context.request.traceProgress as PlanningTraceProgressCallback)(
+ context.traceTime,
+ update,
+ );
+ } catch (error) {
+ context.traceError = reason(
+ 'trace-error',
+ error instanceof Error ? error.message : String(error),
+ );
+ context.trace = undefined;
+ } finally {
+ const transferTime = now() - transferStarted;
+ context.traceTime += transferTime;
+ context.traceTransferTime += transferTime;
+ context.traceReportedTime = context.traceCaptureTime;
+ }
+ } else {
+ context.traceReportedTime = context.traceCaptureTime;
}
- context.traceReportedTime = context.traceTime;
}
}
}
-function recordTraceCandidate(context: SearchContext, candidate: PlanningTraceCandidate): void {
+function observeCallback(context: SearchContext, callback: () => void): void {
+ const started = now();
+ try {
+ callback();
+ } finally {
+ context.observerTime += now() - started;
+ }
+}
+
+function recordTraceCandidate(
+ context: SearchContext,
+ candidate: PlanningTraceCandidate,
+): MutableTraceCandidate | undefined {
// These internal callsites pass fresh literals; retain the full snapshot copy for plan data.
- observeTrace(context, (trace) => trace.recordCandidate(candidate, false));
+ let recorded: MutableTraceCandidate | undefined;
+ observeTrace(context, (trace) => {
+ recorded = trace.recordCandidate(candidate, false);
+ });
+ return recorded;
}
function recordTraceIneligible(
@@ -1123,6 +1419,202 @@ function planningTraceState(
};
}
+function planningTraceDeltas(
+ before: State,
+ after: State,
+ dates: readonly string[],
+): PlanningTraceDelta[] {
+ const deltas: PlanningTraceDelta[] = [];
+ const changedDates = new Set(dates);
+ for (const date of changedDates) {
+ const previous = before.placements.get(date);
+ const placement = after.placements.get(date);
+ if (!previous && placement) {
+ deltas.push({
+ kind: 'placement-added',
+ date,
+ balanceId: placement.balanceId,
+ placement: { ...placement, ruleIds: [...placement.ruleIds].sort() },
+ });
+ } else if (previous && !placement) {
+ deltas.push({
+ kind: 'placement-removed',
+ date,
+ balanceId: previous.balanceId,
+ previousPlacement: {
+ ...previous,
+ ruleIds: [...previous.ruleIds].sort(),
+ },
+ });
+ } else if (
+ previous &&
+ placement &&
+ (JSON.stringify([...previous.ruleIds].sort()) !==
+ JSON.stringify([...placement.ruleIds].sort()) ||
+ previous.balanceId !== placement.balanceId ||
+ previous.type !== placement.type ||
+ previous.supporting !== placement.supporting)
+ ) {
+ deltas.push({
+ kind: 'placement-updated',
+ date,
+ balanceId: placement.balanceId,
+ previousPlacement: {
+ ...previous,
+ ruleIds: [...previous.ruleIds].sort(),
+ },
+ placement: { ...placement, ruleIds: [...placement.ruleIds].sort() },
+ });
+ }
+ }
+ const previousBalances = new Map(before.balances.map((balance) => [balance.id, balance]));
+ for (const balance of after.balances) {
+ const previous = previousBalances.get(balance.id);
+ if (
+ !previous ||
+ previous.remaining !== balance.remaining ||
+ previous.availableFrom !== balance.availableFrom ||
+ previous.expiryDate !== balance.expiryDate ||
+ previous.quantity !== balance.quantity ||
+ previous.type !== balance.type ||
+ previous.earned !== balance.earned ||
+ previous.sourceRuleId !== balance.sourceRuleId
+ ) {
+ deltas.push({
+ kind: 'balance-changed',
+ balanceId: balance.id,
+ remainingBefore: previous?.remaining,
+ remainingAfter: balance.remaining,
+ balance: { ...balance },
+ });
+ }
+ }
+ const previousActivations = new Set(
+ before.earnedActivations.map((activation) => activation.id),
+ );
+ for (const activation of after.earnedActivations) {
+ if (previousActivations.has(activation.id)) continue;
+ deltas.push({
+ kind: 'earned-activated',
+ balanceId: activation.id,
+ activation: { ...activation, dates: [...activation.dates] },
+ });
+ }
+ return deltas;
+}
+
+function recordTraceBranchDecision(
+ context: SearchContext,
+ before: State,
+ after: State,
+ parentDecisionId: string | undefined,
+ dates: readonly string[] | (() => readonly string[]),
+ technicalComparison?: () => {
+ values: NonNullable;
+ firstDecisiveComparator?: string;
+ },
+): ObligationBranchReference | undefined {
+ let reference: ObligationBranchReference | undefined;
+ observeTrace(context, (trace) => {
+ const branchDates = typeof dates === 'function' ? dates() : dates;
+ const comparison = technicalComparison?.();
+ const candidate: MutableTraceCandidate = {
+ status: 'evaluated-rejected',
+ dates: [...branchDates],
+ technicalComparison: comparison?.values,
+ firstDecisiveComparator: comparison?.firstDecisiveComparator,
+ };
+ const recorded = trace.recordDecision('choose', branchDates, candidate, {
+ parentDecisionId,
+ deltas: planningTraceDeltas(before, after, branchDates),
+ });
+ const decisionId = recorded && trace.decisionIdFor(recorded);
+ if (recorded && decisionId)
+ reference = { candidate: recorded, decisionId, stateAfter: after };
+ });
+ return reference;
+}
+
+function recordTraceIfThenEvaluation(
+ context: SearchContext,
+ dates: readonly string[] | (() => readonly string[]),
+ technicalComparison: () => {
+ values: NonNullable;
+ firstDecisiveComparator?: string;
+ },
+): MutableTraceCandidate | undefined {
+ let evaluation: MutableTraceCandidate | undefined;
+ observeTrace(context, (trace) => {
+ const candidateDates = typeof dates === 'function' ? dates() : dates;
+ const comparison = technicalComparison();
+ evaluation = trace.recordCandidate(
+ {
+ status: 'evaluated-rejected',
+ dates: [...candidateDates],
+ technicalComparison: comparison.values,
+ firstDecisiveComparator: comparison.firstDecisiveComparator,
+ },
+ false,
+ );
+ });
+ return evaluation;
+}
+
+function recordTraceBranchMutation(
+ context: SearchContext,
+ before: State,
+ after: State,
+ parentDecisionId: string | undefined,
+ candidate: MutableTraceCandidate | undefined,
+ dates: readonly string[],
+): string | undefined {
+ let decisionId: string | undefined;
+ observeTrace(context, (trace) => {
+ decisionId = trace.recordBranchTransition(
+ dates,
+ parentDecisionId,
+ planningTraceDeltas(before, after, dates),
+ candidate,
+ );
+ });
+ return decisionId;
+}
+
+function recordTraceAtRiskBranchMutation(
+ context: SearchContext,
+ after: State,
+ parentDecisionId: string | undefined,
+ candidate: MutableTraceCandidate | undefined,
+ date: string,
+ balanceId: string,
+ remainingBefore: number,
+): string | undefined {
+ let decisionId: string | undefined;
+ observeTrace(context, (trace) => {
+ const placement = after.placements.get(date);
+ const balance = after.balances.find((item) => item.id === balanceId);
+ if (!placement || !balance) return;
+ const deltas: PlanningTraceDelta[] = [
+ {
+ kind: 'placement-added',
+ date,
+ balanceId,
+ placement: { ...placement, ruleIds: [...placement.ruleIds].sort() },
+ },
+ ];
+ if (remainingBefore !== balance.remaining)
+ deltas.push({
+ kind: 'balance-changed',
+ balanceId,
+ remainingBefore,
+ remainingAfter: balance.remaining,
+ balance: { ...balance },
+ });
+ decisionId = trace.recordBranchTransition([date], parentDecisionId, deltas, candidate);
+ });
+ return decisionId;
+}
+
function recordTraceState(
context: SearchContext,
state: State,
@@ -1558,6 +2050,10 @@ function buildRequirements(
context: SearchContext,
force: RuleForce,
): Requirement[] {
+ if (force === 'obligation' && rules === context.rules) {
+ const cached = obligationRequirementCache.get(context);
+ if (cached) return cached;
+ }
const requirements: Requirement[] = [];
let index = 0;
for (const rule of rules) {
@@ -1610,12 +2106,15 @@ function buildRequirements(
// TIME_WINDOW consumer even when the window starts earlier; availability
// checks still prevent an earned balance from funding an earlier date, while
// this ordering keeps valid later chains visible to the joint search.
- return requirements.sort(
+ const ordered = requirements.sort(
(a, b) =>
Number(a.rule.type !== 'if_then') - Number(b.rule.type !== 'if_then') ||
compareDate(a.anchor, b.anchor) ||
a.index - b.index,
);
+ if (force === 'obligation' && rules === context.rules)
+ obligationRequirementCache.set(context, ordered);
+ return ordered;
}
function obligationBranchEstimate(requirements: Requirement[]): number {
@@ -1653,8 +2152,12 @@ function assignDate(
selector: LeaveSelector,
ruleId: string,
context: SearchContext,
+ selectedEvaluation?: (candidate: MutableTraceCandidate | undefined) => void,
): State | null {
- return assignDateVariants(state, date, selector, ruleId, context, true)[0] ?? null;
+ return (
+ assignDateVariants(state, date, selector, ruleId, context, true, selectedEvaluation)[0] ??
+ null
+ );
}
function assignDateVariants(
@@ -1664,6 +2167,7 @@ function assignDateVariants(
ruleId: string,
context: SearchContext,
firstOnly = false,
+ selectedEvaluation?: (candidate: MutableTraceCandidate | undefined) => void,
): State[] {
context.check();
const existing = state.placements.get(date);
@@ -1697,7 +2201,7 @@ function assignDateVariants(
}
const result = cloneState(state);
if (ruleId) result.placements.get(date)!.ruleIds.add(ruleId);
- recordTraceCandidate(context, {
+ const evaluation = recordTraceCandidate(context, {
status: 'evaluated-rejected',
date,
placement: {
@@ -1705,6 +2209,7 @@ function assignDateVariants(
ruleIds: [...result.placements.get(date)!.ruleIds],
},
});
+ selectedEvaluation?.(evaluation);
return [result];
}
const balances = state.balances
@@ -1748,6 +2253,13 @@ function assignDateVariants(
});
if (firstOnly && balances.length) {
const first = balances[0];
+ const selectionFacts = firstCompatibleBalanceSelectionFacts(
+ selector,
+ date,
+ first,
+ balances[1],
+ context,
+ );
const result = cloneState(state);
result.balances.find((item) => item.id === first.id)!.remaining--;
result.placements.set(date, {
@@ -1758,9 +2270,10 @@ function assignDateVariants(
supporting: false,
});
for (const [index, balance] of balances.entries()) {
- recordTraceCandidate(context, {
+ const evaluation = recordTraceCandidate(context, {
status: index === 0 ? 'evaluated-rejected' : 'not-examined',
date,
+ ...(index === 0 ? selectionFacts : {}),
...(index === 0
? {}
: {
@@ -1777,6 +2290,7 @@ function assignDateVariants(
supporting: false,
},
});
+ if (index === 0) selectedEvaluation?.(evaluation);
}
return [result];
}
@@ -1809,6 +2323,11 @@ function assignDateToBalance(
balanceId: string,
context: SearchContext,
ruleIds = new Set(),
+ selectedEvaluation?: (candidate: MutableTraceCandidate | undefined) => void,
+ selectionFacts?: Pick<
+ PlanningTraceCandidate,
+ 'technicalComparison' | 'firstDecisiveComparator'
+ >,
): State | null {
context.check();
if (state.placements.has(date)) {
@@ -1846,14 +2365,16 @@ function assignDateToBalance(
ruleIds: new Set(ruleIds),
supporting: false,
});
- recordTraceCandidate(context, {
+ const evaluation = recordTraceCandidate(context, {
status: 'evaluated-rejected',
date,
+ ...selectionFacts,
placement: {
...result.placements.get(date)!,
ruleIds: [...result.placements.get(date)!.ruleIds],
},
});
+ selectedEvaluation?.(evaluation);
return result;
}
@@ -1863,6 +2384,12 @@ function assignDateToBalanceInPlace(
balanceId: string,
context: SearchContext,
ruleIds = new Set(),
+ selectedEvaluation?: (candidate: MutableTraceCandidate | undefined) => void,
+ beforeMutation?: (state: State) => void,
+ selectionFacts?: Pick<
+ PlanningTraceCandidate,
+ 'technicalComparison' | 'firstDecisiveComparator'
+ >,
): boolean {
context.check();
if (state.placements.has(date)) {
@@ -1890,6 +2417,7 @@ function assignDateToBalanceInPlace(
});
return false;
}
+ beforeMutation?.(state);
balance.remaining--;
state.placements.set(date, {
date,
@@ -1898,14 +2426,16 @@ function assignDateToBalanceInPlace(
ruleIds: new Set(ruleIds),
supporting: false,
});
- recordTraceCandidate(context, {
+ const evaluation = recordTraceCandidate(context, {
status: 'evaluated-rejected',
date,
+ ...selectionFacts,
placement: {
...state.placements.get(date)!,
ruleIds: [...state.placements.get(date)!.ruleIds],
},
});
+ selectedEvaluation?.(evaluation);
return true;
}
@@ -2035,23 +2565,44 @@ function satisfyObligations(
index = 0,
solutions?: State[],
solutionLimit = OBLIGATION_SOLUTION_LIMIT,
+ branchPath: readonly ObligationBranchReference[] = [],
+ parentDecisionId?: string,
): State | null {
context.check();
if (index >= requirements.length) {
const finalized = finalizeEarnedState(state, context);
+ if (!finalized)
+ recordTraceCandidate(context, {
+ status: 'pruned',
+ reason: reason(
+ 'earned-entitlement-order',
+ 'Generated leave is unavailable until its producing time-off period ends.',
+ ),
+ });
if (!finalized) return null;
- if (solutions) solutions.push(finalized);
+ if (solutions) {
+ const allDates = [
+ ...new Set([...state.placements.keys(), ...finalized.placements.keys()]),
+ ];
+ const finalization =
+ finalized === state
+ ? undefined
+ : recordTraceBranchDecision(
+ context,
+ state,
+ finalized,
+ parentDecisionId,
+ allDates,
+ );
+ const path = finalization ? [...branchPath, finalization] : [...branchPath];
+ if (path.length) context.obligationSolutionBranches.set(finalized, path);
+ solutions.push(finalized);
+ }
return solutions ? null : finalized;
}
const requirement = requirements[index];
if (requirement.patterns) {
for (const pattern of requirement.patterns) {
- observeTrace(context, (trace) =>
- trace.recordDecision('choose', pattern, {
- status: 'evaluated-rejected',
- dates: pattern,
- }),
- );
for (const assigned of assignDatesVariants(state, pattern, requirement.rule, context)) {
const candidate = addEarnedBalance(
assigned,
@@ -2060,6 +2611,13 @@ function satisfyObligations(
pattern,
context,
);
+ const branch = recordTraceBranchDecision(
+ context,
+ state,
+ candidate,
+ parentDecisionId,
+ pattern,
+ );
const complete = satisfyObligations(
requirements,
candidate,
@@ -2067,6 +2625,8 @@ function satisfyObligations(
index + 1,
solutions,
solutionLimit,
+ branch ? [...branchPath, branch] : branchPath,
+ branch?.decisionId ?? parentDecisionId,
);
if (complete && !solutions) return complete;
if (solutions && solutionLimit > 0 && solutions.length >= solutionLimit) {
@@ -2088,11 +2648,12 @@ function satisfyObligations(
const choose = (position: number, picked: string[], current: State): State | null => {
context.check();
if (picked.length === needed) {
- observeTrace(context, (trace) =>
- trace.recordDecision('choose', picked, {
- status: 'evaluated-rejected',
- dates: picked,
- }),
+ const branch = recordTraceBranchDecision(
+ context,
+ state,
+ current,
+ parentDecisionId,
+ picked,
);
const complete = satisfyObligations(
requirements,
@@ -2101,6 +2662,8 @@ function satisfyObligations(
index + 1,
solutions,
solutionLimit,
+ branch ? [...branchPath, branch] : branchPath,
+ branch?.decisionId ?? parentDecisionId,
);
if (complete && !solutions) return complete;
return null;
@@ -2144,6 +2707,13 @@ function satisfyObligations(
return choose(0, [], state);
}
+function chooseObligationBranches(state: State, context: SearchContext): void {
+ for (const branch of context.obligationSolutionBranches.get(state) ?? []) {
+ observeTrace(context, (trace) => trace.chooseEvaluation(branch.candidate));
+ recordTraceState(context, branch.stateAfter, branch.candidate);
+ }
+}
+
function placeAtRisk(state: State, context: SearchContext): State {
// At-risk placement is a greedy pass over one state. Clone it once, then
// mutate that private copy; cloning the whole placement map for every day is
@@ -2168,27 +2738,84 @@ function placeAtRisk(state: State, context: SearchContext): State {
const current = result.balances.find((item) => item.id === balance.id)!;
if (current.remaining <= 0) break;
let nextState: State | null = null;
- for (const candidate of availableDates) {
+ let nextEvaluation: MutableTraceCandidate | undefined;
+ for (const [candidateIndex, candidate] of availableDates.entries()) {
context.check();
if (
!result.placements.has(candidate) &&
compareDate(current.availableFrom, candidate) <= 0 &&
compareDate(candidate, current.expiryDate) <= 0
) {
+ const nextDate = availableDates
+ .slice(candidateIndex + 1)
+ .find(
+ (date) =>
+ !result.placements.has(date) &&
+ compareDate(current.availableFrom, date) <= 0 &&
+ compareDate(date, current.expiryDate) <= 0,
+ );
+ const selectionFacts = nextDate
+ ? atRiskDateSelectionFacts(balance.type, candidate, nextDate, context)
+ : undefined;
+ let remainingBeforeTransition = current.remaining;
+ let evaluation: MutableTraceCandidate | undefined;
const assigned = canMutate
- ? assignDateToBalanceInPlace(result, candidate, current.id, context)
+ ? assignDateToBalanceInPlace(
+ result,
+ candidate,
+ current.id,
+ context,
+ new Set(),
+ (candidateTrace) => (evaluation = candidateTrace),
+ (before) =>
+ (remainingBeforeTransition =
+ before.balances.find((item) => item.id === current.id)
+ ?.remaining ?? remainingBeforeTransition),
+ selectionFacts,
+ )
? result
: null
- : assignDateToBalance(result, candidate, current.id, context);
+ : assignDateToBalance(
+ result,
+ candidate,
+ current.id,
+ context,
+ new Set(),
+ (candidateTrace) => (evaluation = candidateTrace),
+ selectionFacts,
+ );
const feasible = assigned ? finalizeEarnedState(assigned, context) : null;
if (feasible) {
nextState = feasible;
+ nextEvaluation = evaluation;
+ if (context.traceBranchActive) {
+ const parent = canMutate
+ ? recordTraceAtRiskBranchMutation(
+ context,
+ feasible,
+ context.traceBranchParentId,
+ evaluation,
+ candidate,
+ current.id,
+ remainingBeforeTransition,
+ )
+ : recordTraceBranchMutation(
+ context,
+ result,
+ feasible,
+ context.traceBranchParentId,
+ evaluation,
+ [candidate],
+ );
+ context.traceBranchParentId = parent ?? context.traceBranchParentId;
+ }
break;
}
}
}
if (!nextState) break;
result = nextState;
+ if (!context.traceBranchActive) recordTraceState(context, result, nextEvaluation);
}
}
return result;
@@ -2228,12 +2855,32 @@ function preferenceTimeWindow(
for (const date of candidates) {
context.check();
if (count >= rule.placeDays) break;
- const next = assignDate(result, date, rule.placeType, rule.id, context);
+ const before = result;
+ let evaluation: MutableTraceCandidate | undefined;
+ const next = assignDate(
+ result,
+ date,
+ rule.placeType,
+ rule.id,
+ context,
+ (candidate) => (evaluation = candidate),
+ );
// A later preference can extend the generating period of an earned
// entitlement. Reject that candidate immediately instead of allowing an
// optional preference to turn the final plan into a conflict.
const feasible = next ? finalizeEarnedState(next, context) : null;
if (feasible) {
+ if (context.traceBranchActive) {
+ const parent = recordTraceBranchMutation(
+ context,
+ before,
+ feasible,
+ context.traceBranchParentId,
+ evaluation,
+ [date],
+ );
+ context.traceBranchParentId = parent ?? context.traceBranchParentId;
+ } else recordTraceState(context, feasible, evaluation);
result = feasible;
count++;
}
@@ -2251,12 +2898,45 @@ function preferenceIfThen(
let bestCount = 0;
let bestFutureScore = Number.POSITIVE_INFINITY;
let bestMetrics: PlanningMetrics | undefined;
+ let bestBranch: ObligationBranchReference | undefined;
+ let bestEvaluation: MutableTraceCandidate | undefined;
const search = (index: number, current: State, count: number, futureScore: number): void => {
context.check();
- if (count + matchingOccurrences.length - index < bestCount) return;
+ const maximumCount = count + matchingOccurrences.length - index;
+ if (maximumCount < bestCount) {
+ const comparator = `complete-occurrences:${rule.id}`;
+ recordTraceCandidate(context, {
+ status: 'pruned',
+ reason: reason(
+ 'preference-branch-bound',
+ 'This branch cannot complete as many preference occurrences as the current best.',
+ [rule.id],
+ ),
+ technicalComparison: [
+ {
+ name: comparator,
+ direction: 'maximize',
+ before: bestCount,
+ after: maximumCount,
+ },
+ ],
+ firstDecisiveComparator: comparator,
+ });
+ return;
+ }
if (index >= matchingOccurrences.length) {
const finalized = finalizeEarnedState(current, context);
- if (!finalized) return;
+ if (!finalized) {
+ recordTraceCandidate(context, {
+ status: 'pruned',
+ reason: reason(
+ 'earned-entitlement-order',
+ 'Generated leave is unavailable until its producing time-off period ends.',
+ [rule.id],
+ ),
+ });
+ return;
+ }
const metrics = qualityMetrics(finalized, context, []);
const betterTie =
futureScore < bestFutureScore ||
@@ -2266,11 +2946,52 @@ function preferenceIfThen(
(compareMetrics(metrics, bestMetrics) === 0 &&
compareText(placementTieKey(finalized), placementTieKey(bestState)) <
0)));
- if (count > bestCount || (count === bestCount && betterTie)) {
+ const better = count > bestCount || (count === bestCount && betterTie);
+ let branch: ObligationBranchReference | undefined;
+ let evaluation: MutableTraceCandidate | undefined;
+ if (context.trace) {
+ const beforeCount = bestCount;
+ const beforeFutureScore = bestFutureScore;
+ const beforeMetrics = bestMetrics;
+ const beforeState = bestState;
+ const candidateDates = () =>
+ [...finalized.placements.values()]
+ .filter(
+ (placement) =>
+ placement.ruleIds.has(rule.id) &&
+ !state.placements.get(placement.date)?.ruleIds.has(rule.id),
+ )
+ .map((placement) => placement.date);
+ const comparison = () =>
+ preferenceIfThenComparisonFacts(
+ rule.id,
+ count,
+ futureScore,
+ metrics,
+ beforeCount,
+ beforeFutureScore,
+ beforeMetrics,
+ finalized,
+ beforeState,
+ );
+ if (context.traceBranchActive)
+ branch = recordTraceBranchDecision(
+ context,
+ state,
+ finalized,
+ context.traceBranchParentId,
+ candidateDates,
+ comparison,
+ );
+ else evaluation = recordTraceIfThenEvaluation(context, candidateDates, comparison);
+ }
+ if (better) {
bestCount = count;
bestState = finalized;
bestFutureScore = futureScore;
bestMetrics = metrics;
+ bestBranch = branch;
+ bestEvaluation = evaluation;
}
return;
}
@@ -2297,9 +3018,100 @@ function preferenceIfThen(
search(index + 1, current, count, futureScore);
};
search(0, state, 0, 0);
+ if (bestBranch) {
+ observeTrace(context, (trace) => trace.chooseEvaluation(bestBranch!.candidate));
+ context.traceBranchParentId = bestBranch.decisionId;
+ } else if (!context.traceBranchActive) {
+ recordTraceState(context, bestState, bestEvaluation);
+ }
return { state: bestState, count: bestCount };
}
+function preferenceIfThenComparisonFacts(
+ ruleId: string,
+ count: number,
+ futureScore: number,
+ metrics: PlanningMetrics,
+ bestCount: number,
+ bestFutureScore: number,
+ bestMetrics: PlanningMetrics | undefined,
+ state: State,
+ bestState: State,
+): {
+ values: NonNullable;
+ firstDecisiveComparator?: string;
+} {
+ const values: NonNullable[number][] = [];
+ const countName = `complete-occurrences:${ruleId}`;
+ const countEqual = count === bestCount;
+ values.push({ name: countName, direction: 'maximize', before: bestCount, after: count });
+ const scoreName = `future-pattern-conflict-score:${ruleId}`;
+ const scoreBefore = Number.isFinite(bestFutureScore) ? bestFutureScore : '∞';
+ values.push({
+ name: scoreName,
+ direction: 'minimize',
+ before: scoreBefore,
+ after: futureScore,
+ });
+ let firstDecisiveComparator = !countEqual ? countName : undefined;
+ if (countEqual && futureScore !== bestFutureScore) firstDecisiveComparator = scoreName;
+
+ if (futureScore === bestFutureScore && bestMetrics) {
+ const metricComparators = [
+ {
+ name: 'unplaced-at-risk',
+ direction: 'minimize' as const,
+ before: bestMetrics.unplacedAtRisk,
+ after: metrics.unplacedAtRisk,
+ },
+ {
+ name: 'holiday-week-value',
+ direction: 'maximize' as const,
+ before: bestMetrics.holidayWeekValue,
+ after: metrics.holidayWeekValue,
+ },
+ {
+ name: 'non-working-day-gain',
+ direction: 'maximize' as const,
+ before: bestMetrics.nonWorkingDayGain,
+ after: metrics.nonWorkingDayGain,
+ },
+ {
+ name: 'longest-work-sequence',
+ direction: 'minimize' as const,
+ before: bestMetrics.longestWorkSequence,
+ after: metrics.longestWorkSequence,
+ },
+ {
+ name: 'short-sequence-deficit',
+ direction: 'minimize' as const,
+ before: bestMetrics.shortSequenceDeficit,
+ after: metrics.shortSequenceDeficit,
+ },
+ {
+ name: 'supporting-placements',
+ direction: 'minimize' as const,
+ before: bestMetrics.supportingPlacements,
+ after: metrics.supportingPlacements,
+ },
+ ];
+ for (const comparator of metricComparators) {
+ values.push(comparator);
+ if (comparator.before !== comparator.after) {
+ firstDecisiveComparator ??= comparator.name;
+ break;
+ }
+ }
+ if (metricComparators.every((comparator) => comparator.before === comparator.after)) {
+ const before = placementTieKey(bestState);
+ const after = placementTieKey(state);
+ values.push({ name: 'placement-date', direction: 'minimize', before, after });
+ if (before !== after) firstDecisiveComparator ??= 'placement-date';
+ }
+ }
+ return { values, firstDecisiveComparator };
+}
+
function preferenceDateConflictScore(
selector: LeaveSelector,
date: string,
@@ -2319,6 +3131,89 @@ function preferenceDateConflictScore(
return demand ? -demand : 0;
}
+function atRiskDateSelectionFacts(
+ selector: LeaveSelector,
+ selectedDate: string,
+ nextDate: string,
+ context: SearchContext,
+): Pick {
+ const selectedScore = preferenceDateConflictScore(selector, selectedDate, context);
+ const nextScore = preferenceDateConflictScore(selector, nextDate, context);
+ const technicalComparison: NonNullable<
+ PlanningTraceCandidate['technicalComparison']
+ >[number][] = [
+ {
+ name: 'preference-date-conflict',
+ direction: 'minimize',
+ before: selectedScore,
+ after: nextScore,
+ },
+ ];
+ if (selectedScore !== nextScore)
+ return { technicalComparison, firstDecisiveComparator: 'preference-date-conflict' };
+ technicalComparison.push({
+ name: 'placement-date',
+ direction: 'minimize',
+ before: selectedDate,
+ after: nextDate,
+ });
+ return { technicalComparison, firstDecisiveComparator: 'placement-date' };
+}
+
+function firstCompatibleBalanceSelectionFacts(
+ selector: LeaveSelector,
+ date: string,
+ first: InternalBalance,
+ second: InternalBalance | undefined,
+ context: SearchContext,
+): Pick {
+ if (!second) return {};
+ const values: NonNullable[number][] = [];
+ let firstDecisiveComparator: string | undefined;
+ const add = (
+ name: string,
+ direction: 'minimize' | 'maximize',
+ before: number | string,
+ after: number | string,
+ ): boolean => {
+ values.push({ name, direction, before, after });
+ if (before !== after) firstDecisiveComparator ??= name;
+ return before === after;
+ };
+ if (selector === 'any') {
+ const sharedTypes = context.sharedTypesByDate.get(date) ?? new Set();
+ let equal = add(
+ 'shared-placement-type',
+ 'maximize',
+ Number(sharedTypes.has(first.type)),
+ Number(sharedTypes.has(second.type)),
+ );
+ const protectedTypes = context.protectedTypesByDate.get(date) ?? new Set();
+ if (equal && protectedTypes.size)
+ equal = add(
+ 'protected-preference-type',
+ 'maximize',
+ Number(protectedTypes.has(first.type)),
+ Number(protectedTypes.has(second.type)),
+ );
+ if (
+ equal &&
+ context.reservedTypes.has(first.type) !== context.reservedTypes.has(second.type)
+ )
+ add(
+ 'reserved-type',
+ 'minimize',
+ Number(context.reservedTypes.has(first.type)),
+ Number(context.reservedTypes.has(second.type)),
+ );
+ }
+ if (!firstDecisiveComparator) {
+ const equal = add('balance-expiry', 'minimize', first.expiryDate, second.expiryDate);
+ if (equal) add('balance-id', 'minimize', first.id, second.id);
+ }
+ return { technicalComparison: values, firstDecisiveComparator };
+}
+
function patternConflictScore(
selector: LeaveSelector,
pattern: string[],
@@ -2586,6 +3481,265 @@ function compareObligationPreviews(
return compareText(placementTieKey(a.state), placementTieKey(b.state));
}
+function obligationPreviewComparisonFacts(
+ a: { state: State; preferences: PreferenceResult[]; metrics: PlanningMetrics },
+ b: { state: State; preferences: PreferenceResult[]; metrics: PlanningMetrics },
+): {
+ values: NonNullable;
+ firstDecisiveComparator?: string;
+} {
+ const values: NonNullable[number][] = [];
+ const add = (
+ name: string,
+ direction: 'minimize' | 'maximize',
+ before: number | string,
+ after: number | string,
+ ) => {
+ values.push({ name, direction, before, after });
+ return before === after;
+ };
+ let equal = add(
+ 'unplaced-at-risk',
+ 'minimize',
+ b.metrics.unplacedAtRisk,
+ a.metrics.unplacedAtRisk,
+ );
+ if (!equal) return { values, firstDecisiveComparator: 'unplaced-at-risk' };
+ for (let index = 0; index < a.metrics.preferenceSatisfaction.length; index++) {
+ const before = b.metrics.preferenceSatisfaction[index] ?? 0;
+ const after = a.metrics.preferenceSatisfaction[index] ?? 0;
+ const ruleId =
+ a.preferences[index]?.ruleId ?? b.preferences[index]?.ruleId ?? String(index);
+ equal = add(`preference:${ruleId}`, 'maximize', before, after);
+ if (!equal) return { values, firstDecisiveComparator: `preference:${ruleId}` };
+ }
+ const metrics = [
+ {
+ name: 'holiday-week-value',
+ direction: 'maximize' as const,
+ before: b.metrics.holidayWeekValue,
+ after: a.metrics.holidayWeekValue,
+ },
+ {
+ name: 'non-working-day-gain',
+ direction: 'maximize' as const,
+ before: b.metrics.nonWorkingDayGain,
+ after: a.metrics.nonWorkingDayGain,
+ },
+ {
+ name: 'longest-work-sequence',
+ direction: 'minimize' as const,
+ before: b.metrics.longestWorkSequence,
+ after: a.metrics.longestWorkSequence,
+ },
+ {
+ name: 'short-sequence-deficit',
+ direction: 'minimize' as const,
+ before: b.metrics.shortSequenceDeficit,
+ after: a.metrics.shortSequenceDeficit,
+ },
+ {
+ name: 'supporting-placements',
+ direction: 'minimize' as const,
+ before: b.metrics.supportingPlacements,
+ after: a.metrics.supportingPlacements,
+ },
+ ];
+ for (const metric of metrics) {
+ equal = add(metric.name, metric.direction, metric.before, metric.after);
+ if (!equal) return { values, firstDecisiveComparator: metric.name };
+ }
+ const beforeExpiry = expiryTieKey(b.state);
+ const afterExpiry = expiryTieKey(a.state);
+ equal = add('balance-expiry', 'minimize', beforeExpiry, afterExpiry);
+ if (!equal) return { values, firstDecisiveComparator: 'balance-expiry' };
+ const beforeDates = placementTieKey(b.state);
+ const afterDates = placementTieKey(a.state);
+ equal = add('placement-date', 'minimize', beforeDates, afterDates);
+ return { values, firstDecisiveComparator: equal ? undefined : 'placement-date' };
+}
+
+type QualityTraceDetails = Pick<
+ PlanningTraceCandidate,
+ 'technicalComparison' | 'firstDecisiveComparator' | 'reason'
+>;
+
+type QualityTraceComparator = NonNullable[number];
+
+function addQualityComparator(
+ values: QualityTraceComparator[],
+ name: string,
+ direction: QualityTraceComparator['direction'],
+ before: number | string,
+ after: number | string,
+): boolean {
+ values.push({ name, direction, before, after });
+ return before === after;
+}
+
+function movableProtectionTraceDetails(
+ baseline: PlanningMetrics,
+ candidate: PlanningMetrics,
+ preferences: readonly PreferenceResult[],
+): QualityTraceDetails {
+ const values: QualityTraceComparator[] = [];
+ const reject = (name: string, message: string): QualityTraceDetails => ({
+ technicalComparison: values,
+ firstDecisiveComparator: name,
+ reason: reason('quality-protected-objective', message),
+ });
+ const atRiskEqual = addQualityComparator(
+ values,
+ 'protected:unplaced-at-risk',
+ 'minimize',
+ baseline.unplacedAtRisk,
+ candidate.unplacedAtRisk,
+ );
+ if (!atRiskEqual)
+ return reject(
+ 'protected:unplaced-at-risk',
+ 'This move changes the amount of unplaced at-risk leave protected by the quality pass.',
+ );
+ addQualityComparator(
+ values,
+ 'protected:holiday-week-value',
+ 'maximize',
+ baseline.holidayWeekValue,
+ candidate.holidayWeekValue,
+ );
+ if (candidate.holidayWeekValue < baseline.holidayWeekValue)
+ return reject(
+ 'protected:holiday-week-value',
+ 'This move reduces public-holiday week value protected by the quality pass.',
+ );
+ if (candidate.preferenceSatisfaction.length !== baseline.preferenceSatisfaction.length) {
+ const name = 'protected:preference-count';
+ addQualityComparator(
+ values,
+ name,
+ 'maximize',
+ baseline.preferenceSatisfaction.length,
+ candidate.preferenceSatisfaction.length,
+ );
+ return reject(
+ name,
+ 'This move changes the preference result protected by the quality pass.',
+ );
+ }
+ for (let index = 0; index < baseline.preferenceSatisfaction.length; index++) {
+ const name = `protected:preference:${preferences[index]?.ruleId ?? index}`;
+ const before = baseline.preferenceSatisfaction[index];
+ const after = candidate.preferenceSatisfaction[index];
+ if (!addQualityComparator(values, name, 'maximize', before, after))
+ return reject(
+ name,
+ 'This move changes a preference result protected by the quality pass.',
+ );
+ }
+ return { technicalComparison: values };
+}
+
+function movableIncumbentTraceDetails(
+ stage: 'non-working-day-gain' | 'longest-work-sequence' | 'short-sequence-deficit' | 'dates',
+ incumbent: PlanningMetrics,
+ candidate: PlanningMetrics,
+ incumbentDate: string,
+ candidateDate: string,
+): QualityTraceDetails {
+ const values: QualityTraceComparator[] = [];
+ const add = (
+ name: string,
+ direction: QualityTraceComparator['direction'],
+ before: number | string,
+ after: number | string,
+ ) => addQualityComparator(values, name, direction, before, after);
+ const finishIfDifferent = (name: string, equal: boolean): QualityTraceDetails | undefined =>
+ equal ? undefined : { technicalComparison: values, firstDecisiveComparator: name };
+
+ let equal = add(
+ 'holiday-week-value',
+ 'maximize',
+ incumbent.holidayWeekValue,
+ candidate.holidayWeekValue,
+ );
+ let decisive = finishIfDifferent('holiday-week-value', equal);
+ if (decisive) return decisive;
+ if (stage === 'non-working-day-gain') {
+ equal = add(
+ 'non-working-day-gain',
+ 'maximize',
+ incumbent.nonWorkingDayGain,
+ candidate.nonWorkingDayGain,
+ );
+ return finishIfDifferent('non-working-day-gain', equal) ?? { technicalComparison: values };
+ }
+ equal = add(
+ 'non-working-day-gain',
+ 'maximize',
+ incumbent.nonWorkingDayGain,
+ candidate.nonWorkingDayGain,
+ );
+ decisive = finishIfDifferent('non-working-day-gain', equal);
+ if (decisive) return decisive;
+ if (stage === 'longest-work-sequence') {
+ equal = add(
+ 'longest-work-sequence',
+ 'minimize',
+ incumbent.longestWorkSequence,
+ candidate.longestWorkSequence,
+ );
+ return finishIfDifferent('longest-work-sequence', equal) ?? { technicalComparison: values };
+ }
+ equal = add(
+ 'longest-work-sequence',
+ 'minimize',
+ incumbent.longestWorkSequence,
+ candidate.longestWorkSequence,
+ );
+ decisive = finishIfDifferent('longest-work-sequence', equal);
+ if (decisive) return decisive;
+ if (stage === 'short-sequence-deficit') {
+ equal = add(
+ 'short-sequence-deficit',
+ 'minimize',
+ incumbent.shortSequenceDeficit,
+ candidate.shortSequenceDeficit,
+ );
+ return (
+ finishIfDifferent('short-sequence-deficit', equal) ?? { technicalComparison: values }
+ );
+ }
+ equal = add(
+ 'short-sequence-deficit',
+ 'minimize',
+ incumbent.shortSequenceDeficit,
+ candidate.shortSequenceDeficit,
+ );
+ decisive = finishIfDifferent('short-sequence-deficit', equal);
+ if (decisive) return decisive;
+ if (stage === 'dates') {
+ const dateEqual = add('placement-date', 'minimize', incumbentDate, candidateDate);
+ return finishIfDifferent('placement-date', dateEqual) ?? { technicalComparison: values };
+ }
+ return { technicalComparison: values };
+}
+
+function recordTraceDecision(context: SearchContext, dates: readonly string[]): void {
+ observeTrace(context, (trace) => trace.recordDecision('choose', dates));
+}
+
+function updateTraceEvaluationReason(
+ context: SearchContext,
+ evaluation: MutableTraceCandidate | undefined,
+ evaluationReason: PlanningReason,
+): void {
+ if (!evaluation) return;
+ observeTrace(context, (trace) => {
+ evaluation.reason = evaluationReason;
+ trace.updateEvaluation(evaluation);
+ });
+}
+
function recordTraceEvaluation(
context: SearchContext,
beforeState: State,
@@ -2595,20 +3749,25 @@ function recordTraceEvaluation(
afterMetrics: PlanningMetrics,
dates: readonly string[],
sourceDate?: string,
+ qualityDetails?: () => QualityTraceDetails,
): MutableTraceCandidate | undefined {
let evaluation: MutableTraceCandidate | undefined;
observeTrace(context, (trace) => {
const date = dates[dates.length - 1];
const placement = date ? afterState.placements.get(date) : undefined;
+ const details = qualityDetails?.();
evaluation = {
status: 'evaluated-rejected',
sourceDate,
date,
dates: [...dates],
+ reason: details?.reason,
comparison: objectiveComparison(
- objectiveVector(beforeMetrics, preferences, beforeState),
- objectiveVector(afterMetrics, preferences, afterState),
+ objectiveVector(beforeMetrics, preferences, beforeState, context),
+ objectiveVector(afterMetrics, preferences, afterState, context),
),
+ technicalComparison: details?.technicalComparison,
+ firstDecisiveComparator: details?.firstDecisiveComparator,
placement: placement
? { ...placement, ruleIds: [...placement.ruleIds].sort() }
: undefined,
@@ -2618,6 +3777,118 @@ function recordTraceEvaluation(
return context.trace ? evaluation : undefined;
}
+function holidayWeekProtectionTraceDetails(
+ baseline: PlanningMetrics,
+ candidate: PlanningMetrics,
+ preferences: readonly PreferenceResult[],
+): QualityTraceDetails {
+ const values: QualityTraceComparator[] = [];
+ const reject = (name: string, message: string): QualityTraceDetails => ({
+ technicalComparison: values,
+ firstDecisiveComparator: name,
+ reason: reason('quality-protected-objective', message),
+ });
+ addQualityComparator(
+ values,
+ 'protected:unplaced-at-risk',
+ 'minimize',
+ baseline.unplacedAtRisk,
+ candidate.unplacedAtRisk,
+ );
+ if (candidate.unplacedAtRisk > baseline.unplacedAtRisk)
+ return reject(
+ 'protected:unplaced-at-risk',
+ 'This completion leaves more at-risk leave unplaced.',
+ );
+ for (
+ let index = 0;
+ index <
+ Math.max(baseline.preferenceSatisfaction.length, candidate.preferenceSatisfaction.length);
+ index++
+ ) {
+ const name = `protected:preference:${preferences[index]?.ruleId ?? index}`;
+ const before = baseline.preferenceSatisfaction[index] ?? 0;
+ const after = candidate.preferenceSatisfaction[index] ?? 0;
+ if (!addQualityComparator(values, name, 'maximize', before, after))
+ return reject(name, 'This completion changes a higher-priority preference result.');
+ }
+ addQualityComparator(
+ values,
+ 'protected:holiday-week-value',
+ 'maximize',
+ baseline.holidayWeekValue,
+ candidate.holidayWeekValue,
+ );
+ if (candidate.holidayWeekValue <= baseline.holidayWeekValue)
+ return reject(
+ 'protected:holiday-week-value',
+ 'This completion does not increase public-holiday week value.',
+ );
+ return { technicalComparison: values };
+}
+
+function qualityMetricTraceDetails(
+ incumbent: PlanningMetrics,
+ candidate: PlanningMetrics,
+): QualityTraceDetails {
+ const values: QualityTraceComparator[] = [];
+ const comparators = [
+ ['unplaced-at-risk', 'minimize', incumbent.unplacedAtRisk, candidate.unplacedAtRisk],
+ ['holiday-week-value', 'maximize', incumbent.holidayWeekValue, candidate.holidayWeekValue],
+ [
+ 'non-working-day-gain',
+ 'maximize',
+ incumbent.nonWorkingDayGain,
+ candidate.nonWorkingDayGain,
+ ],
+ [
+ 'longest-work-sequence',
+ 'minimize',
+ incumbent.longestWorkSequence,
+ candidate.longestWorkSequence,
+ ],
+ [
+ 'short-sequence-deficit',
+ 'minimize',
+ incumbent.shortSequenceDeficit,
+ candidate.shortSequenceDeficit,
+ ],
+ [
+ 'supporting-placements',
+ 'minimize',
+ incumbent.supportingPlacements,
+ candidate.supportingPlacements,
+ ],
+ ] as const;
+ for (const [name, direction, before, after] of comparators) {
+ if (!addQualityComparator(values, name, direction, before, after))
+ return { technicalComparison: values, firstDecisiveComparator: name };
+ }
+ return { technicalComparison: values };
+}
+
+function qualityMetricTieTraceDetails(
+ incumbent: PlanningMetrics,
+ candidate: PlanningMetrics,
+ incumbentState: State,
+ candidateState: State,
+): QualityTraceDetails {
+ const comparison = qualityMetricTraceDetails(incumbent, candidate);
+ if (comparison.firstDecisiveComparator) return comparison;
+ const values = [...(comparison.technicalComparison ?? [])];
+ const equal = addQualityComparator(
+ values,
+ 'placement-date',
+ 'minimize',
+ placementTieKey(incumbentState),
+ placementTieKey(candidateState),
+ );
+ return {
+ technicalComparison: values,
+ firstDecisiveComparator: equal ? undefined : 'placement-date',
+ };
+}
+
function optimizeMovablePlacements(
state: State,
context: SearchContext,
@@ -2690,6 +3961,7 @@ function optimizeMovablePlacements(
(!current.ruleIds.size && !isAtRisk(balance, context) && !current.supporting)
)
continue;
+ recordTraceDecision(context, [original.date]);
const baseline = qualityMetrics(result, context, preferences);
const without = cloneState(result);
without.placements.delete(original.date);
@@ -2729,26 +4001,54 @@ function optimizeMovablePlacements(
if (!feasibleCandidate) continue;
feasibleCandidate.placements.get(date)!.supporting = current.supporting;
const metrics = qualityMetrics(feasibleCandidate, context, preferences);
- const evaluation = recordTraceEvaluation(
- context,
- result,
- feasibleCandidate,
- preferences,
- baseline,
- metrics,
- [date],
- current.date,
- );
- if (metrics.unplacedAtRisk !== baseline.unplacedAtRisk) continue;
- if (metrics.holidayWeekValue < baseline.holidayWeekValue) continue;
+ if (metrics.unplacedAtRisk !== baseline.unplacedAtRisk) {
+ recordTraceEvaluation(
+ context,
+ result,
+ feasibleCandidate,
+ preferences,
+ baseline,
+ metrics,
+ [date],
+ current.date,
+ () => movableProtectionTraceDetails(baseline, metrics, preferences),
+ );
+ continue;
+ }
+ if (metrics.holidayWeekValue < baseline.holidayWeekValue) {
+ recordTraceEvaluation(
+ context,
+ result,
+ feasibleCandidate,
+ preferences,
+ baseline,
+ metrics,
+ [date],
+ current.date,
+ () => movableProtectionTraceDetails(baseline, metrics, preferences),
+ );
+ continue;
+ }
if (
metrics.preferenceSatisfaction.length !==
baseline.preferenceSatisfaction.length ||
metrics.preferenceSatisfaction.some(
(value, index) => value !== baseline.preferenceSatisfaction[index],
)
- )
+ ) {
+ recordTraceEvaluation(
+ context,
+ result,
+ feasibleCandidate,
+ preferences,
+ baseline,
+ metrics,
+ [date],
+ current.date,
+ () => movableProtectionTraceDetails(baseline, metrics, preferences),
+ );
continue;
+ }
const better =
metrics.holidayWeekValue > bestMetrics.holidayWeekValue ||
(metrics.holidayWeekValue === bestMetrics.holidayWeekValue &&
@@ -2769,7 +4069,56 @@ function optimizeMovablePlacements(
metrics.shortSequenceDeficit ===
bestMetrics.shortSequenceDeficit &&
date < bestDate));
+ const evaluation = recordTraceEvaluation(
+ context,
+ result,
+ feasibleCandidate,
+ preferences,
+ baseline,
+ metrics,
+ [date],
+ current.date,
+ () => {
+ const protectedDetails = movableProtectionTraceDetails(
+ baseline,
+ metrics,
+ preferences,
+ );
+ const incumbentDetails = movableIncumbentTraceDetails(
+ stage,
+ bestMetrics,
+ metrics,
+ bestDate,
+ date,
+ );
+ return {
+ technicalComparison: [
+ ...(protectedDetails.technicalComparison ?? []),
+ ...(incumbentDetails.technicalComparison ?? []),
+ ],
+ firstDecisiveComparator:
+ protectedDetails.reason?.code === 'quality-protected-objective'
+ ? protectedDetails.firstDecisiveComparator
+ : incumbentDetails.firstDecisiveComparator,
+ reason: better
+ ? undefined
+ : reason(
+ 'quality-no-improvement',
+ `This candidate did not improve the current best result in the ${stage} pass.`,
+ ),
+ };
+ },
+ );
if (better) {
+ if (bestEvaluation)
+ updateTraceEvaluationReason(
+ context,
+ bestEvaluation,
+ reason(
+ 'quality-outperformed',
+ 'A later candidate became the best result in this pass.',
+ ),
+ );
best = cloneState(feasibleCandidate);
bestMetrics = metrics;
bestDate = date;
@@ -2874,6 +4223,7 @@ function optimizeHolidayWeeks(
const candidates = context.holidayWeeks;
while (true) {
context.check();
+ if (candidates.length) recordTraceDecision(context, []);
const baseline = qualityMetrics(result, context, preferences);
const completed = candidates.filter((candidate) =>
candidate.dates.every((date) => result.placements.has(date)),
@@ -2891,6 +4241,32 @@ function optimizeHolidayWeeks(
const complete = completeHolidayWeek(result, candidate, context, protectedDates);
if (!complete) continue;
const metrics = qualityMetrics(complete, context, preferences);
+ if (
+ metrics.unplacedAtRisk > baseline.unplacedAtRisk ||
+ comparePreferenceVectors(
+ metrics.preferenceSatisfaction,
+ baseline.preferenceSatisfaction,
+ ) !== 0 ||
+ metrics.holidayWeekValue <= baseline.holidayWeekValue
+ ) {
+ recordTraceEvaluation(
+ context,
+ result,
+ complete,
+ preferences,
+ baseline,
+ metrics,
+ candidate.dates,
+ undefined,
+ () => holidayWeekProtectionTraceDetails(baseline, metrics, preferences),
+ );
+ continue;
+ }
+ const better =
+ !best ||
+ compareMetrics(metrics, bestMetrics) < 0 ||
+ (compareMetrics(metrics, bestMetrics) === 0 &&
+ placementTieKey(complete) < placementTieKey(best));
const evaluation = recordTraceEvaluation(
context,
result,
@@ -2899,22 +4275,43 @@ function optimizeHolidayWeeks(
baseline,
metrics,
candidate.dates,
+ undefined,
+ () => {
+ const protectedDetails = holidayWeekProtectionTraceDetails(
+ baseline,
+ metrics,
+ preferences,
+ );
+ const incumbentDetails = best
+ ? qualityMetricTieTraceDetails(bestMetrics, metrics, best, complete)
+ : undefined;
+ return {
+ technicalComparison: [
+ ...(protectedDetails.technicalComparison ?? []),
+ ...(incumbentDetails?.technicalComparison ?? []),
+ ],
+ firstDecisiveComparator: protectedDetails.reason
+ ? protectedDetails.firstDecisiveComparator
+ : incumbentDetails?.firstDecisiveComparator,
+ reason: better
+ ? undefined
+ : reason(
+ 'quality-no-improvement',
+ 'This completion did not outrank the current best on its compared objectives or date tie-break.',
+ ),
+ };
+ },
);
- if (
- metrics.unplacedAtRisk > baseline.unplacedAtRisk ||
- comparePreferenceVectors(
- metrics.preferenceSatisfaction,
- baseline.preferenceSatisfaction,
- ) !== 0 ||
- metrics.holidayWeekValue <= baseline.holidayWeekValue
- )
- continue;
- if (
- !best ||
- compareMetrics(metrics, bestMetrics) < 0 ||
- (compareMetrics(metrics, bestMetrics) === 0 &&
- placementTieKey(complete) < placementTieKey(best))
- ) {
+ if (better) {
+ if (bestEvaluation)
+ updateTraceEvaluationReason(
+ context,
+ bestEvaluation,
+ reason(
+ 'quality-outperformed',
+ 'A later holiday-week completion became the best result in this pass.',
+ ),
+ );
best = complete;
bestMetrics = metrics;
bestEvaluation = evaluation;
@@ -2938,6 +4335,81 @@ function sameHigherQuality(a: PlanningMetrics, b: PlanningMetrics): boolean {
);
}
+function expiryProtectionTraceDetails(
+ baseline: PlanningMetrics,
+ candidate: PlanningMetrics,
+ preferences: readonly PreferenceResult[],
+): QualityTraceDetails {
+ const values: QualityTraceComparator[] = [];
+ const reject = (name: string): QualityTraceDetails => ({
+ technicalComparison: values,
+ firstDecisiveComparator: name,
+ reason: reason(
+ 'quality-protected-objective',
+ `This replacement changes ${name.slice('protected:'.length).replaceAll('-', ' ')} protected by the expiry pass.`,
+ ),
+ });
+ for (const [name, direction, before, after] of [
+ [
+ 'protected:unplaced-at-risk',
+ 'minimize',
+ baseline.unplacedAtRisk,
+ candidate.unplacedAtRisk,
+ ],
+ [
+ 'protected:holiday-week-value',
+ 'maximize',
+ baseline.holidayWeekValue,
+ candidate.holidayWeekValue,
+ ],
+ [
+ 'protected:non-working-day-gain',
+ 'maximize',
+ baseline.nonWorkingDayGain,
+ candidate.nonWorkingDayGain,
+ ],
+ [
+ 'protected:longest-work-sequence',
+ 'minimize',
+ baseline.longestWorkSequence,
+ candidate.longestWorkSequence,
+ ],
+ [
+ 'protected:short-sequence-deficit',
+ 'minimize',
+ baseline.shortSequenceDeficit,
+ candidate.shortSequenceDeficit,
+ ],
+ ] as const) {
+ if (!addQualityComparator(values, name, direction, before, after)) return reject(name);
+ }
+ if (baseline.preferenceSatisfaction.length !== candidate.preferenceSatisfaction.length) {
+ const name = 'protected:preference-count';
+ addQualityComparator(
+ values,
+ name,
+ 'maximize',
+ baseline.preferenceSatisfaction.length,
+ candidate.preferenceSatisfaction.length,
+ );
+ return reject(name);
+ }
+ for (let index = 0; index < baseline.preferenceSatisfaction.length; index++) {
+ const name = `protected:preference:${preferences[index]?.ruleId ?? index}`;
+ if (
+ !addQualityComparator(
+ values,
+ name,
+ 'maximize',
+ baseline.preferenceSatisfaction[index],
+ candidate.preferenceSatisfaction[index],
+ )
+ )
+ return reject(name);
+ }
+ return { technicalComparison: values };
+}
+
function optimizeExpiryPlacements(
state: State,
context: SearchContext,
@@ -2954,6 +4426,7 @@ function optimizeExpiryPlacements(
if (!current) continue;
const source = result.balances.find((balance) => balance.id === current.balanceId);
if (!source) continue;
+ recordTraceDecision(context, [original.date]);
const baseline = qualityMetrics(result, context, preferences);
const without = cloneState(result);
without.placements.delete(current.date);
@@ -2985,6 +4458,9 @@ function optimizeExpiryPlacements(
if (!feasibleCandidate) continue;
feasibleCandidate.placements.get(current.date)!.supporting = current.supporting;
const metrics = qualityMetrics(feasibleCandidate, context, preferences);
+ const expiry = expiryTieKey(feasibleCandidate);
+ const preservesQuality = sameHigherQuality(metrics, baseline);
+ const better = preservesQuality && expiry < bestExpiry;
const evaluation = recordTraceEvaluation(
context,
result,
@@ -2994,9 +4470,47 @@ function optimizeExpiryPlacements(
metrics,
[current.date],
current.date,
+ () => {
+ const protectedDetails = expiryProtectionTraceDetails(
+ baseline,
+ metrics,
+ preferences,
+ );
+ const values = [...(protectedDetails.technicalComparison ?? [])];
+ let firstDecisiveComparator = protectedDetails.firstDecisiveComparator;
+ let candidateReason = protectedDetails.reason;
+ if (!candidateReason) {
+ const equalExpiry = addQualityComparator(
+ values,
+ 'balance-expiry',
+ 'minimize',
+ bestExpiry,
+ expiry,
+ );
+ if (!equalExpiry) firstDecisiveComparator = 'balance-expiry';
+ if (!better)
+ candidateReason = reason(
+ 'quality-no-improvement',
+ 'This replacement does not use an earlier-expiring balance than the current best.',
+ );
+ }
+ return {
+ technicalComparison: values,
+ firstDecisiveComparator,
+ reason: candidateReason,
+ };
+ },
);
- const expiry = expiryTieKey(feasibleCandidate);
- if (sameHigherQuality(metrics, baseline) && expiry < bestExpiry) {
+ if (better) {
+ if (bestEvaluation)
+ updateTraceEvaluationReason(
+ context,
+ bestEvaluation,
+ reason(
+ 'quality-outperformed',
+ 'A later replacement became the best earlier-expiry choice in this pass.',
+ ),
+ );
best = feasibleCandidate;
bestExpiry = expiry;
bestEvaluation = evaluation;
@@ -3177,12 +4691,47 @@ function remainingBalances(state: State, context: SearchContext): RemainingBalan
});
}
+function unsatisfiedObligationCount(state: State, context: SearchContext): number {
+ const placementCounts = new Map();
+ for (const placement of state.placements.values())
+ for (const ruleId of placement.ruleIds)
+ placementCounts.set(ruleId, (placementCounts.get(ruleId) ?? 0) + 1);
+ let unsatisfied = 0;
+ for (const requirement of buildRequirements(context.rules, context, 'obligation')) {
+ if (!requirement.patterns) {
+ if ((placementCounts.get(requirement.rule.id) ?? 0) !== requirement.rule.placeDays)
+ unsatisfied++;
+ continue;
+ }
+ let complete = false;
+ for (const pattern of requirement.patterns) {
+ if (
+ pattern.every((date) =>
+ state.placements.get(date)?.ruleIds.has(requirement.rule.id),
+ )
+ ) {
+ complete = true;
+ break;
+ }
+ }
+ if (!complete) unsatisfied++;
+ }
+ return unsatisfied;
+}
+
function objectiveVector(
metrics: PlanningMetrics,
preferences: readonly PreferenceResult[],
state: State,
+ context: SearchContext,
): PlanningObjectiveVector {
const objectives: PlanningObjective[] = [
+ {
+ id: 'obligation-validity',
+ direction: 'minimize',
+ value: unsatisfiedObligationCount(state, context),
+ label: 'Unsatisfied obligations',
+ },
{
id: 'unplaced-at-risk',
direction: 'minimize',
@@ -3259,7 +4808,7 @@ function planExplanation(
const rawRules = Array.isArray(source.rules) ? source.rules : [];
const labels = recordValue(source.labels) as Record | undefined;
const identities = recordValue(source.identities) as Record | undefined;
- const vector = objectiveVector(metrics, preferences, state);
+ const vector = objectiveVector(metrics, preferences, state, context);
return {
kind: 'PlanExplanation',
placements: placements.map((placement) => {
@@ -3294,7 +4843,7 @@ function planExplanation(
const withoutPreferences = preferenceResultsForState(withoutPlacement, context);
const withoutMetrics = qualityMetrics(withoutPlacement, context, withoutPreferences);
const comparison = objectiveComparison(
- objectiveVector(withoutMetrics, withoutPreferences, withoutPlacement),
+ objectiveVector(withoutMetrics, withoutPreferences, withoutPlacement, context),
vector,
);
const contributions: PlacementContribution[] = [
@@ -3439,7 +4988,10 @@ function markProgress(context: SearchContext, stage: string): void {
trace.recordDecision(stage === 'validation' ? 'validate' : 'choose', []);
});
}
- if (typeof context.request.progress === 'function') context.request.progress(stage);
+ if (typeof context.request.progress === 'function')
+ observeCallback(context, () =>
+ (context.request.progress as (stage: string) => void)(stage),
+ );
}
function planningStages(context: SearchContext): string[] {
@@ -3488,24 +5040,28 @@ function obligationConflict(
.filter((rule) => rule.enabled && (rule.rewardDays ?? 0) > 0 && rule.rewardType)
.map((rule) => rule.rewardType!),
);
+ let generatedCapacityBottleneck = false;
for (const requirement of requirements) {
if (requirement.rule.placeType === 'any') continue;
const required = requirement.patterns?.[0]?.length ?? requirement.rule.placeDays;
const capacity = context.initialBalances
.filter((balance) => balance.type === requirement.rule.placeType)
.reduce((sum, balance) => sum + balance.quantity, 0);
- if (capacity < required && !generatedTypes.has(requirement.rule.placeType))
- return {
- kind: 'PlanningConflict',
- conflictingRuleIds: ruleIds,
- reasons: [
- reason(
- 'typed-capacity',
- `The obligations require more ${requirement.rule.placeType.toUpperCase()} leave than is available.`,
- ruleIds,
- ),
- ],
- };
+ if (capacity < required) {
+ if (!generatedTypes.has(requirement.rule.placeType))
+ return {
+ kind: 'PlanningConflict',
+ conflictingRuleIds: ruleIds,
+ reasons: [
+ reason(
+ 'typed-capacity',
+ `The obligations require more ${requirement.rule.placeType.toUpperCase()} leave than is available.`,
+ ruleIds,
+ ),
+ ],
+ };
+ generatedCapacityBottleneck = true;
+ }
}
if (
requirements.some(
@@ -3525,7 +5081,7 @@ function obligationConflict(
),
],
};
- if (generatedTypes.size)
+ if (generatedCapacityBottleneck)
return {
kind: 'PlanningConflict',
conflictingRuleIds: ruleIds,
@@ -3550,6 +5106,160 @@ function obligationConflict(
};
}
+function supportingProtectionTraceDetails(
+ baseline: PlanningMetrics,
+ candidate: PlanningMetrics,
+ preferences: readonly PreferenceResult[],
+): QualityTraceDetails {
+ const values: QualityTraceComparator[] = [];
+ const reject = (name: string, message: string): QualityTraceDetails => ({
+ technicalComparison: values,
+ firstDecisiveComparator: name,
+ reason: reason('quality-protected-objective', message),
+ });
+ const same = (
+ name: string,
+ direction: QualityTraceComparator['direction'],
+ a: number,
+ b: number,
+ ) => addQualityComparator(values, name, direction, a, b);
+ if (
+ !same(
+ 'protected:unplaced-at-risk',
+ 'minimize',
+ baseline.unplacedAtRisk,
+ candidate.unplacedAtRisk,
+ )
+ )
+ return reject(
+ 'protected:unplaced-at-risk',
+ 'This supporting placement changes the amount of unplaced at-risk leave.',
+ );
+ if (baseline.preferenceSatisfaction.length !== candidate.preferenceSatisfaction.length) {
+ const name = 'protected:preference-count';
+ same(
+ name,
+ 'maximize',
+ baseline.preferenceSatisfaction.length,
+ candidate.preferenceSatisfaction.length,
+ );
+ return reject(name, 'This supporting placement changes a preference result.');
+ }
+ for (let index = 0; index < baseline.preferenceSatisfaction.length; index++) {
+ const name = `protected:preference:${preferences[index]?.ruleId ?? index}`;
+ if (
+ !same(
+ name,
+ 'maximize',
+ baseline.preferenceSatisfaction[index],
+ candidate.preferenceSatisfaction[index],
+ )
+ )
+ return reject(name, 'This supporting placement changes a preference result.');
+ }
+ for (const [name, direction, before, after, isAllowed] of [
+ [
+ 'protected:holiday-week-value',
+ 'maximize',
+ baseline.holidayWeekValue,
+ candidate.holidayWeekValue,
+ candidate.holidayWeekValue >= baseline.holidayWeekValue,
+ ],
+ [
+ 'protected:non-working-day-gain',
+ 'maximize',
+ baseline.nonWorkingDayGain,
+ candidate.nonWorkingDayGain,
+ candidate.nonWorkingDayGain >= baseline.nonWorkingDayGain,
+ ],
+ [
+ 'protected:longest-work-sequence',
+ 'minimize',
+ baseline.longestWorkSequence,
+ candidate.longestWorkSequence,
+ candidate.longestWorkSequence <= baseline.longestWorkSequence,
+ ],
+ [
+ 'protected:short-sequence-deficit',
+ 'minimize',
+ baseline.shortSequenceDeficit,
+ candidate.shortSequenceDeficit,
+ candidate.shortSequenceDeficit <= baseline.shortSequenceDeficit,
+ ],
+ ] as const) {
+ same(name, direction, before, after);
+ if (!isAllowed)
+ return reject(
+ name,
+ `This supporting placement worsens ${name.slice('protected:'.length).replaceAll('-', ' ')}.`,
+ );
+ }
+ return { technicalComparison: values };
+}
+
+function supportingRemovalTraceDetails(
+ baseline: PlanningMetrics,
+ candidate: PlanningMetrics,
+): QualityTraceDetails {
+ const values: QualityTraceComparator[] = [];
+ const reject = (name: string, message: string): QualityTraceDetails => ({
+ technicalComparison: values,
+ firstDecisiveComparator: name,
+ reason: reason('quality-protected-objective', message),
+ });
+ if (
+ !addQualityComparator(
+ values,
+ 'protected:unplaced-at-risk',
+ 'minimize',
+ baseline.unplacedAtRisk,
+ candidate.unplacedAtRisk,
+ )
+ )
+ return reject(
+ 'protected:unplaced-at-risk',
+ 'Removing this supporting placement changes unplaced at-risk leave.',
+ );
+ for (const [name, direction, before, after, isAllowed] of [
+ [
+ 'protected:holiday-week-value',
+ 'maximize',
+ baseline.holidayWeekValue,
+ candidate.holidayWeekValue,
+ candidate.holidayWeekValue >= baseline.holidayWeekValue,
+ ],
+ [
+ 'protected:non-working-day-gain',
+ 'maximize',
+ baseline.nonWorkingDayGain,
+ candidate.nonWorkingDayGain,
+ candidate.nonWorkingDayGain >= baseline.nonWorkingDayGain,
+ ],
+ [
+ 'protected:longest-work-sequence',
+ 'minimize',
+ baseline.longestWorkSequence,
+ candidate.longestWorkSequence,
+ candidate.longestWorkSequence <= baseline.longestWorkSequence,
+ ],
+ [
+ 'protected:short-sequence-deficit',
+ 'minimize',
+ baseline.shortSequenceDeficit,
+ candidate.shortSequenceDeficit,
+ candidate.shortSequenceDeficit <= baseline.shortSequenceDeficit,
+ ],
+ ] as const) {
+ addQualityComparator(values, name, direction, before, after);
+ if (!isAllowed)
+ return reject(
+ name,
+ `Removing this supporting placement worsens ${name.slice('protected:'.length).replaceAll('-', ' ')}.`,
+ );
+ }
+ return { technicalComparison: values };
+}
+
function supportingPlacements(
state: State,
context: SearchContext,
@@ -3582,15 +5292,6 @@ function supportingPlacements(
const feasibleCandidate = finalizeEarnedState(candidate, context);
if (!feasibleCandidate) continue;
const metrics = qualityMetrics(feasibleCandidate, context, preferences);
- const evaluation = recordTraceEvaluation(
- context,
- result,
- feasibleCandidate,
- preferences,
- before,
- metrics,
- [date],
- );
const preservesHigherMetrics =
metrics.unplacedAtRisk === before.unplacedAtRisk &&
metrics.preferenceSatisfaction.length ===
@@ -3602,14 +5303,89 @@ function supportingPlacements(
metrics.nonWorkingDayGain >= before.nonWorkingDayGain &&
metrics.longestWorkSequence <= before.longestWorkSequence &&
metrics.shortSequenceDeficit <= before.shortSequenceDeficit;
+ if (!preservesHigherMetrics) {
+ recordTraceEvaluation(
+ context,
+ result,
+ feasibleCandidate,
+ preferences,
+ before,
+ metrics,
+ [date],
+ undefined,
+ () => supportingProtectionTraceDetails(before, metrics, preferences),
+ );
+ continue;
+ }
const useful =
- preservesHigherMetrics &&
- (metrics.holidayWeekValue > before.holidayWeekValue ||
- metrics.nonWorkingDayGain > before.nonWorkingDayGain ||
- metrics.longestWorkSequence < before.longestWorkSequence ||
- metrics.shortSequenceDeficit < before.shortSequenceDeficit);
- if (!useful) continue;
- if (!bestMetrics || compareMetrics(metrics, bestMetrics) < 0) {
+ metrics.holidayWeekValue > before.holidayWeekValue ||
+ metrics.nonWorkingDayGain > before.nonWorkingDayGain ||
+ metrics.longestWorkSequence < before.longestWorkSequence ||
+ metrics.shortSequenceDeficit < before.shortSequenceDeficit;
+ if (!useful) {
+ recordTraceEvaluation(
+ context,
+ result,
+ feasibleCandidate,
+ preferences,
+ before,
+ metrics,
+ [date],
+ undefined,
+ () => ({
+ ...supportingProtectionTraceDetails(before, metrics, preferences),
+ reason: reason(
+ 'quality-no-improvement',
+ 'This supporting placement does not improve a protected quality objective.',
+ ),
+ }),
+ );
+ continue;
+ }
+ const better = !bestMetrics || compareMetrics(metrics, bestMetrics) < 0;
+ const evaluation = recordTraceEvaluation(
+ context,
+ result,
+ feasibleCandidate,
+ preferences,
+ before,
+ metrics,
+ [date],
+ undefined,
+ () => {
+ const protectedDetails = supportingProtectionTraceDetails(
+ before,
+ metrics,
+ preferences,
+ );
+ const incumbentDetails = bestMetrics
+ ? qualityMetricTraceDetails(bestMetrics, metrics)
+ : undefined;
+ return {
+ technicalComparison: [
+ ...(protectedDetails.technicalComparison ?? []),
+ ...(incumbentDetails?.technicalComparison ?? []),
+ ],
+ firstDecisiveComparator: incumbentDetails?.firstDecisiveComparator,
+ reason: better
+ ? undefined
+ : reason(
+ 'quality-no-improvement',
+ 'This candidate did not outrank the current best supporting placement.',
+ ),
+ };
+ },
+ );
+ if (better) {
+ if (bestEvaluation)
+ updateTraceEvaluationReason(
+ context,
+ bestEvaluation,
+ reason(
+ 'quality-outperformed',
+ 'A later candidate became the best supporting placement.',
+ ),
+ );
best = feasibleCandidate;
bestMetrics = metrics;
bestEvaluation = evaluation;
@@ -3635,11 +5411,18 @@ function pruneRedundantSupportingPlacements(
const baseline = qualityMetrics(result, context, preferences);
for (const placement of [...result.placements.values()].filter((item) => item.supporting)) {
context.check();
+ recordTraceDecision(context, [placement.date]);
const candidate = cloneState(result);
candidate.placements.delete(placement.date);
const balance = candidate.balances.find((item) => item.id === placement.balanceId);
if (balance) balance.remaining++;
const metrics = qualityMetrics(candidate, context, preferences);
+ const removesWithoutQualityLoss =
+ metrics.unplacedAtRisk === baseline.unplacedAtRisk &&
+ metrics.holidayWeekValue >= baseline.holidayWeekValue &&
+ metrics.nonWorkingDayGain >= baseline.nonWorkingDayGain &&
+ metrics.longestWorkSequence <= baseline.longestWorkSequence &&
+ metrics.shortSequenceDeficit <= baseline.shortSequenceDeficit;
const evaluation = recordTraceEvaluation(
context,
result,
@@ -3649,14 +5432,15 @@ function pruneRedundantSupportingPlacements(
metrics,
[placement.date],
placement.date,
+ () => {
+ const details = supportingRemovalTraceDetails(baseline, metrics);
+ return {
+ ...details,
+ reason: removesWithoutQualityLoss ? undefined : details.reason,
+ };
+ },
);
- if (
- metrics.unplacedAtRisk === baseline.unplacedAtRisk &&
- metrics.holidayWeekValue >= baseline.holidayWeekValue &&
- metrics.nonWorkingDayGain >= baseline.nonWorkingDayGain &&
- metrics.longestWorkSequence <= baseline.longestWorkSequence &&
- metrics.shortSequenceDeficit <= baseline.shortSequenceDeficit
- ) {
+ if (removesWithoutQualityLoss) {
recordTraceState(context, candidate, evaluation);
result = candidate;
removed = true;
@@ -3679,15 +5463,21 @@ function compareMetrics(a: PlanningMetrics, b: PlanningMetrics): number {
return a.supportingPlacements - b.supportingPlacements;
}
-export type PlanningCheckpoint = (plan: PlanOutcome) => void;
+export type PlanningCheckpoint = (plan: PlanOutcome, state?: PlanningTraceState) => void;
export interface PlanningArtifacts {
explanation?: PlanningExplanation;
trace?: PlanningTrace;
finalState?: PlanningTraceState;
explanationError?: PlanningReason;
+ timings?: PlanningArtifactTimings;
}
export type PlanningArtifactsCallback = (artifacts: PlanningArtifacts) => void;
export type PlanningOutcomeCallback = (outcome: PlanningOutcome) => void;
+export type PlanningExplanationCallback = (
+ explanation: PlanningExplanation | undefined,
+ explanationError: PlanningReason | undefined,
+ finalState: PlanningTraceState | undefined,
+) => void;
/** @internal Worker-only synchronous core. Application code uses the public calculation context. */
export function calculatePlanningInternal(
@@ -3695,6 +5485,7 @@ export function calculatePlanningInternal(
checkpointCallback?: PlanningCheckpoint,
artifactsCallback?: PlanningArtifactsCallback,
outcomeCallback?: PlanningOutcomeCallback,
+ explanationCallback?: PlanningExplanationCallback,
): PlanningOutcome {
const source = runtimeRequest(request);
const normalized = normalizeRequest(source);
@@ -3749,7 +5540,11 @@ export function calculatePlanningInternal(
preferenceDemandByDate: new Map(),
anyPreferenceDemandByDate: new Map(),
traceTime: 0,
+ traceCaptureTime: 0,
+ traceTransferTime: 0,
traceReportedTime: 0,
+ observerTime: 0,
+ obligationSolutionBranches: new WeakMap(),
} as SearchContext;
const traceStarted = now();
const traceRecorder = artifactsCallback
@@ -3765,15 +5560,23 @@ export function calculatePlanningInternal(
},
planningStages(context),
new Set(workingDays),
+ typeof context.request.traceProgress === 'function',
)
: undefined;
- if (traceRecorder) context.traceTime += now() - traceStarted;
+ if (traceRecorder) {
+ const elapsed = now() - traceStarted;
+ context.traceTime += elapsed;
+ context.traceCaptureTime += elapsed;
+ }
context.trace = traceRecorder;
context.holidayWeeks = holidayWeekCandidates(context);
context.check = () => {
context.nodes++;
if (context.deadline <= 1 || context.nodes % 16 === 0) {
- if (now() - context.started - context.traceTime >= context.deadline)
+ if (
+ now() - context.started - context.traceTime - context.observerTime >=
+ context.deadline
+ )
throw new DeadlineReached();
}
};
@@ -3791,12 +5594,16 @@ export function calculatePlanningInternal(
let checkpoint: PlanOutcome | undefined;
let checkpointState: State | undefined;
const finish = (outcome: PlanningOutcome, explanationState?: State): PlanningOutcome => {
+ const planningFinishedAt = now();
+ const planningExcludedTime = context.traceTime + context.observerTime;
outcomeCallback?.(outcome);
+ const materializationStarted = now();
// Explanation materialization is post-outcome work and must not consume or
// inherit the planning deadline.
context.check = () => undefined;
let explanation: PlanningExplanation | undefined;
let explanationError: PlanningReason | undefined;
+ let finalState: PlanningTraceState | undefined;
try {
if (outcome.kind === 'Plan' && explanationState)
explanation = planExplanation(
@@ -3847,6 +5654,27 @@ export function calculatePlanningInternal(
error instanceof Error ? error.message : String(error),
);
}
+ try {
+ if (explanationState)
+ finalState = planningTraceState(explanationState, context.horizon);
+ } catch (error) {
+ explanationError ??= reason(
+ 'explanation-error',
+ error instanceof Error ? error.message : String(error),
+ );
+ }
+ let explanationDeliveryTime: number;
+ const explanationDeliveryStarted = now();
+ try {
+ explanationCallback?.(explanation, explanationError, finalState);
+ } catch (error) {
+ explanationError ??= reason(
+ 'explanation-error',
+ error instanceof Error ? error.message : String(error),
+ );
+ } finally {
+ explanationDeliveryTime = now() - explanationDeliveryStarted;
+ }
let trace: PlanningTrace | undefined;
try {
trace = traceRecorder?.finish(
@@ -3859,7 +5687,7 @@ export function calculatePlanningInternal(
? outcome.completedStages
: outcome.kind === 'PlanningIncomplete'
? outcome.completedStages
- : [],
+ : stages,
context.traceError ??
(outcome.kind === 'PlanningIncomplete' ? outcome.reasons[0] : undefined),
);
@@ -3881,23 +5709,75 @@ export function calculatePlanningInternal(
},
phases: [],
completedStages: [],
+ nonWorkingDates: dateRange(horizon.start, horizon.end)
+ .filter((date) => !workingDays.includes(date))
+ .map((date) => ({
+ date,
+ reason: reason('non-working', 'The date is a weekend or public holiday.'),
+ })),
error: traceError,
};
}
+ let finalTraceDeliveryTime = 0;
+ if (
+ context.trace === traceRecorder &&
+ typeof context.request.traceProgress === 'function'
+ ) {
+ const update = traceRecorder?.drainUpdate();
+ if (update) {
+ const deliveryStarted = now();
+ try {
+ (context.request.traceProgress as PlanningTraceProgressCallback)(
+ context.traceTime,
+ update,
+ );
+ } catch (error) {
+ const traceError = reason(
+ 'trace-error',
+ error instanceof Error ? error.message : String(error),
+ );
+ trace = trace ? { ...trace, status: 'failed', error: traceError } : undefined;
+ context.traceError = traceError;
+ } finally {
+ finalTraceDeliveryTime = now() - deliveryStarted;
+ context.traceTime += finalTraceDeliveryTime;
+ context.traceTransferTime += finalTraceDeliveryTime;
+ }
+ }
+ }
+ const traceMaterializationMs = Math.max(
+ 0,
+ now() - materializationStarted - explanationDeliveryTime - finalTraceDeliveryTime,
+ );
artifactsCallback?.({
explanation,
trace,
- finalState: explanationState
- ? planningTraceState(explanationState, context.horizon)
- : undefined,
+ finalState,
explanationError,
+ timings: {
+ planningWorkMs: Math.max(0, planningFinishedAt - started - planningExcludedTime),
+ traceCaptureMs: context.traceCaptureTime,
+ traceMaterializationMs,
+ traceTransferMs: context.traceTransferTime,
+ },
});
return outcome;
};
const publishCheckpoint = (): void => {
checkpoint = makePlan(state, context, preferences, stages);
checkpointState = cloneState(state);
- checkpointCallback?.(checkpoint);
+ if (checkpointCallback) {
+ let checkpointSnapshot: PlanningTraceState;
+ const captureStarted = now();
+ try {
+ checkpointSnapshot = planningTraceState(checkpointState, context.horizon);
+ } finally {
+ const captureTime = now() - captureStarted;
+ context.traceTime += captureTime;
+ context.traceCaptureTime += captureTime;
+ }
+ observeCallback(context, () => checkpointCallback(checkpoint!, checkpointSnapshot));
+ }
};
try {
markProgress(context, 'validation');
@@ -3941,36 +5821,65 @@ export function calculatePlanningInternal(
obligationSolutionLimit,
);
if (!obligationSolutions.length) {
+ stages.push('obligations');
return finish(obligationConflict(obligationRequirements, context));
}
const preferenceRules = normalized.rules.filter(
(item) => item.enabled && item.force === 'preference',
);
- let bestPreview: {
- obligationState: State;
- state: State;
- preferences: PreferenceResult[];
- metrics: PlanningMetrics;
- } | null = null;
+ let bestPreview: ObligationPreview | null = null;
for (const obligationSolution of obligationSolutions) {
context.check();
- const atRiskState = placeAtRisk(cloneState(obligationSolution), context);
- const preview = applyPreferenceRules(atRiskState, preferenceRules, context);
- const previewState = finalizeEarnedState(preview.state, context);
- if (!previewState) continue;
- const candidate = {
- obligationState: obligationSolution,
- state: previewState,
- preferences: preview.preferences,
- metrics: qualityMetrics(previewState, context, preview.preferences),
- };
- if (!bestPreview || compareObligationPreviews(candidate, bestPreview) < 0)
- bestPreview = candidate;
+ const obligationPath = context.obligationSolutionBranches.get(obligationSolution) ?? [];
+ const previousBranchActive = context.traceBranchActive;
+ const previousBranchParent = context.traceBranchParentId;
+ context.traceBranchActive = true;
+ context.traceBranchParentId = obligationPath.at(-1)?.decisionId;
+ try {
+ const atRiskState = placeAtRisk(cloneState(obligationSolution), context);
+ const preview = applyPreferenceRules(atRiskState, preferenceRules, context);
+ const previewState = finalizeEarnedState(preview.state, context);
+ if (!previewState) continue;
+ const candidate = {
+ obligationState: obligationSolution,
+ state: previewState,
+ preferences: preview.preferences,
+ metrics: qualityMetrics(previewState, context, preview.preferences),
+ };
+ const compared: number | undefined = bestPreview
+ ? compareObligationPreviews(candidate, bestPreview)
+ : undefined;
+ const branch: ObligationBranchReference | undefined =
+ compared !== undefined
+ ? recordTraceBranchDecision(
+ context,
+ previewState,
+ previewState,
+ context.traceBranchParentId,
+ [],
+ () => obligationPreviewComparisonFacts(candidate, bestPreview!),
+ )
+ : undefined;
+ const previewCandidate: ObligationPreview = {
+ ...candidate,
+ previewBranch: branch,
+ };
+ if (!bestPreview || compared! < 0) bestPreview = previewCandidate;
+ } finally {
+ context.traceBranchActive = previousBranchActive;
+ context.traceBranchParentId = previousBranchParent;
+ }
}
if (!bestPreview) {
+ stages.push('obligations');
return finish(obligationConflict(obligationRequirements, context));
}
state = bestPreview.obligationState;
+ chooseObligationBranches(state, context);
+ if (bestPreview.previewBranch)
+ observeTrace(context, (trace) =>
+ trace.chooseEvaluation(bestPreview!.previewBranch!.candidate),
+ );
stages.push('obligations');
publishCheckpoint();
markProgress(context, 'at-risk');
@@ -4048,26 +5957,129 @@ export function calculatePlanningInternal(
markProgress(context, 'complete');
return finish(checkpoint!, state);
} catch (error) {
- if (!(error instanceof DeadlineReached)) throw error;
- const reasons = [
- reason(
- 'deadline',
- `Planning stopped after ${context.deadline}ms before all quality stages completed.`,
- ),
- ];
+ const interruption =
+ error instanceof DeadlineReached
+ ? reason(
+ 'deadline',
+ `Planning stopped after ${context.deadline}ms before all quality stages completed.`,
+ )
+ : reason('planner-error', error instanceof Error ? error.message : String(error));
return finish(
{
kind: 'PlanningIncomplete',
incumbent: checkpoint,
- completedStages: checkpoint?.completedStages ?? [],
- lastCompletedStage: checkpoint?.lastCompletedStage,
- reasons,
+ completedStages: [...stages],
+ lastCompletedStage: stages[stages.length - 1],
+ reasons: [interruption],
},
checkpointState,
);
}
}
+/** Materialize an incumbent explanation from a delivered checkpoint without rerunning planning. */
+export function explainIncompleteCheckpoint(
+ request: unknown,
+ incumbent: PlanOutcome,
+ checkpoint: PlanningTraceState,
+ interruption: PlanningReason,
+): PlanningArtifacts {
+ try {
+ const source = runtimeRequest(request);
+ const normalized = normalizeRequest(source);
+ if (normalized.reasons.length || !normalized.horizon)
+ throw new Error('The checkpoint explanation request is invalid.');
+ const horizon = normalized.horizon;
+ const holidays = holidaySet(horizon);
+ const calendarDays = dateRange(addDays(horizon.start, -400), addDays(horizon.end, 400));
+ const calendarIndex = new Map(calendarDays.map((date, index) => [date, index]));
+ const workingCalendar = new Set(calendarDays.filter((date) => isWorking(date, holidays)));
+ const workingPrefix = [0];
+ for (const date of calendarDays)
+ workingPrefix.push(
+ workingPrefix[workingPrefix.length - 1] + Number(workingCalendar.has(date)),
+ );
+ const context = {
+ horizon,
+ holidayWeeks: [],
+ holidays,
+ calendarDays,
+ calendarIndex,
+ workingCalendar,
+ workingPrefix,
+ workingDays: dateRange(horizon.start, horizon.end).filter((date) =>
+ isWorking(date, holidays),
+ ),
+ initialBalances: normalized.balances,
+ rules: normalized.rules,
+ deadline: Number.POSITIVE_INFINITY,
+ started: 0,
+ request: source,
+ nodes: 0,
+ check: () => undefined,
+ reservedTypes: new Set(),
+ sharedTypesByDate: new Map>(),
+ protectedTypesByDate: new Map>(),
+ preferenceDemandByDate: new Map(),
+ anyPreferenceDemandByDate: new Map(),
+ traceTime: 0,
+ traceCaptureTime: 0,
+ traceTransferTime: 0,
+ traceReportedTime: 0,
+ observerTime: 0,
+ obligationSolutionBranches: new WeakMap(),
+ } as SearchContext;
+ context.holidayWeeks = holidayWeekCandidates(context);
+ const state: State = {
+ balances: checkpoint.balances.map((balance) => ({ ...balance })),
+ placements: new Map(
+ checkpoint.placements.map((placement) => [
+ placement.date,
+ { ...placement, ruleIds: new Set(placement.ruleIds) },
+ ]),
+ ),
+ earnedActivations: checkpoint.earnedActivations.map((activation) => ({
+ ...activation,
+ dates: [...activation.dates],
+ })),
+ };
+ const explanation = planExplanation(
+ state,
+ context,
+ incumbent.placements,
+ incumbent.timeOffPeriods,
+ incumbent.preferences,
+ incumbent.remainingBalances,
+ incumbent.metrics,
+ );
+ const uncompletedStages = planningStages(context).filter(
+ (stage) => !incumbent.completedStages.includes(stage),
+ );
+ return {
+ explanation:
+ uncompletedStages.length === 0
+ ? explanation
+ : {
+ ...explanation,
+ incomplete: {
+ kind: 'IncompleteExplanation',
+ reason: interruption,
+ completedStages: [...incumbent.completedStages],
+ uncompletedStages,
+ },
+ },
+ finalState: planningTraceState(state, horizon),
+ };
+ } catch (error) {
+ return {
+ explanationError: reason(
+ 'explanation-error',
+ error instanceof Error ? error.message : String(error),
+ ),
+ };
+ }
+}
+
function counterfactualReason(
code: CounterfactualReasonCode,
message: string,
@@ -4075,6 +6087,130 @@ function counterfactualReason(
return { code, message };
}
+function counterfactualContext(
+ source: RuntimePlanningRequest,
+ normalized: ReturnType,
+ horizon: { start: string; end: string },
+): SearchContext {
+ const holidays = holidaySet(horizon);
+ const calendarDays = dateRange(addDays(horizon.start, -400), addDays(horizon.end, 400));
+ const calendarIndex = new Map(calendarDays.map((date, index) => [date, index]));
+ const workingCalendar = new Set(calendarDays.filter((date) => isWorking(date, holidays)));
+ const workingPrefix = [0];
+ for (const date of calendarDays)
+ workingPrefix.push(
+ workingPrefix[workingPrefix.length - 1] + Number(workingCalendar.has(date)),
+ );
+ const context = {
+ horizon,
+ holidayWeeks: [],
+ holidays,
+ calendarDays,
+ calendarIndex,
+ workingCalendar,
+ workingPrefix,
+ workingDays: dateRange(horizon.start, horizon.end).filter((date) =>
+ isWorking(date, holidays),
+ ),
+ initialBalances: normalized.balances,
+ rules: normalized.rules,
+ deadline: Number.POSITIVE_INFINITY,
+ started: 0,
+ request: source,
+ nodes: 0,
+ check: () => undefined,
+ reservedTypes: new Set(),
+ sharedTypesByDate: new Map>(),
+ protectedTypesByDate: new Map>(),
+ preferenceDemandByDate: new Map(),
+ anyPreferenceDemandByDate: new Map(),
+ traceTime: 0,
+ traceCaptureTime: 0,
+ traceTransferTime: 0,
+ traceReportedTime: 0,
+ observerTime: 0,
+ obligationSolutionBranches: new WeakMap(),
+ } as SearchContext;
+ context.holidayWeeks = holidayWeekCandidates(context);
+ return context;
+}
+
+function stateFromPlanningTrace(state: PlanningTraceState): State {
+ return {
+ balances: state.balances.map((balance) => ({ ...balance })),
+ placements: new Map(
+ state.placements.map((placement) => [
+ placement.date,
+ { ...placement, ruleIds: new Set(placement.ruleIds) },
+ ]),
+ ),
+ earnedActivations: state.earnedActivations.map((activation) => ({
+ ...activation,
+ dates: [...activation.dates],
+ })),
+ };
+}
+
+/** Explain basic date eligibility against a historical source state without validating future obligations. */
+export function historicalCounterfactualReasons(
+ request: unknown,
+ sourceState: PlanningTraceState,
+ selected: LeavePlacement,
+ options?: CounterfactualBaselineOptions,
+): readonly HistoricalCounterfactualReason[] {
+ const source = runtimeRequest(request);
+ const normalized = normalizeRequest(source);
+ const horizon = normalized.horizon ?? sourceState.horizon;
+ const context = counterfactualContext(source, normalized, horizon);
+ const baseline = stateFromPlanningTrace(sourceState);
+ const removeDate = options ? options.removeDate : selected.date;
+ const removed = removeDate ? baseline.placements.get(removeDate) : undefined;
+ if (removeDate) baseline.placements.delete(removeDate);
+ const balance = baseline.balances.find((candidate) => candidate.id === selected.balanceId);
+ if (balance && removed?.balanceId === selected.balanceId) balance.remaining++;
+ const results: HistoricalCounterfactualReason[] = [];
+ for (const date of dateRange(horizon.start, horizon.end)) {
+ let ineligible: (PlanningReason & { code: CounterfactualReasonCode }) | undefined;
+ if (!isWorking(date, context.holidays))
+ ineligible = counterfactualReason(
+ 'non-working',
+ 'The date is a weekend or public holiday.',
+ );
+ else if (baseline.placements.has(date))
+ ineligible = counterfactualReason(
+ 'occupied',
+ 'Another placement already uses this date.',
+ );
+ else if (!balance || balance.remaining <= 0)
+ ineligible = counterfactualReason(
+ 'unavailable',
+ 'The selected leave entitlement is unavailable.',
+ );
+ else if (compareDate(balance.availableFrom, date) > 0)
+ ineligible = counterfactualReason(
+ 'unavailable',
+ 'The selected entitlement is not available yet.',
+ );
+ else if (compareDate(date, balance.expiryDate) > 0)
+ ineligible = counterfactualReason(
+ 'expired',
+ 'The selected entitlement has expired by this date.',
+ );
+ else if (balance.type !== selected.type)
+ ineligible = counterfactualReason(
+ 'type-incompatible',
+ 'The date cannot host this leave type.',
+ );
+ else if (!counterfactualRuleValid(date, selected.type, selected.ruleIds, context, baseline))
+ ineligible = counterfactualReason(
+ 'rule-invalid',
+ 'The date does not preserve the placement rules.',
+ );
+ if (ineligible) results.push({ date, reason: ineligible });
+ }
+ return results;
+}
+
function sameObjectiveValue(a: number | string, b: number | string): boolean {
return a === b;
}
@@ -4222,56 +6358,9 @@ export function analyzeCounterfactualInternal(
const source = runtimeRequest(request);
const normalized = normalizeRequest(source);
const horizon = normalized.horizon ?? plan.horizon;
- const holidays = holidaySet(horizon);
- const calendarDays = dateRange(addDays(horizon.start, -400), addDays(horizon.end, 400));
- const calendarIndex = new Map(calendarDays.map((date, index) => [date, index]));
- const workingCalendar = new Set(calendarDays.filter((date) => isWorking(date, holidays)));
- const workingPrefix = [0];
- for (const date of calendarDays)
- workingPrefix.push(
- workingPrefix[workingPrefix.length - 1] + Number(workingCalendar.has(date)),
- );
- const context = {
- horizon,
- holidays,
- holidayWeeks: [],
- calendarDays,
- calendarIndex,
- workingCalendar,
- workingPrefix,
- workingDays: dateRange(horizon.start, horizon.end).filter((date) =>
- isWorking(date, holidays),
- ),
- initialBalances: normalized.balances,
- rules: normalized.rules,
- deadline: Number.POSITIVE_INFINITY,
- started: 0,
- request: source,
- nodes: 0,
- check: () => undefined,
- reservedTypes: new Set(),
- sharedTypesByDate: new Map>(),
- protectedTypesByDate: new Map>(),
- preferenceDemandByDate: new Map(),
- anyPreferenceDemandByDate: new Map(),
- traceTime: 0,
- traceReportedTime: 0,
- } as SearchContext;
- context.holidayWeeks = holidayWeekCandidates(context);
+ const context = counterfactualContext(source, normalized, horizon);
const finalState: State = finalStateSnapshot
- ? {
- balances: finalStateSnapshot.balances.map((balance) => ({ ...balance })),
- placements: new Map(
- finalStateSnapshot.placements.map((placement) => [
- placement.date,
- { ...placement, ruleIds: new Set(placement.ruleIds) },
- ]),
- ),
- earnedActivations: finalStateSnapshot.earnedActivations.map((activation) => ({
- ...activation,
- dates: [...activation.dates],
- })),
- }
+ ? stateFromPlanningTrace(finalStateSnapshot)
: (() => {
const balances = normalized.balances.map((balance) => ({
...balance,
@@ -4320,11 +6409,11 @@ export function analyzeCounterfactualInternal(
const beforeState = options?.compareOriginalState ? comparisonState : baseline;
const beforePreferences = preferenceResultsForState(beforeState, context);
const beforeMetrics = qualityMetrics(beforeState, context, beforePreferences);
- const before = objectiveVector(beforeMetrics, beforePreferences, beforeState);
+ const before = objectiveVector(beforeMetrics, beforePreferences, beforeState, context);
const results: CounterfactualDateResult[] = [];
for (const date of dateRange(horizon.start, horizon.end)) {
let ineligible: (PlanningReason & { code: CounterfactualReasonCode }) | undefined;
- if (!isWorking(date, holidays))
+ if (!isWorking(date, context.holidays))
ineligible = counterfactualReason(
'non-working',
'The date is a weekend or public holiday.',
@@ -4382,7 +6471,26 @@ export function analyzeCounterfactualInternal(
continue;
}
candidate.placements.get(date)!.supporting = selected.supporting;
- if (!counterfactualObligationsValid(candidate, context, comparisonState)) {
+ 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,
+ );
+ }
+ const finalized = finalizeEarnedState(candidate, context);
+ if (!finalized) {
+ results.push({
+ date,
+ status: 'ineligible',
+ reason: counterfactualReason(
+ 'unavailable',
+ 'This date would make generated leave unavailable until its producing period ends.',
+ ),
+ });
+ continue;
+ }
+ if (!counterfactualObligationsValid(finalized, context, comparisonState)) {
results.push({
date,
status: 'ineligible',
@@ -4393,9 +6501,9 @@ export function analyzeCounterfactualInternal(
});
continue;
}
- const afterPreferences = preferenceResultsForState(candidate, context);
- const afterMetrics = qualityMetrics(candidate, context, afterPreferences);
- const after = objectiveVector(afterMetrics, afterPreferences, candidate);
+ const afterPreferences = preferenceResultsForState(finalized, context);
+ const afterMetrics = qualityMetrics(finalized, context, afterPreferences);
+ const after = objectiveVector(afterMetrics, afterPreferences, finalized, context);
const comparison = objectiveComparison(before, after);
results.push({ date, status: 'eligible', comparison });
}
diff --git a/src/app/services/leave-planner.service.spec.ts b/src/app/services/leave-planner.service.spec.ts
index 834f811..ede3de5 100644
--- a/src/app/services/leave-planner.service.spec.ts
+++ b/src/app/services/leave-planner.service.spec.ts
@@ -1,10 +1,20 @@
import { vi } from 'vitest';
import {
+ applyReplayDeltas,
PlanningOutcome,
PlanningRequest,
createPlanningCalculation,
planLeave,
} from './leave-planner.service';
+import type {
+ CounterfactualAnalysis,
+ HistoricalEligibilityReason,
+ PlanOutcome,
+ PlanningReason,
+ PlanningTraceState,
+ PlanningTraceUpdate,
+} from './leave-planner.service';
+import type { CounterfactualBaselineOptions } from './leave-planner.core';
const horizon = { start: '2024-01-01', end: '2025-01-01' };
const balance = (
@@ -1033,19 +1043,23 @@ describe('planLeave', () => {
}
}
vi.stubGlobal('Worker', ZeroBudgetWorker);
+ vi.useFakeTimers();
try {
- const result = await planLeave({ horizon, balances: [], rules: [], deadlineMs: 0 });
+ const resultPromise = planLeave({ horizon, balances: [], rules: [], deadlineMs: 0 });
+ await vi.advanceTimersByTimeAsync(30_000);
+ const result = await resultPromise;
expect(result.kind).toBe('PlanningIncomplete');
expect(started).toBe(true);
expect(terminated).toBe(true);
} finally {
+ vi.useRealTimers();
vi.unstubAllGlobals();
}
});
});
describe('planLeave transport', () => {
- it('terminates an isolated calculation at its wall deadline with its incumbent', async () => {
+ it('terminates an isolated worker that misses the liveness deadline with its incumbent', async () => {
const incumbent = isPlan(
await runInNonBrowser({
horizon,
@@ -1069,25 +1083,173 @@ describe('planLeave transport', () => {
}
let terminated = false;
vi.stubGlobal('Worker', HangingWorker);
+ vi.useFakeTimers();
try {
- const result = await planLeave({
+ const resultPromise = planLeave({
horizon,
balances: [balance('cp', 'cp', 2)],
rules: [],
deadlineMs: 10,
});
+ await vi.advanceTimersByTimeAsync(30_000);
+ const result = await resultPromise;
expect(result.kind).toBe('PlanningIncomplete');
if (result.kind === 'PlanningIncomplete') {
expect(result.incumbent?.kind).toBe('Plan');
expect(result.lastCompletedStage).toBe('dates');
+ expect(result.reasons[0].code).toBe('worker-error');
}
expect(terminated).toBe(true);
} finally {
+ vi.useRealTimers();
vi.unstubAllGlobals();
}
});
+ it('keeps the delivered checkpoint prefix and recovers its explanation after watchdog failure', async () => {
+ const core = await import('./leave-planner.core');
+ const request = {
+ horizon: { start: '2024-01-01', end: '2024-01-10' },
+ balances: [balance('cp', 'cp', 1)],
+ rules: [],
+ };
+ let checkpoint: PlanOutcome | undefined;
+ let checkpointState: PlanningTraceState | undefined;
+ const expected = core.calculatePlanningInternal(request, (plan, state) => {
+ checkpoint = plan;
+ checkpointState = state;
+ });
+ expect(expected.kind).toBe('Plan');
+ if (expected.kind !== 'Plan' || !checkpointState) return;
+ checkpoint = {
+ ...expected,
+ completedStages: ['validation', 'obligations', 'at-risk'],
+ lastCompletedStage: 'at-risk',
+ };
+ const initialState: PlanningTraceState = {
+ horizon: request.horizon,
+ balances: [
+ {
+ id: 'cp',
+ type: 'cp',
+ quantity: 1,
+ remaining: 1,
+ availableFrom: '2024-01-01',
+ expiryDate: '2024-12-31',
+ earned: false,
+ },
+ ],
+ placements: [],
+ earnedActivations: [],
+ };
+ const prefix: PlanningTraceUpdate = {
+ initialState,
+ operations: [
+ {
+ kind: 'phase',
+ phase: {
+ id: 'phase:1',
+ name: 'dates',
+ completed: false,
+ status: 'interrupted',
+ },
+ },
+ {
+ kind: 'decision',
+ phaseId: 'phase:1',
+ decision: { id: 'decision:1', action: 'choose', dates: ['2024-01-02'] },
+ },
+ {
+ kind: 'candidate',
+ phaseId: 'phase:1',
+ decisionId: 'decision:1',
+ index: 0,
+ mode: 'append',
+ candidate: { status: 'evaluated-rejected', date: '2024-01-02' },
+ },
+ ],
+ };
+ const workers: Array<{ terminated: boolean }> = [];
+ class RecoveringWorker {
+ onmessage: ((event: MessageEvent) => void) | null = null;
+ onerror: (() => void) | null = null;
+ terminated = false;
+
+ constructor() {
+ workers.push(this);
+ }
+
+ postMessage(
+ message:
+ | { type: 'plan' }
+ | {
+ type: 'explain-checkpoint';
+ request: unknown;
+ plan: PlanOutcome;
+ state: PlanningTraceState;
+ interruption: PlanningReason;
+ },
+ ): void {
+ if (message.type === 'plan') {
+ queueMicrotask(() => {
+ this.onmessage?.({
+ data: { type: 'checkpoint', plan: checkpoint, state: checkpointState },
+ } as MessageEvent);
+ this.onmessage?.({
+ data: { type: 'trace-update', totalMs: 0, update: prefix },
+ } as MessageEvent);
+ });
+ return;
+ }
+ if (message.type === 'explain-checkpoint')
+ queueMicrotask(() => {
+ const recovered = core.explainIncompleteCheckpoint(
+ message.request,
+ message.plan,
+ message.state,
+ message.interruption,
+ );
+ this.onmessage?.({
+ data: { type: 'explanation', ...recovered },
+ } as MessageEvent);
+ });
+ }
+
+ terminate(): void {
+ this.terminated = true;
+ }
+ }
+ const browserWindow = globalThis.window;
+ vi.stubGlobal('window', {});
+ vi.stubGlobal('Worker', RecoveringWorker);
+ vi.useFakeTimers();
+ try {
+ const calculation = createPlanningCalculation({ ...request, deadlineMs: 20 });
+ await vi.runAllTicks();
+ await vi.advanceTimersByTimeAsync(30_000);
+ const outcome = await calculation.outcome;
+ expect(outcome.kind).toBe('PlanningIncomplete');
+ if (outcome.kind === 'PlanningIncomplete')
+ expect(outcome.incumbent).toEqual(checkpoint);
+ const trace = await calculation.trace;
+ expect(trace?.status).toBe('failed');
+ expect(trace?.error?.code).toBe('worker-error');
+ expect(trace?.phases[0].decisions[0].candidates).toHaveLength(1);
+ const explanation = await calculation.explanation;
+ expect(explanation?.kind).toBe('PlanExplanation');
+ if (explanation?.kind === 'PlanExplanation')
+ expect(explanation.incomplete?.reason.code).toBe('worker-error');
+ expect((await calculation.explanationError)?.code).toBe('worker-error');
+ expect(workers).toHaveLength(2);
+ expect(workers.every((worker) => worker.terminated)).toBe(true);
+ } finally {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ vi.stubGlobal('window', browserWindow);
+ }
+ });
+
it('reports no completed stage when an isolated worker never checkpoints', async () => {
class SilentWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
@@ -1100,25 +1262,30 @@ describe('planLeave transport', () => {
terminate(): void {}
}
vi.stubGlobal('Worker', SilentWorker);
+ vi.useFakeTimers();
try {
- const result = await planLeave({
+ const resultPromise = planLeave({
horizon,
balances: [balance('cp', 'cp', 2)],
rules: [],
deadlineMs: 10,
});
+ await vi.advanceTimersByTimeAsync(30_000);
+ const result = await resultPromise;
expect(result.kind).toBe('PlanningIncomplete');
if (result.kind === 'PlanningIncomplete') {
expect(result.incumbent).toBeUndefined();
expect(result.completedStages).toEqual([]);
expect(result.lastCompletedStage).toBeUndefined();
+ expect(result.reasons[0].code).toBe('worker-error');
}
} finally {
+ vi.useRealTimers();
vi.unstubAllGlobals();
}
});
- it('does not charge reported trace instrumentation time to the planning deadline', async () => {
+ it('does not let excluded trace transfer time consume worker liveness before the outcome', async () => {
const expected = isPlan(
await runInNonBrowser({ horizon, balances: [balance('cp', 'cp', 1)], rules: [] }),
);
@@ -1127,15 +1294,18 @@ describe('planLeave transport', () => {
onerror: (() => void) | null = null;
postMessage(): void {
- this.onmessage?.({ data: { type: 'trace-overhead', totalMs: 30 } } as MessageEvent);
+ this.onmessage?.({ data: { type: 'trace-overhead', totalMs: 0 } } as MessageEvent);
setTimeout(() => {
+ this.onmessage?.({
+ data: { type: 'trace-overhead', totalMs: 300 },
+ } as MessageEvent);
this.onmessage?.({
data: { type: 'result', outcome: expected },
} as MessageEvent);
this.onmessage?.({
data: { type: 'artifacts', artifacts: {} },
} as MessageEvent);
- }, 15);
+ }, 300);
}
terminate(): void {}
@@ -1147,7 +1317,7 @@ describe('planLeave transport', () => {
horizon,
balances: [balance('cp', 'cp', 1)],
rules: [],
- deadlineMs: 10,
+ deadlineMs: 100,
}),
).toEqual(expected);
} finally {
@@ -1241,6 +1411,98 @@ describe('planLeave transport', () => {
});
describe('planning calculation context', () => {
+ it('replays full balance metadata when an existing balance changes', () => {
+ const state = {
+ balances: [
+ {
+ id: 'earned:producer:2024-01-04',
+ type: 'rtt' as const,
+ quantity: 0,
+ remaining: 0,
+ availableFrom: '2024-01-01',
+ expiryDate: '2024-12-31',
+ earned: false,
+ },
+ ],
+ placements: new Map(),
+ earnedActivations: [],
+ };
+
+ applyReplayDeltas(state, [
+ {
+ kind: 'balance-changed',
+ balanceId: 'earned:producer:2024-01-04',
+ remainingBefore: 0,
+ remainingAfter: 1,
+ balance: {
+ id: 'earned:producer:2024-01-04',
+ type: 'rtt',
+ quantity: 1,
+ remaining: 1,
+ availableFrom: '2024-01-05',
+ expiryDate: '2024-12-31',
+ earned: true,
+ sourceRuleId: 'producer',
+ },
+ },
+ ]);
+
+ expect(state.balances[0]).toEqual({
+ id: 'earned:producer:2024-01-04',
+ type: 'rtt',
+ quantity: 1,
+ remaining: 1,
+ availableFrom: '2024-01-05',
+ expiryDate: '2024-12-31',
+ earned: true,
+ sourceRuleId: 'producer',
+ });
+ });
+
+ it('applies placement delta balance changes to the replayed balance', () => {
+ const state = {
+ balances: [
+ {
+ id: 'cp',
+ type: 'cp' as const,
+ quantity: 1,
+ remaining: 1,
+ availableFrom: '2024-01-01',
+ expiryDate: '2024-12-31',
+ earned: false,
+ },
+ ],
+ placements: new Map(),
+ earnedActivations: [],
+ };
+
+ applyReplayDeltas(state, [
+ {
+ kind: 'placement-added',
+ balanceId: 'cp',
+ remainingBefore: 1,
+ remainingAfter: 0,
+ placement: {
+ date: '2024-01-04',
+ balanceId: 'cp',
+ type: 'cp',
+ ruleIds: [],
+ supporting: false,
+ },
+ },
+ ]);
+
+ expect(state.balances[0]).toEqual({
+ id: 'cp',
+ type: 'cp',
+ quantity: 1,
+ remaining: 0,
+ availableFrom: '2024-01-01',
+ expiryDate: '2024-12-31',
+ earned: false,
+ });
+ });
+
it('keeps the planning outcome identical when trace instrumentation is enabled', async () => {
const core = await import('./leave-planner.core');
const request = {
@@ -1280,6 +1542,25 @@ describe('planning calculation context', () => {
}
});
+ it('lets validation reject an invalid Date in the submitted horizon', async () => {
+ const browserWindow = globalThis.window;
+ vi.stubGlobal('window', undefined);
+ try {
+ const calculation = createPlanningCalculation({
+ horizon: { start: new Date(Number.NaN), end: '2025-01-01' },
+ balances: [],
+ rules: [],
+ });
+ const outcome = await calculation.outcome;
+ expect(outcome.kind).toBe('InvalidInput');
+ if (outcome.kind === 'InvalidInput')
+ expect(outcome.reasons[0].code).toBe('horizon-start');
+ expect(calculation.snapshot.horizon.start).toBe('Invalid Date');
+ } finally {
+ vi.stubGlobal('window', browserWindow);
+ }
+ });
+
it('materializes a chronological explanation after the unchanged outcome', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
@@ -1670,6 +1951,168 @@ describe('planning calculation context', () => {
}
});
+ it('keeps counterfactual worker failures local and allows a clean retry', async () => {
+ const request = {
+ horizon: { start: '2024-01-01', end: '2024-01-10' },
+ balances: [balance('cp', 'cp', 1)],
+ rules: [],
+ };
+ const expected = isPlan(await runInNonBrowser(request));
+ const selected = expected.placements[0];
+ const trace = {
+ status: 'ready' as const,
+ initialState: {
+ horizon: expected.horizon,
+ balances: [],
+ placements: [],
+ earnedActivations: [],
+ },
+ phases: [],
+ completedStages: expected.completedStages,
+ };
+ const response: CounterfactualAnalysis = {
+ kind: 'Counterfactual',
+ calculationId: 'transport-test',
+ placement: selected,
+ hypothetical: true,
+ baseline: { objectives: [] },
+ results: [],
+ };
+ let counterfactualCalls = 0;
+ class RetriableWorker {
+ onmessage: ((event: MessageEvent) => void) | null = null;
+ onerror: (() => void) | null = null;
+ terminated = false;
+
+ postMessage(message: { type: string }): void {
+ if (message.type === 'plan') {
+ queueMicrotask(() => {
+ this.onmessage?.({
+ data: { type: 'result', outcome: expected },
+ } as MessageEvent);
+ this.onmessage?.({
+ data: {
+ type: 'explanation',
+ explanation: { kind: 'PlanExplanation', placements: [] },
+ },
+ } as MessageEvent);
+ this.onmessage?.({
+ data: {
+ type: 'artifacts-start',
+ artifacts: {},
+ trace,
+ },
+ } as MessageEvent);
+ this.onmessage?.({ data: { type: 'artifacts-complete' } } as MessageEvent);
+ });
+ return;
+ }
+ counterfactualCalls++;
+ queueMicrotask(() => {
+ if (counterfactualCalls === 1)
+ this.onmessage?.({
+ data: {
+ type: 'error',
+ code: 'counterfactual-error',
+ message: 'Counterfactual analysis failed locally.',
+ },
+ } as MessageEvent);
+ else
+ this.onmessage?.({
+ data: { type: 'counterfactual-result', analysis: response },
+ } as MessageEvent);
+ });
+ }
+
+ terminate(): void {
+ this.terminated = true;
+ }
+ }
+ const browserWindow = globalThis.window;
+ vi.stubGlobal('window', {});
+ vi.stubGlobal('Worker', RetriableWorker);
+ try {
+ const calculation = createPlanningCalculation(request);
+ expect(await calculation.outcome).toEqual(expected);
+ await calculation.trace;
+ await expect(calculation.analyzeCounterfactual(selected)).rejects.toThrow(
+ 'Counterfactual analysis failed locally.',
+ );
+ const analysis = await calculation.analyzeCounterfactual(selected);
+ expect(analysis.kind).toBe('Counterfactual');
+ expect(analysis.calculationId).toBe(calculation.id);
+ expect(counterfactualCalls).toBe(2);
+ } finally {
+ vi.unstubAllGlobals();
+ vi.stubGlobal('window', browserWindow);
+ }
+ });
+
+ it('terminates an active counterfactual worker when a newer calculation starts', async () => {
+ const request = {
+ horizon: { start: '2024-01-01', end: '2024-01-10' },
+ balances: [balance('cp', 'cp', 1)],
+ rules: [],
+ };
+ const expected = isPlan(await runInNonBrowser(request));
+ const trace = {
+ status: 'ready' as const,
+ initialState: {
+ horizon: expected.horizon,
+ balances: [],
+ placements: [],
+ earnedActivations: [],
+ },
+ phases: [],
+ completedStages: expected.completedStages,
+ };
+ const workers: Array<{ terminated: boolean }> = [];
+ class SupersededCounterfactualWorker {
+ onmessage: ((event: MessageEvent) => void) | null = null;
+ onerror: (() => void) | null = null;
+ terminated = false;
+
+ constructor() {
+ workers.push(this);
+ }
+
+ postMessage(message: { type: string }): void {
+ if (message.type !== 'plan') return;
+ queueMicrotask(() => {
+ this.onmessage?.({
+ data: { type: 'result', outcome: expected },
+ } as MessageEvent);
+ this.onmessage?.({
+ data: { type: 'artifacts-start', artifacts: {}, trace },
+ } as MessageEvent);
+ this.onmessage?.({ data: { type: 'artifacts-complete' } } as MessageEvent);
+ });
+ }
+
+ terminate(): void {
+ this.terminated = true;
+ }
+ }
+ const browserWindow = globalThis.window;
+ vi.stubGlobal('window', {});
+ vi.stubGlobal('Worker', SupersededCounterfactualWorker);
+ try {
+ const previous = createPlanningCalculation(request);
+ await previous.outcome;
+ await previous.trace;
+ const pendingAnalysis = previous.analyzeCounterfactual(expected.placements[0]);
+ await vi.waitFor(() => expect(workers).toHaveLength(2));
+ const latest = createPlanningCalculation(request);
+ const stale = await pendingAnalysis;
+ expect(stale.kind).toBe('CounterfactualStale');
+ expect(workers[1].terminated).toBe(true);
+ expect((await latest.outcome).kind).toBe('Plan');
+ } finally {
+ vi.unstubAllGlobals();
+ vi.stubGlobal('window', browserWindow);
+ }
+ });
+
it('keeps earned-entitlement availability and provenance in counterfactuals', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
@@ -1788,6 +2231,358 @@ describe('planning calculation context', () => {
}
});
+ it('keeps source-state ineligibility separate from unexamined trace history', async () => {
+ const browserWindow = globalThis.window;
+ vi.stubGlobal('window', undefined);
+ try {
+ const calculation = createPlanningCalculation({
+ horizon: { start: '2024-01-01', end: '2024-01-11' },
+ balances: [
+ {
+ ...balance('cp', 'cp', 1, '2024-01-07'),
+ availableFrom: '2024-01-04',
+ },
+ ],
+ rules: [],
+ });
+ 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' &&
+ delta.placement?.date === '2024-01-04',
+ ),
+ );
+ const placement = decision?.deltas.find(
+ (delta) =>
+ delta.kind === 'placement-added' && delta.placement?.date === '2024-01-04',
+ )?.placement;
+ expect(placement).toBeDefined();
+ expect(decision).toBeDefined();
+ if (!placement || !decision) return;
+
+ const selection = { decisionId: decision.id, placement };
+ const historicalEligibility = await calculation.historicalEligibility(selection);
+ expect(historicalEligibility).toContainEqual({
+ date: '2024-01-02',
+ reason: expect.objectContaining({ code: 'unavailable' }),
+ });
+ expect(historicalEligibility).toContainEqual({
+ date: '2024-01-08',
+ reason: expect.objectContaining({ code: 'expired' }),
+ });
+
+ const analysis = await calculation.analyzeCounterfactual(selection);
+ expect(analysis.kind).toBe('Counterfactual');
+ if (analysis.kind !== 'Counterfactual') return;
+ expect(analysis.results.find((result) => result.date === '2024-01-02')).toMatchObject({
+ status: 'ineligible',
+ historicalStatus: 'ineligible',
+ reason: { code: 'unavailable' },
+ historicalReason: { code: 'unavailable' },
+ });
+ expect(analysis.results.find((result) => result.date === '2024-01-08')).toMatchObject({
+ status: 'ineligible',
+ historicalStatus: 'ineligible',
+ reason: { code: 'expired' },
+ historicalReason: { code: 'expired' },
+ });
+ expect(analysis.results.find((result) => result.date === '2024-01-04')).toMatchObject({
+ historicalStatus: 'chosen',
+ historicalReason: undefined,
+ });
+ } finally {
+ vi.stubGlobal('window', browserWindow);
+ }
+ });
+
+ it('replays branch-local ancestors without applying the selected decision twice', async () => {
+ const request = {
+ horizon: { start: '2024-01-01', end: '2024-01-10' },
+ balances: [balance('cp', 'cp', 1)],
+ rules: [],
+ };
+ const plan = isPlan(await runInNonBrowser(request));
+ const firstPlacement = plan.placements[0];
+ if (!firstPlacement) throw new Error('Expected the plan to contain a placement.');
+ const selected = { ...firstPlacement, date: '2024-01-03' };
+ const outcome = { ...plan, placements: [selected] };
+ const committedPlacement = { ...selected, date: '2024-01-01' };
+ const parentPlacement = { ...selected, date: '2024-01-02' };
+ const initialState: PlanningTraceState = {
+ horizon: request.horizon,
+ balances: [
+ {
+ id: 'cp',
+ type: 'cp',
+ quantity: 5,
+ remaining: 5,
+ availableFrom: '2024-01-01',
+ expiryDate: '2024-01-31',
+ earned: false,
+ },
+ ],
+ placements: [],
+ earnedActivations: [],
+ };
+ const committedDeltas = [
+ {
+ kind: 'placement-added' as const,
+ date: committedPlacement.date,
+ balanceId: 'cp',
+ placement: committedPlacement,
+ },
+ {
+ kind: 'balance-changed' as const,
+ balanceId: 'cp',
+ remainingBefore: 5,
+ remainingAfter: 4,
+ balance: {
+ id: 'cp',
+ type: 'cp' as const,
+ quantity: 5,
+ remaining: 4,
+ availableFrom: '2024-01-02',
+ expiryDate: '2024-01-31',
+ earned: true,
+ sourceRuleId: 'producer',
+ },
+ },
+ ];
+ const parentDeltas = [
+ {
+ kind: 'placement-added' as const,
+ date: parentPlacement.date,
+ balanceId: 'cp',
+ placement: parentPlacement,
+ },
+ {
+ kind: 'balance-changed' as const,
+ balanceId: 'cp',
+ remainingBefore: 4,
+ remainingAfter: 3,
+ },
+ ];
+ const selectedTransition = [
+ {
+ kind: 'placement-moved' as const,
+ fromDate: parentPlacement.date,
+ toDate: selected.date,
+ date: selected.date,
+ balanceId: 'cp',
+ previousPlacement: parentPlacement,
+ placement: selected,
+ },
+ {
+ kind: 'balance-changed' as const,
+ balanceId: 'cp',
+ remainingBefore: 3,
+ remainingAfter: 2,
+ },
+ ];
+ const update: PlanningTraceUpdate = {
+ initialState,
+ operations: [
+ {
+ kind: 'phase',
+ phase: {
+ id: 'obligations',
+ name: 'obligations',
+ completed: true,
+ status: 'completed',
+ },
+ },
+ {
+ kind: 'decision',
+ phaseId: 'obligations',
+ decision: { id: 'committed', action: 'choose', dates: ['2024-01-01'] },
+ },
+ ...committedDeltas.map((delta, index) => ({
+ kind: 'delta' as const,
+ phaseId: 'obligations',
+ decisionId: 'committed',
+ index,
+ delta,
+ })),
+ {
+ kind: 'decision',
+ phaseId: 'obligations',
+ decision: {
+ id: 'branch-parent',
+ action: 'choose',
+ dates: [parentPlacement.date],
+ branch: { deltas: parentDeltas },
+ },
+ },
+ {
+ kind: 'decision',
+ phaseId: 'obligations',
+ decision: {
+ id: 'branch-selected',
+ action: 'choose',
+ dates: [selected.date],
+ branch: {
+ parentDecisionId: 'branch-parent',
+ deltas: selectedTransition,
+ },
+ },
+ },
+ ...selectedTransition.map((delta, index) => ({
+ kind: 'delta' as const,
+ phaseId: 'obligations',
+ decisionId: 'branch-selected',
+ index,
+ delta,
+ })),
+ {
+ kind: 'decision',
+ phaseId: 'obligations',
+ decision: {
+ id: 'self-parent',
+ action: 'choose',
+ dates: [selected.date],
+ branch: { parentDecisionId: 'self-parent', deltas: [] },
+ },
+ },
+ ],
+ };
+ const response: CounterfactualAnalysis = {
+ kind: 'Counterfactual',
+ calculationId: 'branch-transport-test',
+ placement: selected,
+ hypothetical: true,
+ baseline: { objectives: [] },
+ results: [],
+ };
+ let receivedState: PlanningTraceState | undefined;
+ let receivedOptions: CounterfactualBaselineOptions | undefined;
+ class BranchTraceWorker {
+ onmessage: ((event: MessageEvent) => void) | null = null;
+ onerror: (() => void) | null = null;
+
+ postMessage(message: {
+ type: string;
+ state?: PlanningTraceState;
+ options?: CounterfactualBaselineOptions;
+ }): void {
+ if (message.type === 'plan') {
+ this.onmessage?.({ data: { type: 'result', outcome } } as MessageEvent);
+ this.onmessage?.({
+ data: {
+ type: 'artifacts-start',
+ artifacts: {},
+ trace: {
+ status: 'ready',
+ initialState,
+ phases: [],
+ completedStages: outcome.completedStages,
+ nonWorkingDates: [],
+ error: undefined,
+ },
+ },
+ } as MessageEvent);
+ this.onmessage?.({
+ data: { type: 'trace-update', totalMs: 0, update },
+ } as MessageEvent);
+ this.onmessage?.({
+ data: { type: 'artifacts-complete', traceTransferMs: 0 },
+ } as MessageEvent);
+ return;
+ }
+ receivedState = message.state;
+ receivedOptions = message.options;
+ if (message.type === 'historical-eligibility') {
+ queueMicrotask(() =>
+ this.onmessage?.({
+ data: {
+ type: 'historical-eligibility-result',
+ entries: [
+ {
+ date: '2024-01-03',
+ reason: {
+ code: 'unavailable',
+ message:
+ 'The selected entitlement is not available yet.',
+ },
+ } satisfies HistoricalEligibilityReason,
+ ],
+ },
+ } as MessageEvent),
+ );
+ return;
+ }
+ queueMicrotask(() =>
+ this.onmessage?.({
+ data: { type: 'counterfactual-result', analysis: response },
+ } as MessageEvent),
+ );
+ }
+
+ terminate(): void {}
+ }
+ const browserWindow = globalThis.window;
+ vi.stubGlobal('window', {});
+ vi.stubGlobal('Worker', BranchTraceWorker);
+ try {
+ const calculation = createPlanningCalculation(request);
+ await calculation.trace;
+ const historicalEligibility = await calculation.historicalEligibility({
+ decisionId: 'branch-selected',
+ placement: selected,
+ });
+ expect(historicalEligibility).toEqual([
+ {
+ date: '2024-01-03',
+ reason: {
+ code: 'unavailable',
+ message: 'The selected entitlement is not available yet.',
+ },
+ },
+ ]);
+ expect(receivedState?.balances[0]).toMatchObject({
+ earned: true,
+ sourceRuleId: 'producer',
+ });
+ expect(receivedOptions).toEqual({
+ removeDate: '2024-01-02',
+ compareOriginalState: true,
+ });
+ const analysis = await calculation.analyzeCounterfactual({
+ decisionId: 'branch-selected',
+ placement: selected,
+ });
+ expect(analysis.kind).toBe('Counterfactual');
+ expect(receivedState?.placements.map((placement) => placement.date)).toEqual([
+ '2024-01-01',
+ '2024-01-02',
+ ]);
+ expect(receivedState?.balances[0]?.remaining).toBe(3);
+ expect(receivedState?.balances[0]).toMatchObject({
+ availableFrom: '2024-01-02',
+ earned: true,
+ sourceRuleId: 'producer',
+ });
+ expect(receivedOptions).toEqual({
+ removeDate: '2024-01-02',
+ compareOriginalState: true,
+ });
+ await expect(
+ calculation.analyzeCounterfactual({
+ decisionId: 'self-parent',
+ placement: selected,
+ }),
+ ).rejects.toThrow('The selected trace decision is unavailable.');
+ } finally {
+ vi.unstubAllGlobals();
+ 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);
@@ -2025,7 +2820,7 @@ describe('planning calculation context', () => {
reason: candidate.reason?.code,
})) ?? [];
expect(candidates.slice(0, 2)).toEqual([
- { balanceId: 'earlier', status: 'evaluated-rejected', reason: undefined },
+ { balanceId: 'earlier', status: 'chosen', reason: undefined },
{ balanceId: 'later', status: 'not-examined', reason: 'first-compatible-balance' },
]);
} finally {
@@ -2177,6 +2972,370 @@ describe('planning calculation context', () => {
}
});
+ it('delivers every streamed evaluation after ten seconds without delaying the outcome', async () => {
+ const request = {
+ horizon: { start: '2024-01-01', end: '2024-12-31' },
+ balances: [balance('cp', 'cp', 1)],
+ rules: [],
+ };
+ const expected = isPlan(await runInNonBrowser(request));
+ const candidateCount = 768;
+ const initialState: PlanningTraceState = {
+ horizon: request.horizon,
+ balances: [],
+ placements: [],
+ earnedActivations: [],
+ };
+ const operations: PlanningTraceUpdate['operations'][number][] = [
+ {
+ kind: 'phase',
+ phase: { id: 'phase:1', name: 'search', completed: true, status: 'completed' },
+ },
+ {
+ kind: 'decision',
+ phaseId: 'phase:1',
+ decision: { id: 'decision:1', action: 'choose', dates: ['2024-02-01'] },
+ },
+ ...Array.from({ length: candidateCount }, (_, index) => ({
+ kind: 'candidate' as const,
+ phaseId: 'phase:1',
+ decisionId: 'decision:1',
+ index,
+ mode: 'append' as const,
+ candidate: { status: 'evaluated-rejected' as const, date: '2024-02-01' },
+ })),
+ ];
+ const trace = {
+ status: 'ready' as const,
+ initialState,
+ completedStages: ['dates'],
+ };
+ class SlowTraceWorker {
+ onmessage: ((event: MessageEvent) => void) | null = null;
+ onerror: (() => void) | null = null;
+ terminated = false;
+
+ postMessage(): void {
+ queueMicrotask(() => {
+ this.onmessage?.({
+ data: { type: 'result', outcome: expected },
+ } as MessageEvent);
+ this.onmessage?.({
+ data: {
+ type: 'explanation',
+ explanation: { kind: 'PlanExplanation', placements: [] },
+ finalState: initialState,
+ },
+ } as MessageEvent);
+ });
+ setTimeout(() => {
+ for (let offset = 0; offset < operations.length; offset += 64)
+ this.onmessage?.({
+ data: {
+ type: 'trace-update',
+ totalMs: 0,
+ update: {
+ initialState: offset === 0 ? initialState : undefined,
+ operations: operations.slice(offset, offset + 64),
+ },
+ },
+ } as MessageEvent);
+ this.onmessage?.({
+ data: {
+ type: 'trace-status-index',
+ statuses: new Map([['2024-02-01', 'evaluated-rejected']]),
+ nonWorkingDates: [],
+ },
+ } as MessageEvent);
+ this.onmessage?.({
+ data: { type: 'artifacts-start', artifacts: {}, trace },
+ } as MessageEvent);
+ this.onmessage?.({ data: { type: 'artifacts-complete' } } as MessageEvent);
+ }, 10_001);
+ }
+
+ terminate(): void {
+ this.terminated = true;
+ }
+ }
+ const browserWindow = globalThis.window;
+ vi.stubGlobal('window', {});
+ vi.stubGlobal('Worker', SlowTraceWorker);
+ try {
+ const calculation = createPlanningCalculation(request);
+ let traceReady = false;
+ const tracePromise = calculation.trace.then((value) => {
+ traceReady = true;
+ return value;
+ });
+ expect(await calculation.outcome).toEqual(expected);
+ expect((await calculation.explanation)?.kind).toBe('PlanExplanation');
+ expect(traceReady).toBe(false);
+ const traceStarted = performance.now();
+ const loadedTrace = await tracePromise;
+ expect(performance.now() - traceStarted).toBeGreaterThanOrEqual(10_000);
+ expect(loadedTrace?.phases[0].decisions[0].candidates).toHaveLength(candidateCount);
+ expect(loadedTrace?.status).toBe('ready');
+ } finally {
+ vi.unstubAllGlobals();
+ vi.stubGlobal('window', browserWindow);
+ }
+ }, 15_000);
+
+ it('drains queued trace packets in batches of at most sixteen', async () => {
+ const request = {
+ horizon: { start: '2024-01-01', end: '2024-12-31' },
+ balances: [balance('cp', 'cp', 1)],
+ rules: [],
+ };
+ const expected = isPlan(await runInNonBrowser(request));
+ const packetCount = 17;
+ const initialState: PlanningTraceState = {
+ horizon: request.horizon,
+ balances: [],
+ placements: [],
+ earnedActivations: [],
+ };
+ const trace = {
+ status: 'ready' as const,
+ initialState,
+ completedStages: ['dates'],
+ };
+ const updates = Array.from(
+ { length: packetCount },
+ (_, index): PlanningTraceUpdate => ({
+ initialState: index === 0 ? initialState : undefined,
+ operations:
+ index === 0
+ ? [
+ {
+ kind: 'phase',
+ phase: {
+ id: 'phase:1',
+ name: 'search',
+ completed: true,
+ status: 'completed',
+ },
+ },
+ {
+ kind: 'decision',
+ phaseId: 'phase:1',
+ decision: {
+ id: 'decision:1',
+ action: 'choose',
+ dates: ['2024-02-01'],
+ },
+ },
+ {
+ kind: 'candidate',
+ phaseId: 'phase:1',
+ decisionId: 'decision:1',
+ index,
+ mode: 'append',
+ candidate: {
+ status: 'evaluated-rejected',
+ date: '2024-02-01',
+ },
+ },
+ ]
+ : [
+ {
+ kind: 'candidate',
+ phaseId: 'phase:1',
+ decisionId: 'decision:1',
+ index,
+ mode: 'append',
+ candidate: {
+ status: 'evaluated-rejected',
+ date: '2024-02-01',
+ },
+ },
+ ],
+ }),
+ );
+ let performanceNowCalls = 0;
+ let performanceNowCallsAtTermination = -1;
+ let terminationCount = 0;
+ let acknowledgementCount = 0;
+ class BatchedTraceWorker {
+ onmessage: ((event: MessageEvent) => void) | null = null;
+ onerror: (() => void) | null = null;
+
+ postMessage(message?: { type?: string }): void {
+ if (message?.type === 'trace-ack') {
+ acknowledgementCount++;
+ return;
+ }
+ this.onmessage?.({ data: { type: 'result', outcome: expected } } as MessageEvent);
+ this.onmessage?.({
+ data: {
+ type: 'explanation',
+ explanation: { kind: 'PlanExplanation', placements: [] },
+ finalState: initialState,
+ },
+ } as MessageEvent);
+ this.onmessage?.({
+ data: { type: 'artifacts-start', artifacts: {}, trace },
+ } as MessageEvent);
+ for (const update of updates)
+ this.onmessage?.({
+ data: { type: 'trace-update', totalMs: 0, update },
+ } as MessageEvent);
+ this.onmessage?.({
+ data: { type: 'artifacts-complete', traceTransferMs: 0 },
+ } as MessageEvent);
+ }
+
+ terminate(): void {
+ terminationCount++;
+ performanceNowCallsAtTermination = performanceNowCalls;
+ }
+ }
+ const browserWindow = globalThis.window;
+ const timeoutSpy = vi.spyOn(globalThis, 'setTimeout');
+ vi.spyOn(performance, 'now').mockImplementation(() => {
+ performanceNowCalls++;
+ return 0;
+ });
+ vi.stubGlobal('window', {});
+ vi.stubGlobal('Worker', BatchedTraceWorker);
+ try {
+ const calculation = createPlanningCalculation(request);
+ const loadedTrace = await calculation.trace;
+ expect(loadedTrace?.phases[0].decisions[0].candidates).toHaveLength(packetCount);
+ expect(timeoutSpy.mock.calls.filter(([, delay]) => delay === 0)).toHaveLength(2);
+ expect(terminationCount).toBe(1);
+ expect(performanceNowCallsAtTermination).toBe(2);
+ expect(acknowledgementCount).toBe(0);
+ } finally {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ vi.stubGlobal('window', browserWindow);
+ }
+ });
+
+ it('acknowledges marked trace packets only after applying them and waits for the tail', async () => {
+ const request = {
+ horizon: { start: '2024-01-01', end: '2024-12-31' },
+ balances: [balance('cp', 'cp', 1)],
+ rules: [],
+ };
+ const expected = isPlan(await runInNonBrowser(request));
+ const initialState: PlanningTraceState = {
+ horizon: request.horizon,
+ balances: [],
+ placements: [],
+ earnedActivations: [],
+ };
+ const trace = {
+ status: 'ready' as const,
+ initialState,
+ completedStages: ['dates'],
+ };
+ const phase = {
+ kind: 'phase' as const,
+ phase: { id: 'phase:1', name: 'search', completed: true, status: 'completed' as const },
+ };
+ const decision = {
+ kind: 'decision' as const,
+ phaseId: 'phase:1',
+ decision: { id: 'decision:1', action: 'choose' as const, dates: ['2024-02-01'] },
+ };
+ const candidate = (index: number) => ({
+ kind: 'candidate' as const,
+ phaseId: 'phase:1',
+ decisionId: 'decision:1',
+ index,
+ mode: 'append' as const,
+ candidate: { status: 'evaluated-rejected' as const, date: '2024-02-01' },
+ });
+ const firstUpdate: PlanningTraceUpdate = {
+ initialState,
+ operations: [phase, decision, candidate(0)],
+ };
+ const tailUpdate: PlanningTraceUpdate = { operations: [candidate(1)] };
+ let acknowledgePackets = 0;
+ let tailSent = false;
+ let resolveFirstAcknowledgement!: () => void;
+ const firstAcknowledgement = new Promise(
+ (resolve) => (resolveFirstAcknowledgement = resolve),
+ );
+ class AcknowledgedTraceWorker {
+ onmessage: ((event: MessageEvent) => void) | null = null;
+ onerror: (() => void) | null = null;
+
+ postMessage(message: { type?: string; count?: number }): void {
+ if (message.type === 'trace-ack') {
+ acknowledgePackets += message.count ?? 0;
+ if (acknowledgePackets === 1) {
+ tailSent = true;
+ queueMicrotask(() =>
+ this.onmessage?.({
+ data: {
+ type: 'trace-update',
+ totalMs: 0,
+ update: tailUpdate,
+ acknowledge: true,
+ },
+ } as MessageEvent),
+ );
+ resolveFirstAcknowledgement();
+ } else if (acknowledgePackets === 2)
+ queueMicrotask(() =>
+ this.onmessage?.({
+ data: { type: 'artifacts-complete', traceTransferMs: 0 },
+ } as MessageEvent),
+ );
+ return;
+ }
+ this.onmessage?.({ data: { type: 'result', outcome: expected } } as MessageEvent);
+ this.onmessage?.({
+ data: {
+ type: 'explanation',
+ explanation: { kind: 'PlanExplanation', placements: [] },
+ finalState: initialState,
+ },
+ } as MessageEvent);
+ this.onmessage?.({
+ data: { type: 'artifacts-start', artifacts: {}, trace },
+ } as MessageEvent);
+ this.onmessage?.({
+ data: {
+ type: 'trace-update',
+ totalMs: 0,
+ update: firstUpdate,
+ acknowledge: true,
+ },
+ } as MessageEvent);
+ }
+
+ terminate(): void {}
+ }
+ const browserWindow = globalThis.window;
+ vi.stubGlobal('window', {});
+ vi.stubGlobal('Worker', AcknowledgedTraceWorker);
+ try {
+ const calculation = createPlanningCalculation(request);
+ let traceReady = false;
+ const tracePromise = calculation.trace.then((result) => {
+ traceReady = true;
+ return result;
+ });
+ await calculation.outcome;
+ expect(acknowledgePackets).toBe(0);
+ expect(traceReady).toBe(false);
+ await firstAcknowledgement;
+ expect(tailSent).toBe(true);
+ expect(traceReady).toBe(false);
+ const loadedTrace = await tracePromise;
+ expect(acknowledgePackets).toBe(2);
+ expect(loadedTrace?.phases[0].decisions[0].candidates).toHaveLength(2);
+ } finally {
+ vi.unstubAllGlobals();
+ vi.stubGlobal('window', browserWindow);
+ }
+ });
+
it('keeps a valid outcome when trace delivery fails afterward', async () => {
const expected = isPlan(
await runInNonBrowser({ horizon, balances: [balance('cp', 'cp', 1)], rules: [] }),
diff --git a/src/app/services/leave-planner.service.ts b/src/app/services/leave-planner.service.ts
index eb4a5ad..716b39b 100644
--- a/src/app/services/leave-planner.service.ts
+++ b/src/app/services/leave-planner.service.ts
@@ -13,9 +13,16 @@ import type {
PlanningOutcome,
PlanningRequest,
PlanningSnapshot,
+ PlanningArtifactTimings,
PlanningTrace,
+ PlanningTraceCandidate,
+ PlanningTraceDecision,
+ PlanningTraceDelta,
+ PlanningTracePhase,
+ PlanningTraceUpdate,
PlanningReason,
PlanningTraceState,
+ HistoricalCounterfactualReason,
StaleCounterfactualAnalysis,
TraceCandidateStatus,
TraceCounterfactualSelection,
@@ -53,12 +60,15 @@ export type {
PlanningRequest,
PlanningRule,
PlanningSnapshot,
+ PlanningArtifactTimings,
PlanningTrace,
PlanningTraceCandidate,
PlanningTraceDecision,
PlanningTraceDelta,
PlanningTracePhase,
PlanningTraceState,
+ PlanningTraceOperation,
+ PlanningTraceUpdate,
PreferenceResult,
RemainingBalance,
RemainingClassification,
@@ -66,9 +76,14 @@ export type {
TimeOffPeriod,
TimeWindowPlanningRule,
TraceCounterfactualSelection,
+ TraceCandidateStatus,
} from './leave-planner.core';
const MAX_PLANNING_MS = 10_000;
+// Core owns the work deadline; this independent timer only recovers a stuck worker.
+const WORKER_LIVENESS_TIMEOUT_MS = 30_000;
+const MAX_TRACE_UPDATES_PER_DRAIN = 16;
+const TRACE_DRAIN_TIME_BUDGET_MS = 4;
interface UntrustedRecord {
horizon?: unknown;
@@ -135,7 +150,8 @@ function cloneUnknown(value: unknown, seen = new WeakMap