-
- {{ tr().planning.historical }}:
- {{
- dateStatuses()[result.date]?.label ??
- traceStatusLabel(
- result.historicalStatus ?? 'not-examined'
- )
- }}
-
- @if (dateStatuses()[result.date]?.reason; as historicalReason) {
-
- {{ tr().planning.reason }}:
- {{ historicalReason }}
-
- }
@if (dateComparison(); as comparison) {
} @else {
@@ -1583,9 +1005,7 @@
{{
counterfactualError()
? tr().planning.retryCounterfactual
- : traceReplay()
- ? tr().planning.comparison.backToTrace
- : tr().planning.comparison.backToPlan
+ : tr().planning.comparison.backToPlan
}}
diff --git a/src/app/components/calendar-year/calendar-year.scss b/src/app/components/calendar-year/calendar-year.scss
index 292825e..d5d590c 100644
--- a/src/app/components/calendar-year/calendar-year.scss
+++ b/src/app/components/calendar-year/calendar-year.scss
@@ -85,8 +85,7 @@ summary:focus-visible {
}
.results-header,
-.explanation-heading,
-.replay-notice {
+.explanation-heading {
display: flex;
flex-wrap: wrap;
align-items: center;
@@ -249,14 +248,12 @@ summary:focus-visible {
}
.placement-explanation,
-.outcome-explanation,
-.trace-decisions {
+.outcome-explanation {
display: grid;
gap: 1rem;
min-width: 0;
}
-.placement-explanation,
-.trace-layout {
+.placement-explanation {
padding: 1.25rem;
}
.placement-explanation ul {
@@ -264,8 +261,7 @@ summary:focus-visible {
padding-inline-start: 1.25rem;
}
.break-story,
-.outcome-explanation,
-.replay-notice {
+.outcome-explanation {
padding: 1rem;
border-radius: var(--ui-radius-sm);
background: var(--ui-panel-bg);
@@ -339,17 +335,13 @@ summary:focus-visible {
.compare-dates-button {
justify-self: start;
}
-.placement-stepper,
-.trace-toolbar,
-.trace-decisions nav,
-.trace-phase-stepper {
+.placement-stepper {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
-.placement-stepper button,
-.trace-phase-stepper button {
+.placement-stepper button {
padding: 0.375rem;
}
output {
@@ -499,52 +491,6 @@ output {
box-shadow: none;
}
-.trace-layout {
- display: grid;
- grid-template-columns: minmax(0, 0.35fr) minmax(0, 1fr);
- gap: 1rem;
-}
-.trace-phases {
- display: grid;
- align-content: start;
- gap: 0.5rem;
- > button {
- justify-content: space-between;
- text-align: start;
- overflow-wrap: anywhere;
- }
- .active {
- border-inline-start: 4px solid var(--mat-sys-primary);
- }
- small {
- color: var(--ui-text-secondary);
- flex-shrink: 0;
- white-space: nowrap;
- }
-}
-.trace-phase-stepper {
- justify-content: center;
-}
-.trace-decisions {
- align-content: start;
-}
-.candidate-list {
- display: grid;
- gap: 0.5rem;
- details {
- padding: 0.5rem;
- border: 1px solid var(--ui-border);
- border-radius: var(--ui-radius-sm);
- overflow-wrap: anywhere;
- }
-}
-.trace-state-summary,
-.replay-notice {
- padding: 0.75rem;
- border: 1px solid var(--mat-sys-primary);
- border-radius: var(--ui-radius-sm);
-}
-
.loading-overlay {
position: absolute;
inset: 0;
@@ -576,18 +522,6 @@ output {
font-weight: 600;
}
-@container calendar-year (max-width: 48rem) {
- .trace-layout {
- grid-template-columns: minmax(0, 1fr);
- }
- .trace-phases {
- grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
- }
- .trace-phase-stepper {
- grid-column: 1 / -1;
- }
-}
-
@container calendar-year (max-width: 35rem) {
.plan-insights {
grid-template-columns: minmax(0, 1fr);
@@ -604,8 +538,7 @@ output {
font-size: 0.75rem;
}
}
- .placement-explanation,
- .trace-layout {
+ .placement-explanation {
padding: 0.875rem;
}
.placement-stepper {
diff --git a/src/app/components/calendar-year/calendar-year.ts b/src/app/components/calendar-year/calendar-year.ts
index 28e9c89..d1d7a2a 100644
--- a/src/app/components/calendar-year/calendar-year.ts
+++ b/src/app/components/calendar-year/calendar-year.ts
@@ -30,17 +30,12 @@ import {
MatDialogRef,
MatDialogTitle,
} from '@angular/material/dialog';
-import {
- CalendarMonth,
- CalendarDayStatus,
- DeclarationPreview,
-} from '../calendar-month/calendar-month';
+import { CalendarMonth, DeclarationPreview } from '../calendar-month/calendar-month';
import { addPlanningDays } from '../../services/planning-date';
import { isWorkingPlanningDate } from '../../services/declared-non-working-days';
import {
CalendarService,
calendarProjectionHorizon,
- DayType,
SelectedDates,
SelectableDayType,
VacDay,
@@ -50,10 +45,8 @@ import { UserInputService } from '../../services/user-input-service';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
import { PlanningDurationService } from '../../services/planning-duration.service';
import {
- applyReplayDeltas,
CounterfactualAnalysis,
CounterfactualSelection,
- LeavePlacement,
LeaveType,
DeclaredNonWorkingDay,
PlanExplanation,
@@ -64,15 +57,9 @@ import {
PlanningObjectiveVector,
PlanningOutcome,
PlanningReason,
- PlanningTrace,
- PlanningTraceCandidate,
- PlanningTraceDecision,
- PlanningTraceState,
} from '../../services/leave-planner.service';
type PlacementExplanation = PlanExplanation['placements'][number];
-type TraceMoment = 'before' | 'after';
-type TraceTransition = Readonly<{ before: PlanningTraceState; after: PlanningTraceState }>;
@Component({
selector: 'app-calendar-year',
@@ -121,12 +108,8 @@ export class CalendarYear {
protected readonly planningOutcome = this.userInput.planningOutcomeSignal;
protected readonly planningExplanation = signal
(null);
protected readonly explanationError = signal(null);
- protected readonly planningTrace = this.userInput.planningTraceSignal;
- protected readonly traceLoading = signal(false);
- protected readonly traceResolved = this.userInput.traceResolvedSignal;
protected readonly selectedPlacementIndex = signal(0);
protected readonly planDetailsOpen = signal(false);
- protected readonly traceOpen = signal(false);
private readonly planDetails = viewChild>('planDetails');
protected readonly counterfactual = signal(null);
protected readonly selectedCounterfactualDate = signal(null);
@@ -134,18 +117,7 @@ export class CalendarYear {
protected readonly counterfactualError = signal(null);
protected readonly invalidComparisonDate = signal(false);
protected readonly comparisonDateInput = signal(null);
- protected readonly tracePhaseIndex = signal(0);
- protected readonly traceDecisionIndex = signal(0);
- protected readonly traceCandidatePage = signal(0);
- protected readonly traceMoment = signal('before');
- 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());
protected readonly declarationEntry = this.userInput.declarationEntrySignal;
@@ -179,21 +151,14 @@ export class CalendarYear {
protected readonly calendarDeclaredDays = computed>>(() => {
const days = this.declarationEntry()
? this.userInput.declaredDaysSignal()
- : (this.traceState()?.declaredDays ??
- this.displayedPlan()?.declaredDays ??
- this.resultDeclaredDays());
+ : (this.displayedPlan()?.declaredDays ?? this.resultDeclaredDays());
return Object.fromEntries(days.map(({ date, type }) => [date, type]));
});
private calculationVersion = 0;
private counterfactualVersion = 0;
- private historicalEligibilityVersion = 0;
private activeCalculation: PlanningCalculationContext | null = null;
private runningCalculation: PlanningCalculationContext | null = null;
- private readonly traceDecisionIndices = new WeakMap<
- PlanningTrace,
- ReadonlyMap
- >();
private displayDateLocale = '';
private displayDateFormatter?: Intl.DateTimeFormat;
@@ -245,28 +210,6 @@ export class CalendarYear {
() => this.planExplanation()?.placements[this.selectedPlacementIndex()],
);
- protected readonly selectedTracePhase = computed(
- () => this.planningTrace()?.phases[this.tracePhaseIndex()],
- );
-
- 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;
- return this.selectedTraceDecision()?.candidates.slice(start, start + 50) ?? [];
- });
-
- protected readonly traceCandidatePages = computed(() =>
- Math.max(1, Math.ceil((this.selectedTraceDecision()?.candidates.length ?? 0) / 50)),
- );
-
protected readonly selectedCounterfactualResult = computed(() =>
this.counterfactual()?.results.find(
(result) => result.date === this.selectedCounterfactualDate(),
@@ -314,12 +257,9 @@ export class CalendarYear {
const analysis = this.counterfactual();
const comparison = this.selectedCounterfactualResult()?.comparison;
if (!analysis || !comparison) return undefined;
- // Final-plan alternatives share a removed-day baseline. Compare their restored
- // plans; a trace candidate instead uses the exact state before its decision.
- const before = this.traceReplay()
- ? comparison.before
- : analysis.results.find((result) => result.date === analysis.placement.date)?.comparison
- ?.after;
+ // Compare restored alternatives against the displayed plan.
+ const before = analysis.results.find((result) => result.date === analysis.placement.date)
+ ?.comparison?.after;
return before
? {
before,
@@ -337,10 +277,6 @@ export class CalendarYear {
this.numericChanges(this.dateComparison()?.before, this.dateComparison()?.after),
);
- protected readonly traceState = computed(() => {
- return this.selectedTraceTransition()?.[this.traceMoment()];
- });
-
protected readonly calendarSelectedDates = computed(() => {
if (this.declarationEntry()) {
const horizon = this.calendarHorizon();
@@ -352,176 +288,22 @@ export class CalendarYear {
})
: new SelectedDates();
}
- const state = this.traceState();
- return state
- ? this.calendarService.selectedDatesForPlan({
- placements: [...state.placements],
- declaredDays: state.declaredDays,
- horizon: { ...state.horizon },
- })
- : this.selectedDates();
+
+ return this.selectedDates();
});
protected readonly currentPlacementDate = computed(() => {
- if (this.traceReplay())
- return (
- this.selectedCounterfactualDate() ??
- this.selectedTraceDate() ??
- this.selectedTraceDecision()?.dates[0] ??
- null
- );
return this.planDetailsOpen() ? (this.selectedExplanation()?.placement.date ?? null) : null;
});
protected readonly explainedPeriod = computed(() =>
- this.planDetailsOpen() && !this.traceReplay()
- ? this.selectedExplanation()?.timeOffPeriod
- : undefined,
+ this.planDetailsOpen() ? this.selectedExplanation()?.timeOffPeriod : undefined,
);
- protected readonly dateStatuses = computed>>(() => {
- const statuses: Record = {};
- const trace = this.planningTrace();
- const closedDayDates = new Set();
- for (const selected of this.selectedDates().datesSelected) {
- if (selected.type !== DayType.CLOSED_DAY || !selected.range.start) continue;
- const day = selected.range.start;
- closedDayDates.add(
- `${String(day.getFullYear()).padStart(4, '0')}-${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}`,
- );
- }
- if (this.traceReplay() && trace) {
- const priority = new Map([
- ['ineligible', 0],
- ['not-examined', 1],
- ['pruned', 2],
- ['evaluated-rejected', 3],
- ['chosen', 4],
- ]);
- for (
- let timestamp = Date.parse(`${trace.initialState.horizon.start}T00:00:00Z`),
- end = Date.parse(`${trace.initialState.horizon.end}T00:00:00Z`);
- timestamp < end;
- timestamp += 86_400_000
- ) {
- const date = new Date(timestamp).toISOString().slice(0, 10);
- statuses[date] = {
- kind: 'not-examined',
- label: this.traceStatusLabel('not-examined'),
- };
- }
- for (const date of Object.keys(statuses)) {
- const reason = this.nonWorkingDateReason(date, closedDayDates);
- if (reason)
- statuses[date] = {
- kind: 'ineligible',
- label: this.traceStatusLabel('ineligible'),
- reason,
- };
- }
- const explicitPriorities = new Map();
- for (const candidate of this.selectedTraceDecision()?.candidates ?? []) {
- for (const date of candidate.dates ?? (candidate.date ? [candidate.date] : [])) {
- const candidatePriority = priority.get(candidate.status)!;
- if (candidatePriority > (explicitPriorities.get(date) ?? -1)) {
- statuses[date] = {
- kind: candidate.status,
- label: this.traceStatusLabel(candidate.status),
- reason: candidate.reason?.message,
- };
- explicitPriorities.set(date, candidatePriority);
- }
- }
- }
- 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();
- const setChange = (
- date: string | undefined,
- change: NonNullable,
- ) => {
- if (!date) return;
- changedDates.add(date);
- statuses[date] = {
- ...(statuses[date] ?? {
- kind: 'not-examined',
- label: this.traceStatusLabel('not-examined'),
- }),
- change,
- changeLabel: this.traceChangeLabel(change),
- };
- };
- 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') {
- setChange(delta.fromDate, 'moved-from');
- setChange(delta.toDate, 'moved-to');
- } else if (delta.kind === 'placement-updated') setChange(delta.date, 'shared');
- }
- const transition = this.selectedTraceTransition();
- for (const placement of [
- ...(transition?.before.placements ?? []),
- ...(transition?.after.placements ?? []),
- ]) {
- if (!changedDates.has(placement.date)) setChange(placement.date, 'unchanged');
- }
- }
- for (const result of this.counterfactual()?.results ?? []) {
- const status = statuses[result.date];
- const historicalStatus =
- result.historicalStatus ??
- (status && status.kind !== 'eligible' ? status.kind : 'not-examined');
- statuses[result.date] = {
- ...status,
- kind: historicalStatus,
- label: this.traceStatusLabel(historicalStatus),
- reason: result.historicalReason
- ? this.reasonLabel(result.historicalReason)
- : status?.reason,
- hypotheticalLabel:
- result.status === 'eligible'
- ? this.tr().planning.eligible
- : this.tr().planning.ineligible,
- hypotheticalReason: result.reason?.message,
- };
- }
- return statuses;
- }
- for (const result of this.counterfactual()?.results ?? []) {
- const nonWorkingReason = this.nonWorkingDateReason(result.date, closedDayDates);
- const historicalStatus =
- result.historicalStatus ?? (nonWorkingReason ? 'ineligible' : 'not-examined');
- statuses[result.date] = {
- kind: historicalStatus,
- label: this.traceStatusLabel(historicalStatus),
- reason: result.historicalReason
- ? this.reasonLabel(result.historicalReason)
- : historicalStatus === 'ineligible'
- ? nonWorkingReason
- : undefined,
- hypotheticalLabel:
- result.status === 'eligible'
- ? this.tr().planning.eligible
- : this.tr().planning.ineligible,
- hypotheticalReason: result.reason?.message,
- };
- }
- return statuses;
- });
-
protected readonly months = computed(() => {
const horizon = this.calendarHorizon();
if (!horizon) return [];
- const plan = this.traceState() ?? this.displayedPlan();
+ const plan = this.displayedPlan();
const projectionHorizon =
!this.declarationEntry() && plan
? (calendarProjectionHorizon(plan) ?? horizon)
@@ -555,7 +337,7 @@ export class CalendarYear {
effect(() => {
if (this.declarationEntry())
untracked(() => {
- this.showFinalPlan();
+ this.clearCounterfactual();
this.liveMessage.set(this.tr().bookedDays.enter);
});
});
@@ -670,8 +452,6 @@ export class CalendarYear {
};
this.applyOutcome({ kind: 'PlanningIncomplete', completedStages: [], reasons: [reason] });
this.explanationError.set(reason);
- this.traceLoading.set(false);
- this.traceResolved.set(true);
this.loadingProgress.set(100);
this.isLoading.set(false);
}
@@ -683,20 +463,9 @@ export class CalendarYear {
this.planningOutcome.set(null);
this.planningExplanation.set(null);
this.explanationError.set(null);
- this.planningTrace.set(null);
- this.traceLoading.set(true);
- this.traceResolved.set(false);
this.selectedPlacementIndex.set(0);
this.planDetailsOpen.set(false);
- this.traceOpen.set(false);
this.clearCounterfactual();
- this.tracePhaseIndex.set(0);
- this.traceDecisionIndex.set(0);
- this.traceCandidatePage.set(0);
- this.traceMoment.set('before');
- this.traceReplay.set(false);
- this.clearHistoricalEligibility();
- this.selectedTraceDate.set(null);
this.selectedDates.set(this.calendarService.getSelectedDatesForHorizon(horizon));
this.userInput.remainingVacationDaysSignal.set([]);
}
@@ -736,44 +505,6 @@ export class CalendarYear {
this.explanationError.set(reason);
this.liveMessage.set(`${this.tr().planning.explanationError} ${reason.message}`);
});
- void calculation.trace
- .then((trace) => {
- if (calculation !== this.activeCalculation) 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 (calculation !== this.activeCalculation) return;
- this.planningTrace.set({
- status: 'failed',
- initialState: {
- calculationDate: calculation.snapshot.calculationDate,
- horizon: calculation.snapshot.horizon,
- ...(calculation.snapshot.declaredDays
- ? { declaredDays: calculation.snapshot.declaredDays }
- : {}),
- balances: [],
- placements: [],
- earnedActivations: [],
- },
- phases: [],
- completedStages: [],
- error: {
- code: 'trace-error',
- message: error instanceof Error ? error.message : String(error),
- },
- });
- this.traceResolved.set(true);
- this.liveMessage.set(this.tr().planning.traceUnavailable);
- })
- .finally(() => {
- if (calculation === this.activeCalculation) this.traceLoading.set(false);
- });
}
private applyOutcome(outcome: PlanningOutcome): void {
@@ -833,7 +564,6 @@ export class CalendarYear {
if (!explanation?.placements.length) return;
const selected = Math.max(0, Math.min(index, explanation.placements.length - 1));
this.selectedPlacementIndex.set(selected);
- this.traceReplay.set(false);
this.clearCounterfactual();
this.selectedCounterfactualDate.set(explanation.placements[selected].placement.date);
if (announce) this.liveMessage.set(this.placementAnnouncement());
@@ -863,19 +593,10 @@ export class CalendarYear {
if (open === this.planDetailsOpen()) return;
this.planDetailsOpen.set(open);
if (open) {
- this.traceOpen.set(false);
this.selectPlacement(this.selectedPlacementIndex());
} else this.clearCounterfactual();
}
- protected toggleTrace(event: Event): void {
- const open = (event.currentTarget as HTMLDetailsElement).open;
- if (open === this.traceOpen()) return;
- this.traceOpen.set(open);
- if (open) this.planDetailsOpen.set(false);
- this.showFinalPlan();
- }
-
protected stepPlacement(step: 'first' | 'previous' | 'next' | 'last'): void {
const last = (this.planExplanation()?.placements.length ?? 1) - 1;
const current = this.selectedPlacementIndex();
@@ -1064,7 +785,7 @@ export class CalendarYear {
return;
}
const declaredType = this.calendarDeclaredDays()[date];
- if (declaredType && !this.traceReplay() && !this.counterfactual()) {
+ if (declaredType && !this.counterfactual()) {
this.liveMessage.set(
`${this.displayDate(date)}. ${this.tr().bookedDays.booked} (${this.translationService.getDayTypeLabel(declaredType)}). ${this.tr().bookedDays.fixed}`,
);
@@ -1074,212 +795,34 @@ export class CalendarYear {
this.invalidComparisonDate.set(false);
this.comparisonDateInput.set(new Date(`${date}T00:00:00`));
}
- if (!this.traceReplay() && !this.counterfactual() && !this.counterfactualLoading()) {
+ if (!this.counterfactual() && !this.counterfactualLoading()) {
const index = this.planExplanation()?.placements.findIndex(
(explanation) => explanation.placement.date === date,
);
if (index !== undefined && index >= 0) {
this.planDetailsOpen.set(true);
- this.traceOpen.set(false);
this.selectPlacement(index);
return;
}
}
- if (this.traceReplay()) {
- this.selectedTraceDate.set(date);
- if (this.counterfactual()?.results.some((result) => result.date === date))
- this.selectedCounterfactualDate.set(date);
- this.liveMessage.set(this.dateStatusAnnouncement(date, this.dateStatuses()[date]));
- return;
- }
if (this.counterfactual()?.results.some((result) => result.date === date)) {
this.selectedCounterfactualDate.set(date);
- this.liveMessage.set(this.dateStatusAnnouncement(date, this.dateStatuses()[date]));
+ this.liveMessage.set(this.dateStatusAnnouncement(date));
}
}
- private dateStatusAnnouncement(date: string, status?: CalendarDayStatus): string {
+ private dateStatusAnnouncement(date: string): string {
+ const result = this.counterfactual()?.results.find((result) => result.date === date);
return [
date,
- status?.label,
- status?.reason,
- status?.hypotheticalLabel
- ? `${this.tr().planning.counterfactual}: ${status.hypotheticalLabel}`
- : undefined,
- status?.hypotheticalReason
- ? `${this.tr().planning.reason}: ${status.hypotheticalReason}`
- : undefined,
- status?.changeLabel,
+ this.tr().planning.counterfactual,
+ result ? this.tr().planning[result.status] : undefined,
+ result?.reason ? this.reasonLabel(result.reason) : undefined,
]
.filter(Boolean)
.join('. ');
}
- protected selectTracePhase(index: number): void {
- const last = (this.planningTrace()?.phases.length ?? 1) - 1;
- index = Math.max(0, Math.min(index, last));
- this.tracePhaseIndex.set(index);
- this.traceDecisionIndex.set(0);
- this.traceCandidatePage.set(0);
- this.traceMoment.set('before');
- this.traceReplay.set(false);
- this.clearHistoricalEligibility();
- this.selectedTraceDate.set(null);
- this.clearCounterfactual();
- 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();
- const target =
- step === 'first'
- ? 0
- : step === 'last'
- ? last
- : step === 'previous'
- ? current - 1
- : current + 1;
- if (target !== current) this.selectTracePhase(target);
- }
-
- protected selectTraceDecision(index: number): void {
- const last = (this.selectedTracePhase()?.decisions.length ?? 1) - 1;
- index = Math.max(0, Math.min(index, last));
- this.traceDecisionIndex.set(index);
- this.traceCandidatePage.set(0);
- this.traceMoment.set('before');
- this.traceReplay.set(true);
- this.selectedTraceDate.set(this.selectedTraceDecision()?.dates[0] ?? null);
- this.clearCounterfactual();
- 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();
- const target =
- step === 'first'
- ? 0
- : step === 'last'
- ? last
- : step === 'previous'
- ? current - 1
- : current + 1;
- if (target !== current) this.selectTraceDecision(target);
- }
-
- protected stepTraceCandidatePage(direction: -1 | 1): void {
- 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()}`,
- );
- }
-
- protected showTraceMoment(moment: TraceMoment): void {
- this.clearCounterfactual();
- this.traceMoment.set(moment);
- this.traceReplay.set(true);
- 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);
- }
-
- protected analyzeTraceCandidate(candidate: PlanningTraceCandidate): void {
- const decision = this.selectedTraceDecision();
- if (!candidate.placement || !decision) return;
- this.traceMoment.set('before');
- this.traceReplay.set(true);
- this.selectedTraceDate.set(candidate.placement.date);
- void this.analyzeSelection(
- { decisionId: decision.id, placement: candidate.placement },
- true,
- );
- }
-
- private nonWorkingDateReason(
- date: string,
- closedDayDates: ReadonlySet,
- ): string | undefined {
- const day = new Date(`${date}T00:00:00`);
- if (day.getDay() === 0 || day.getDay() === 6) return this.tr().dayTypes.weekend;
- if (closedDayDates.has(date)) return this.tr().dayTypes.closedDay;
- return undefined;
- }
-
private counterfactualSelection: CounterfactualSelection | null = null;
private async analyzeSelection(
@@ -1295,12 +838,7 @@ export class CalendarYear {
this.counterfactualLoading.set(true);
this.invalidComparisonDate.set(false);
if (openDialog) this.openComparisonDialog();
- const placement =
- typeof selection === 'string'
- ? selection
- : 'placement' in selection
- ? selection.placement
- : selection;
+ const placement = selection;
this.liveMessage.set(
`${this.tr().planning.counterfactualLoading} ${typeof placement === 'string' ? this.displayDate(placement) : this.displayDate(placement.date)}`,
);
@@ -1312,9 +850,7 @@ export class CalendarYear {
this.counterfactual.set(result);
if (openDialog || this.comparisonDialog)
this.selectCalendarDate(
- this.traceReplay()
- ? result.placement.date
- : (this.nearbyComparisonDates()[0]?.date ?? result.placement.date),
+ this.nearbyComparisonDates()[0]?.date ?? result.placement.date,
);
this.liveMessage.set(this.tr().planning.counterfactualReady);
}
@@ -1362,113 +898,6 @@ export class CalendarYear {
this.comparisonDateInput.set(null);
}
- private replayTraceTransition(
- trace: PlanningTrace | null,
- 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 => ({
- calculationDate: trace.initialState.calculationDate,
- horizon: { ...trace.initialState.horizon },
- ...(trace.initialState.declaredDays
- ? { declaredDays: trace.initialState.declaredDays }
- : {}),
- 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],
- })),
- });
- 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 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;
@@ -1480,18 +909,6 @@ export class CalendarYear {
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 {
const outcome = this.planningOutcome();
return (
@@ -1627,22 +1044,10 @@ export class CalendarYear {
return value instanceof Date ? value.toISOString().slice(0, 10) : value.slice(0, 10);
}
- protected traceStatusLabel(status: PlanningTraceCandidate['status']): string {
- 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];
- }
-
- protected traceChangeLabel(change: NonNullable): string {
- return this.tr().planning.traceChanges[change];
- }
-
protected formatIdentifier(value: string): string {
return value.replaceAll('-', ' ').replaceAll(':', ': ');
}
diff --git a/src/app/components/user-input/user-input.html b/src/app/components/user-input/user-input.html
index 4e4de88..abbd8b2 100644
--- a/src/app/components/user-input/user-input.html
+++ b/src/app/components/user-input/user-input.html
@@ -173,113 +173,7 @@
@if (day.suggestion) {
{{ day.suggestion }}
}
-
-
- {{ tr().userInput.remainingWhy }}
-
- {{ day.horizon }}
- @if (historyBalanceId() === day.id) {
- @if (!traceResolved()) {
- {{ tr().loading }}
- } @else {
- @if (!trace() || trace()?.status === 'failed') {
- {{ tr().planning.traceUnavailable }}
- }
- @if (remainingHistory().length) {
- {{ tr().userInput.remainingHistoryHelp }}
-
- @for (
- attempt of visibleHistory();
- track $index
- ) {
- -
- {{ attempt.date }} ·
- {{ attempt.status }}
- @if (attempt.phase) {
-
{{ attempt.phase }}
- }
- @if (attempt.reason) {
- {{ attempt.reason }}
- }
- @for (
- criterion of attempt.criteria;
- track $index
- ) {
-
- @if (criterion.decisive) {
- {{
- tr().planning.details
- .firstDifference
- }}
- :
- }
- {{ criterion.text }}
-
- }
-
- }
-
- @if (historyPageCount() > 1) {
-
- }
- } @else if (trace() && trace()?.status !== 'failed') {
- {{ tr().userInput.remainingNoEvaluations }}
- }
- }
- }
-
+ {{ day.horizon }}
}
diff --git a/src/app/components/user-input/user-input.scss b/src/app/components/user-input/user-input.scss
index ae3cb5d..fa1f4ae 100644
--- a/src/app/components/user-input/user-input.scss
+++ b/src/app/components/user-input/user-input.scss
@@ -239,33 +239,13 @@ form {
}
}
-.remaining-balances,
-.remaining-history {
+.remaining-balances {
display: grid;
gap: 1rem;
margin: 0.5rem 0;
padding-inline-start: 1.25rem;
}
-.remaining-day-detail summary {
- min-height: 2.75rem;
- align-content: center;
- cursor: pointer;
- text-decoration: underline;
- text-underline-offset: 0.25em;
-}
-
-.decisive {
- font-weight: 700;
-}
-
-.history-pagination {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 0.5rem;
-}
-
:host {
display: block;
container: user-input / inline-size;
diff --git a/src/app/components/user-input/user-input.spec.ts b/src/app/components/user-input/user-input.spec.ts
index cd3556d..bf6ff3b 100644
--- a/src/app/components/user-input/user-input.spec.ts
+++ b/src/app/components/user-input/user-input.spec.ts
@@ -98,11 +98,9 @@ describe('UserInput', () => {
} finally {
vi.stubGlobal('window', browserWindow);
}
- const [outcome, trace] = await Promise.all([calculation.outcome, calculation.trace]);
+ const outcome = await calculation.outcome;
userInputService.planningSnapshotSignal.set(calculation.snapshot);
userInputService.planningOutcomeSignal.set(outcome);
- userInputService.planningTraceSignal.set(trace ?? null);
- userInputService.traceResolvedSignal.set(true);
fixture.detectChanges();
}
@@ -134,7 +132,7 @@ describe('UserInput', () => {
expect(card.textContent).toBe(explanation);
});
- it('shows genuine recorded attempts on demand for usable leave kept for later', async () => {
+ it('explains usable leave kept for later and incomplete balances without attempt controls', async () => {
TestBed.inject(TranslationService).setLanguage('fr');
await calculate({
horizon: { start: '2024-06-04', end: '2024-06-13' },
@@ -158,17 +156,9 @@ describe('UserInput', () => {
expect(card.textContent).not.toContain('Disponible seulement');
expect(root.querySelector('.remaining-history')).toBeNull();
const outcome = JSON.stringify(userInputService.planningOutcomeSignal());
- const trace = JSON.stringify(userInputService.planningTraceSignal());
- const details = root.querySelector('.remaining-day-detail details')!;
- details.open = true;
- details.dispatchEvent(new Event('toggle'));
- fixture.detectChanges();
- expect(root.querySelectorAll('.remaining-history > li').length).toBeGreaterThan(0);
- expect(details.textContent).toContain('Ces dates ont réellement été examinées');
- expect(details.textContent).toContain(presets[3].name);
- expect(details.textContent).toContain('Évalué et rejeté');
+ expect(card.querySelector('details')).toBeNull();
+ expect(card.textContent).not.toContain('trace');
expect(JSON.stringify(userInputService.planningOutcomeSignal())).toBe(outcome);
- expect(JSON.stringify(userInputService.planningTraceSignal())).toBe(trace);
const plan = userInputService.displayedPlanSignal()!;
userInputService.planningOutcomeSignal.set({
@@ -177,17 +167,11 @@ describe('UserInput', () => {
completedStages: [],
reasons: [{ code: 'user-cancelled', message: 'Cancelled' }],
});
- userInputService.planningTraceSignal.set(null);
fixture.detectChanges();
expect(card.textContent).toContain('Sa non-utilisation n’est pas une décision définitive');
expect(card.textContent).not.toContain('Conservé pour plus tard');
expect(root.querySelector('.remaining-history')).toBeNull();
- details.open = true;
- details.dispatchEvent(new Event('toggle'));
- fixture.detectChanges();
- expect(details.textContent).toContain(
- TestBed.inject(TranslationService).tr().planning.traceUnavailable,
- );
+ expect(card.querySelector('details')).toBeNull();
});
it('describes Calculate with an indicative range that follows unsubmitted edits', () => {
diff --git a/src/app/components/user-input/user-input.ts b/src/app/components/user-input/user-input.ts
index cf3995f..3ae0ef6 100644
--- a/src/app/components/user-input/user-input.ts
+++ b/src/app/components/user-input/user-input.ts
@@ -1,11 +1,4 @@
-import {
- Component,
- computed,
- effect,
- inject,
- signal,
- ChangeDetectionStrategy,
-} from '@angular/core';
+import { Component, computed, effect, inject, ChangeDetectionStrategy } from '@angular/core';
import { MatInputModule } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatDatepickerModule } from '@angular/material/datepicker';
@@ -78,10 +71,6 @@ export class UserInput {
return this.translationService.tr;
}
- protected readonly trace = this.userInputService.planningTraceSignal;
- protected readonly traceResolved = this.userInputService.traceResolvedSignal;
- protected readonly historyBalanceId = signal(null);
- protected readonly historyPage = signal(0);
protected readonly remainingDays = computed(() =>
(this.userInputService.displayedPlanSignal()?.remainingBalances ?? []).reduce(
(total, balance) => total + balance.quantity,
@@ -145,57 +134,8 @@ export class UserInput {
});
});
- protected readonly remainingHistory = computed(() => {
- const id = this.historyBalanceId();
- if (!id) return [];
- return (
- this.trace()?.phases.flatMap((phase) =>
- phase.decisions.flatMap((decision) =>
- decision.candidates
- .filter((candidate) => candidate.placement?.balanceId === id)
- .map((candidate) => ({ candidate, phase: phase.name })),
- ),
- ) ?? []
- );
- });
- protected readonly historyPageCount = computed(() =>
- Math.ceil(this.remainingHistory().length / 20),
- );
- protected readonly visibleHistory = computed(() =>
- this.remainingHistory()
- .slice(this.historyPage() * 20, (this.historyPage() + 1) * 20)
- .map(({ candidate, phase }) => ({
- date: (candidate.dates ?? (candidate.date ? [candidate.date] : []))
- .map((date) => this.translationService.formatPlanningDate(date))
- .join(', '),
- phase: this.tr().planning.explanation.stages[phase] ?? this.criterionLabel(phase),
- status: this.tr().planning.traceStatuses[candidate.status],
- reason: candidate.reason
- ? (this.tr().userInput.remainingCandidateReasons[candidate.reason.code] ??
- this.tr().planning.explanation.unavailable[candidate.reason.code] ??
- this.tr().userInput.remainingCandidateReasons['unknown'])
- : '',
- criteria: (candidate.technicalComparison ?? []).flatMap((value) => {
- const label = this.criterionLabel(value.name);
- return label
- ? [
- {
- text: `${label} : ${value.before} → ${value.after}`,
- decisive: value.name === candidate.firstDecisiveComparator,
- },
- ]
- : [];
- }),
- })),
- );
-
constructor() {
effect(() => this.dateAdapter.setLocale(this.translationService.locale()));
- effect(() => {
- this.userInputService.planningOutcomeSignal();
- this.historyBalanceId.set(null);
- this.historyPage.set(0);
- });
}
protected setPlanningDate(field: 'start' | 'lastPlanningDate', value: Date | null): void {
@@ -213,29 +153,6 @@ export class UserInput {
);
}
- private criterionLabel(name: string): string | undefined {
- const id = name.replace(/^protected:/, '');
- if (id.startsWith('optimization:')) return this.ruleName(id.slice('optimization:'.length));
- const key =
- id === 'obligations'
- ? 'obligation-validity'
- : id.startsWith('preference:')
- ? 'preference'
- : id;
- return this.tr().planning.explanation.objectives[key]?.label.replace(
- '{rule}',
- this.ruleName(id.slice('preference:'.length)),
- );
- }
-
- protected toggleRemainingHistory(id: string, event: Event): void {
- const open = (event.currentTarget as HTMLDetailsElement).open;
- if (open && id !== this.historyBalanceId()) {
- this.historyPage.set(0);
- this.historyBalanceId.set(id);
- } else if (!open && id === this.historyBalanceId()) this.historyBalanceId.set(null);
- }
-
protected hasRemainingDays = computed(() => this.remainingDays() > 0);
protected get daysOff() {
diff --git a/src/app/services/calendar-objectives.worker.spec.ts b/src/app/services/calendar-objectives.worker.spec.ts
index f1f7473..032ed03 100644
--- a/src/app/services/calendar-objectives.worker.spec.ts
+++ b/src/app/services/calendar-objectives.worker.spec.ts
@@ -13,6 +13,8 @@ describe('calendar objectives in the real worker', () => {
let directory: string;
let bundle: string;
const threads = new Set();
+ const messages: string[] = [];
+ let stopped = 0;
beforeAll(async () => {
directory = mkdtempSync(join(tmpdir(), 'vac-calendar-worker-'));
@@ -30,6 +32,8 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
});
beforeEach(() => {
+ messages.length = 0;
+ stopped = 0;
vi.setSystemTime(new Date('2024-04-01T12:00:00Z'));
class BrowserWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
@@ -37,13 +41,17 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
private readonly thread = new NodeWorker(bundle);
constructor() {
threads.add(this.thread);
- this.thread.on('message', (data) => this.onmessage?.({ data } as MessageEvent));
+ this.thread.on('message', (data) => {
+ messages.push(data.type);
+ this.onmessage?.({ data } as MessageEvent);
+ });
this.thread.on('error', () => this.onerror?.());
}
postMessage(message: unknown) {
this.thread.postMessage(message);
}
terminate() {
+ stopped++;
void this.thread.terminate();
}
}
@@ -109,14 +117,19 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
if (explanation?.kind !== 'PlanExplanation')
throw new Error('Expected a worker explanation');
expect(explanation.placements[0].firstDecisiveObjective).toBe(first);
- expect((await calculation.trace)?.phases.map((phase) => phase.name)).toEqual([
- 'validation',
- 'obligations',
- first,
- `optimization:${rules[1].id}`,
- 'expiry',
- 'dates',
- ]);
+ await calculation.timings;
+ expect(new Set(messages)).toEqual(
+ new Set([
+ 'heartbeat',
+ 'progress',
+ 'checkpoint',
+ 'result',
+ 'explanation',
+ 'artifacts',
+ ]),
+ );
+ expect(messages.indexOf('result')).toBeLessThan(messages.indexOf('explanation'));
+ expect(stopped).toBe(1);
const comparison = await calculation.analyzeCounterfactual(plan.placements[0]);
if (comparison.kind !== 'Counterfactual')
throw new Error('Expected a worker comparison');
@@ -169,29 +182,6 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
name: 'Captured supporting economy',
configuration: OPTIMIZATION_PRESETS.supportingPlacements,
});
- const trace = await calculation.trace;
- expect(trace?.phases.map(({ name }) => name)).toEqual([
- 'validation',
- 'obligations',
- `optimization:${rules[0].id}`,
- `optimization:${rules[1].id}`,
- 'expiry',
- 'dates',
- ]);
- const candidates = trace?.phases.flatMap(({ decisions }) =>
- decisions.flatMap(({ candidates }) => candidates),
- );
- expect(candidates).toContainEqual(
- expect.objectContaining({
- status: economyFirst ? 'evaluated-rejected' : 'chosen',
- dates: ['2026-11-12', '2026-11-13'],
- comparison: expect.objectContaining({
- firstDecisiveObjective: economyFirst
- ? 'optimization:economy'
- : 'optimization:holiday',
- }),
- }),
- );
const explanation = await calculation.explanation;
if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected an explanation');
if (economyFirst) {
diff --git a/src/app/services/calendar-service.ts b/src/app/services/calendar-service.ts
index 68cdd4e..b3a658f 100644
--- a/src/app/services/calendar-service.ts
+++ b/src/app/services/calendar-service.ts
@@ -11,7 +11,7 @@ import {
LeaveType,
PlanOutcome,
PlanningReason,
- PlanningTraceState,
+ PlanningStateSnapshot,
RemainingClassification,
} from './leave-planner.service';
import { addPlanningDays, addPlanningYears, planningDateInParis } from './planning-date';
@@ -19,8 +19,8 @@ import { addPlanningDays, addPlanningYears, planningDateInParis } from './planni
dayjs.extend(weekOfYear);
dayjs.extend(isoWeek);
-type CalendarPlan = Pick & {
- horizon?: PlanningTraceState['horizon'];
+type CalendarPlan = Pick & {
+ horizon?: PlanningStateSnapshot['horizon'];
timeOffPeriods?: PlanOutcome['timeOffPeriods'];
};
diff --git a/src/app/services/leave-planner-calendar-objectives.spec.ts b/src/app/services/leave-planner-calendar-objectives.spec.ts
index 04e1c1b..948b3ef 100644
--- a/src/app/services/leave-planner-calendar-objectives.spec.ts
+++ b/src/app/services/leave-planner-calendar-objectives.spec.ts
@@ -417,7 +417,7 @@ describe('composed calendar objectives', () => {
expect(ordinary.metrics.holidayWeekValue).toBe(0);
});
- it('uses the same first calendar objective in the plan explanation, trace and date comparison', () => {
+ it('uses the same first calendar objective in the explanation and date comparison', () => {
for (const rules of [
[holiday, gain],
[gain, holiday],
@@ -439,15 +439,6 @@ describe('composed calendar objectives', () => {
'balance-expiry',
'placement-date',
]);
- const comparisons = artifacts.trace?.phases
- .flatMap((phase) => phase.decisions)
- .flatMap((decision) => decision.candidates)
- .filter((candidate) => candidate.status === 'chosen' && candidate.comparison);
- expect(
- comparisons?.some(
- (candidate) => candidate.comparison?.firstDecisiveObjective === first,
- ),
- ).toBe(true);
const placement = rules[0].id === 'holiday' ? plan.placements[1] : plan.placements[0];
const alternative = rules[0].id === 'holiday' ? '2024-04-30' : '2024-05-02';
const comparison = analyzeCounterfactualInternal(
@@ -455,11 +446,16 @@ describe('composed calendar objectives', () => {
plan,
placement,
artifacts.finalState,
- { removeDate: placement.date, compareOriginalState: true },
);
+ const original = comparison.results.find((result) => result.date === placement.date)
+ ?.comparison?.after;
+ const alternativeVector = comparison.results.find(
+ (result) => result.date === alternative,
+ )?.comparison?.after;
expect(
- comparison.results.find((result) => result.date === alternative)?.comparison
- ?.firstDecisiveObjective,
+ alternativeVector?.objectives.find(
+ (objective, index) => objective.value !== original?.objectives[index].value,
+ )?.id,
).toBe(first);
}
});
diff --git a/src/app/services/leave-planner-cancellation.spec.ts b/src/app/services/leave-planner-cancellation.spec.ts
index 7619e53..19d13fc 100644
--- a/src/app/services/leave-planner-cancellation.spec.ts
+++ b/src/app/services/leave-planner-cancellation.spec.ts
@@ -1,10 +1,6 @@
import { createOptimizationPresetRules } from './rule-service';
import { vi } from 'vitest';
-import {
- createPlanningCalculation,
- type PlanningIncompleteOutcome,
- type PlanningTraceUpdate,
-} from './leave-planner.service';
+import { createPlanningCalculation, type PlanningIncompleteOutcome } from './leave-planner.service';
const request = {
horizon: { start: '2024-01-01', end: '2025-01-01' },
@@ -46,7 +42,6 @@ describe('user cancellation', () => {
kind: 'PlanningIncomplete',
reasons: [{ code: 'user-cancelled' }],
});
- expect((await calculation.trace)?.status).toBe('incomplete');
});
it('retains the same declared-only periods and metrics in a cancelled valid plan', async () => {
@@ -96,7 +91,6 @@ describe('user cancellation', () => {
placements: [],
incomplete: { reason: { code: 'user-cancelled' } },
});
- expect(artifacts.trace?.status).toBe('incomplete');
expect(artifacts.finalState?.declaredDays).toEqual(submitted.declaredDays);
});
@@ -193,39 +187,47 @@ describe('user cancellation', () => {
kind: 'IncompleteExplanation',
reason: { code: 'stale-calculation' },
});
- expect((await replaced.trace)?.error).toMatchObject({ code: 'stale-calculation' });
expect(await current.outcome).toMatchObject({ kind: 'Plan' });
- await current.trace;
});
- it('retains a plan at least as good as every valid candidate actually evaluated before cancellation', async () => {
+ it('retains a plan at least as good as every valid checkpoint before cancellation', async () => {
const core = await import('./leave-planner.core');
let cancelled = false;
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
- const outcome = await core.calculatePlanningAsyncInternal(
- {
- horizon: request.horizon,
- balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-12-31' }],
- rules: [
- presets[0],
- ...[
- {
- id: 'january',
- type: 'time_window',
- force: 'preference',
- startDate: '2024-01-01',
- endDate: '2024-01-31',
- placeDays: 2,
- placeType: 'cp',
- },
- ],
- ...presets.slice(1),
+ const checkpoints: (readonly import('./leave-planner.core').PlanningObjective[])[] = [];
+ const submitted = {
+ horizon: request.horizon,
+ balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-12-31' }],
+ rules: [
+ presets[0],
+ ...[
+ {
+ id: 'january',
+ type: 'time_window',
+ force: 'preference',
+ startDate: '2024-01-01',
+ endDate: '2024-01-31',
+ placeDays: 2,
+ placeType: 'cp',
+ },
],
- progress: (stage: string) => {
- if (stage === 'optimization:default-longest-work-sequence') cancelled = true;
- },
+ ...presets.slice(1),
+ ],
+ progress: (stage: string) => {
+ if (stage === 'optimization:default-longest-work-sequence') cancelled = true;
+ },
+ };
+ const outcome = await core.calculatePlanningAsyncInternal(
+ submitted,
+ (plan, state) => {
+ if (!state) return;
+ const explained = core.explainIncompleteCheckpoint(submitted, plan, state, {
+ code: 'user-cancelled',
+ message: 'Cancelled',
+ }).explanation;
+ if (explained?.kind === 'PlanExplanation' && explained.placements.length)
+ checkpoints.push(explained.placements[0].objectiveVector.objectives);
},
- undefined,
(value) => (artifacts = value),
undefined,
undefined,
@@ -235,36 +237,25 @@ describe('user cancellation', () => {
kind: 'PlanningIncomplete',
reasons: [{ code: 'user-cancelled' }],
});
- if (artifacts.explanation?.kind !== 'PlanExplanation' || !artifacts.trace)
- throw new Error('Expected a retained plan explanation and its original trace.');
+ if (artifacts.explanation?.kind !== 'PlanExplanation')
+ throw new Error('Expected a retained plan explanation.');
const retained = artifacts.explanation.placements[0].objectiveVector.objectives;
- const evaluated = artifacts.trace.phases.flatMap((phase) =>
- phase.decisions.flatMap((decision) =>
- decision.candidates.flatMap((candidate) =>
- candidate.comparison?.after.objectives[0].value === 0
- ? [candidate.comparison.after.objectives]
- : [],
- ),
- ),
- );
- expect(evaluated.length).toBeGreaterThan(0);
- for (const candidate of evaluated) {
+ expect(checkpoints.length).toBeGreaterThan(0);
+ for (const candidate of checkpoints) {
expect(candidate.map(({ id }) => id)).toEqual(retained.map(({ id }) => id));
- const firstDifference = retained.findIndex(
+ const first = retained.findIndex(
(objective, index) => objective.value !== candidate[index].value,
);
- if (firstDifference < 0) continue;
- const chosen = retained[firstDifference];
- const alternative = candidate[firstDifference];
+ if (first < 0) continue;
expect(
- chosen.direction === 'minimize'
- ? chosen.value < alternative.value
- : chosen.value > alternative.value,
+ retained[first].direction === 'minimize'
+ ? retained[first].value < candidate[first].value
+ : retained[first].value > candidate[first].value,
).toBe(true);
}
});
- it('requests interruption before a valid plan without terminating trace delivery', async () => {
+ it('requests interruption before a valid plan and terminates after required delivery', async () => {
const calculation = createPlanningCalculation(request);
calculation.cancel();
calculation.cancel();
@@ -285,13 +276,11 @@ describe('user cancellation', () => {
expect(await calculation.outcome).toEqual({ ...cancelled, calculationDate: '2024-01-01' });
expect(currentWorker().terminate).not.toHaveBeenCalled();
currentWorker().emit({ type: 'artifacts', artifacts: {} });
- await calculation.trace;
expect(currentWorker().terminate).toHaveBeenCalledOnce();
});
- it('drains records queued before and after cancellation while keeping the partial plan on artifact error', async () => {
+ it('keeps a cancelled partial plan after explanation delivery fails', async () => {
const core = await import('./leave-planner.core');
- const updates: PlanningTraceUpdate[] = [];
let interrupted = false;
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
const submitted = {
@@ -302,8 +291,6 @@ describe('user cancellation', () => {
const cancelled = await core.calculatePlanningAsyncInternal(
{
...submitted,
- traceProgress: (_: number, update: PlanningTraceUpdate) =>
- updates.push(structuredClone(update)),
progress: (stage: string) => {
if (stage === 'optimization:default-non-working-day-gain') interrupted = true;
},
@@ -314,20 +301,9 @@ describe('user cancellation', () => {
undefined,
() => interrupted,
);
- if (cancelled.kind !== 'PlanningIncomplete' || !cancelled.incumbent || !artifacts.trace)
- throw new Error('Expected a cancelled fixture with a valid plan and original trace.');
+ if (cancelled.kind !== 'PlanningIncomplete' || !cancelled.incumbent)
+ throw new Error('Expected a cancelled fixture with a valid plan.');
const calculation = createPlanningCalculation(submitted);
- const operations = updates.flatMap(({ operations }) => operations);
- const split = Math.floor(operations.length / 2);
- currentWorker().emit({
- type: 'trace-update',
- totalMs: 0,
- acknowledge: true,
- update: {
- ...updates[0],
- operations: operations.slice(0, split),
- },
- });
calculation.cancel();
currentWorker().emit({ type: 'result', outcome: cancelled });
currentWorker().emit({ type: 'result', outcome: cancelled.incumbent });
@@ -336,29 +312,14 @@ describe('user cancellation', () => {
explanationError: { code: 'explanation-error', message: 'Unavailable' },
});
currentWorker().emit({
- type: 'trace-update',
- totalMs: 0,
- acknowledge: true,
- update: {
- operations: operations.slice(split),
- },
- });
- const trace = artifacts.trace;
- currentWorker().emit({
- type: 'artifacts-start',
+ type: 'artifacts',
artifacts: {},
- trace: {
- status: trace.status,
- initialState: trace.initialState,
- completedStages: trace.completedStages,
- nonWorkingDates: trace.nonWorkingDates,
- error: trace.error,
- },
});
- currentWorker().emit({ type: 'artifacts-complete' });
expect(await calculation.outcome).toEqual({ ...cancelled, calculationDate: '2024-01-01' });
expect(await calculation.explanationError).toMatchObject({ code: 'explanation-error' });
- expect(await calculation.trace).toEqual(trace);
+ expect(currentWorker().terminate).toHaveBeenCalledOnce();
+ expect(currentWorker().postMessage).toHaveBeenCalledTimes(2);
+ expect(artifacts.finalState?.placements).toEqual(cancelled.incumbent.placements);
});
it.each([false, true])(
@@ -399,7 +360,6 @@ describe('user cancellation', () => {
kind: 'PlanningIncomplete',
reasons: [{ code: 'worker-error' }],
});
- expect((await calculation.trace)?.status).toBe('failed');
});
it('keeps a cancellation accepted before a queued completion and adapts its explanation without replay', async () => {
@@ -424,7 +384,6 @@ describe('user cancellation', () => {
kind: 'PlanExplanation',
incomplete: { reason: { code: 'user-cancelled' } },
});
- expect((await calculation.trace)?.status).toBe('incomplete');
expect(currentWorker().postMessage).toHaveBeenCalledTimes(2);
});
@@ -453,7 +412,6 @@ describe('user cancellation', () => {
currentWorker().emit({ type: 'result', outcome });
currentWorker().emit({ type: 'artifacts', artifacts: {} });
expect(await newer.outcome).toEqual({ ...outcome, calculationDate: '2024-01-01' });
- await newer.trace;
const sent = currentWorker().postMessage.mock.calls.length;
newer.cancel();
expect(currentWorker().postMessage).toHaveBeenCalledTimes(sent);
diff --git a/src/app/services/leave-planner-explanation.spec.ts b/src/app/services/leave-planner-explanation.spec.ts
index ca68131..4ee6bee 100644
--- a/src/app/services/leave-planner-explanation.spec.ts
+++ b/src/app/services/leave-planner-explanation.spec.ts
@@ -1,6 +1,6 @@
import { createOptimizationPresetRules } from './rule-service';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { createPlanningCalculation, applyReplayDeltas } from './leave-planner.service';
+import { createPlanningCalculation } from './leave-planner.service';
const balance = (
id: string,
@@ -18,8 +18,92 @@ afterEach(() => vi.useRealTimers());
const presets = createOptimizationPresetRules();
-describe('planner explanation and trace seam', () => {
- it('replays a reported occurrence without borrowing its future balance from the final plan', async () => {
+describe('planner explanation and alternative dates', () => {
+ it('delivers only the displayed plan, explanation and state needed for alternative dates', async () => {
+ const calculation = createPlanningCalculation({
+ horizon: { start: '2024-01-02', end: '2024-01-10' },
+ balances: [balance('cp', 'cp', 1, '2024-01-09')],
+ rules: [presets[0]],
+ });
+ const outcome = await calculation.outcome;
+ if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
+ expect(Object.keys(calculation).sort()).toEqual([
+ 'analyzeCounterfactual',
+ 'cancel',
+ 'explanation',
+ 'explanationError',
+ 'id',
+ 'outcome',
+ 'snapshot',
+ 'timings',
+ ]);
+ const core = await import('./leave-planner.core');
+ let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
+ expect(
+ core.calculatePlanningInternal(
+ calculation.snapshot,
+ undefined,
+ (value) => (artifacts = value),
+ ),
+ ).toEqual(outcome);
+ expect(Object.keys(artifacts).sort()).toEqual([
+ 'explanation',
+ 'explanationError',
+ 'finalState',
+ 'timings',
+ ]);
+ expect(artifacts.finalState?.placements).toEqual(outcome.placements);
+ expect(Object.keys(artifacts.timings ?? {})).toEqual(['planningWorkMs']);
+ const original = structuredClone(outcome);
+ const analysis = await calculation.analyzeCounterfactual(outcome.placements[0]);
+ expect(analysis.results).toHaveLength(8);
+ for (const result of analysis.results) {
+ expect(result).not.toHaveProperty('historicalStatus');
+ expect(result).not.toHaveProperty('historicalReason');
+ }
+ expect(outcome).toEqual(original);
+ });
+
+ it('excludes observers from the technical budget and keeps the outcome after explanation delivery fails', async () => {
+ const core = await import('./leave-planner.core');
+ let elapsed = 0;
+ const clock = vi.spyOn(performance, 'now').mockImplementation(() => elapsed);
+ let delivered = false;
+ let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
+ try {
+ const outcome = core.calculatePlanningInternal(
+ {
+ horizon: { start: '2024-01-02', end: '2024-01-10' },
+ balances: [],
+ rules: [],
+ deadlineMs: 10,
+ progress: () => (elapsed += 100),
+ },
+ () => (elapsed += 500),
+ (value) => (artifacts = value),
+ () => (delivered = true),
+ () => {
+ expect(delivered).toBe(true);
+ elapsed += 1000;
+ throw new Error('Explanation delivery failed.');
+ },
+ );
+ expect(outcome.kind).toBe('Plan');
+ expect(artifacts.explanationError).toMatchObject({
+ code: 'explanation-error',
+ message: 'Explanation delivery failed.',
+ });
+ expect(artifacts.finalState?.horizon).toEqual({
+ start: '2024-01-02',
+ end: '2024-01-10',
+ });
+ expect(artifacts.timings?.planningWorkMs).toBe(0);
+ } finally {
+ clock.mockRestore();
+ }
+ });
+
+ it('keeps earned availability after a reported occurrence is extended', async () => {
vi.setSystemTime(new Date('2024-05-03T12:00:00Z'));
const core = await import('./leave-planner.core');
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
@@ -67,42 +151,25 @@ describe('planner explanation and trace seam', () => {
undefined,
(value) => (artifacts = value),
);
- if (outcome.kind !== 'Plan' || !artifacts.trace || !artifacts.finalState)
- throw new Error('Expected the extended generator to finance a traced plan.');
- const replay = {
- balances: artifacts.trace.initialState.balances.map((item) => ({ ...item })),
- placements: new Map(
- artifacts.trace.initialState.placements.map((item) => [item.date, item]),
- ),
- earnedActivations: [...artifacts.trace.initialState.earnedActivations],
- };
+ if (outcome.kind !== 'Plan' || !artifacts.finalState)
+ throw new Error('Expected the extended generator to finance a plan.');
const earnedId = 'earned:producer:2024-05-01';
- let recognized = false;
- for (const decision of artifacts.trace.phases.flatMap(({ decisions }) => decisions)) {
- applyReplayDeltas(replay, decision.deltas, artifacts.finalState);
- if (
- decision.deltas.some(
- ({ kind, balanceId }) => kind === 'earned-activated' && balanceId === earnedId,
- )
- ) {
- recognized = true;
- expect(replay.earnedActivations.map(({ id }) => id)).toContain(earnedId);
- expect(replay.balances.map(({ id }) => id)).not.toContain(earnedId);
- }
- }
- expect(recognized).toBe(true);
- expect(replay.balances).toEqual(artifacts.finalState.balances);
- expect([...replay.placements.values()]).toEqual(artifacts.finalState.placements);
- expect(replay.earnedActivations).toEqual(artifacts.finalState.earnedActivations);
- expect(replay.balances.find(({ id }) => id === earnedId)).toMatchObject({
- remaining: 0,
- availableFrom: '2024-05-06',
- expiryDate: '2025-05-06',
- });
+ expect(artifacts.finalState.balances).toContainEqual(
+ expect.objectContaining({
+ id: earnedId,
+ availableFrom: '2024-05-06',
+ expiryDate: '2025-05-06',
+ remaining: 0,
+ }),
+ );
+ expect(outcome.placements.find(({ balanceId }) => balanceId === earnedId)?.date).toBe(
+ '2024-05-07',
+ );
+ expect(artifacts.finalState.placements).toEqual(outcome.placements);
});
it.each([5, 8])(
- 'keeps %s unrelated obligation branches linear when a declared entitlement is already reported',
+ 'keeps %s unrelated obligations valid without duplicating a reported entitlement',
async (count) => {
vi.setSystemTime(new Date('2024-05-06T12:00:00Z'));
const core = await import('./leave-planner.core');
@@ -116,74 +183,58 @@ describe('planner explanation and trace seam', () => {
'2024-04-25',
'2024-04-26',
].slice(0, count);
- const branchCounts: number[] = [];
for (const rewardDays of [0, 1]) {
- let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
- const outcome = core.calculatePlanningInternal(
- {
- horizon: { start: '2024-04-17', end: '2024-05-08' },
- declaredDays: [
- { date: '2024-04-29', type: 'cp' },
- { date: '2024-04-30', type: 'cp' },
- ],
- balances: [
- {
- id: 'other',
- type: 'other',
- quantity: dates.length,
- availableFrom: '2024-04-17',
- expiryDate: '2025-12-31',
- },
- ],
- rules: [
- ...dates.map((date) => ({
- id: date,
- type: 'time_window' as const,
- force: 'obligation' as const,
- startDate: date,
- endDate: date,
- placeDays: 1,
- placeType: 'other' as const,
- })),
- {
- id: 'producer',
- type: 'if_then',
- force: 'preference',
- dayOfWeek: 3,
- placement: 'before',
- placeDays: 2,
- placeType: 'cp',
- rewardDays,
- rewardType: 'other',
- },
- ],
- },
- undefined,
- (value) => (artifacts = value),
- );
- if (outcome.kind !== 'Plan' || !artifacts.trace)
- throw new Error('Expected a traced completed plan.');
+ const outcome = core.calculatePlanningInternal({
+ horizon: { start: '2024-04-17', end: '2024-05-08' },
+ declaredDays: [
+ { date: '2024-04-29', type: 'cp' },
+ { date: '2024-04-30', type: 'cp' },
+ ],
+ balances: [
+ {
+ id: 'other',
+ type: 'other',
+ quantity: dates.length,
+ availableFrom: '2024-04-17',
+ expiryDate: '2025-12-31',
+ },
+ ],
+ rules: [
+ ...dates.map((date) => ({
+ id: date,
+ type: 'time_window' as const,
+ force: 'obligation' as const,
+ startDate: date,
+ endDate: date,
+ placeDays: 1,
+ placeType: 'other' as const,
+ })),
+ {
+ id: 'producer',
+ type: 'if_then',
+ force: 'preference',
+ dayOfWeek: 3,
+ placement: 'before',
+ placeDays: 2,
+ placeType: 'cp',
+ rewardDays,
+ rewardType: 'other',
+ },
+ ],
+ });
+ if (outcome.kind !== 'Plan') throw new Error('Expected a completed plan.');
expect(outcome.placements.map(({ date }) => date)).toEqual(dates);
expect(outcome.remainingBalances).toEqual([]);
- branchCounts.push(
- artifacts.trace.phases
- .find(({ name }) => name === 'obligations')
- ?.decisions.filter(({ deltas }) =>
- deltas.some(({ placement }) => placement !== undefined),
- ).length ?? 0,
- );
if (rewardDays)
expect(outcome.ifThenContributions?.[0].earnedEntitlement).toMatchObject({
availableFrom: '2024-05-02',
added: false,
});
}
- expect(branchCounts[0]).toBeGreaterThan(0);
- expect(branchCounts[1]).toBe(branchCounts[0]);
},
);
- it('replays an already-reported entitlement without inventing a transient earned balance under explicit priorities', async () => {
+ it('keeps reported entitlements out of the final state under explicit priorities', async () => {
vi.setSystemTime(new Date('2024-05-06T12:00:00Z'));
const core = await import('./leave-planner.core');
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
@@ -224,69 +275,17 @@ describe('planner explanation and trace seam', () => {
undefined,
(value) => (artifacts = value),
);
- if (outcome.kind !== 'Plan' || !artifacts.trace || !artifacts.finalState)
- throw new Error('Expected a traced completed plan.');
+ if (outcome.kind !== 'Plan' || !artifacts.finalState)
+ throw new Error('Expected a completed plan.');
expect(outcome.placements).toMatchObject([{ date: '2024-05-07', balanceId: 'cp' }]);
expect(outcome.remainingBalances).toEqual([]);
expect(outcome.ifThenContributions?.[0].earnedEntitlement).toMatchObject({
availableFrom: '2024-05-06',
added: false,
});
- const decisions = artifacts.trace.phases.flatMap(({ decisions }) => decisions);
- const deltas = decisions.flatMap(({ deltas }) => deltas);
- expect(deltas).toContainEqual(
- expect.objectContaining({ kind: 'balance-changed', balanceId: 'cp' }),
- );
- expect(deltas).not.toContainEqual(
- expect.objectContaining({
- kind: 'balance-changed',
- balanceId: 'earned:producer:2024-05-01',
- }),
- );
- const initial = artifacts.trace.initialState;
- const replay = {
- balances: initial.balances.map((balance) => ({ ...balance })),
- placements: new Map(
- initial.placements.map((placement) => [
- placement.date,
- { ...placement, ruleIds: [...placement.ruleIds] },
- ]),
- ),
- earnedActivations: initial.earnedActivations.map((activation) => ({
- ...activation,
- dates: [...activation.dates],
- })),
- };
- for (const decision of decisions) applyReplayDeltas(replay, decision.deltas);
- expect(replay.balances).toEqual(artifacts.finalState.balances);
- expect(replay.earnedActivations).toEqual(artifacts.finalState.earnedActivations);
- expect([...replay.placements.values()]).toEqual(outcome.placements);
});
- 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 () => {
+ it('associates every joint obligation with its shared placement', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-01-08' },
balances: [balance('cp', 'cp', 2, '2024-12-31')],
@@ -312,151 +311,13 @@ describe('planner explanation and trace seam', () => {
],
});
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);
+ if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
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: [presets[0]],
- });
- 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 === 'optimization:default-unplaced-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.find((candidate) => candidate.status === 'chosen')).toMatchObject({
- technicalComparison: [
- { name: 'obligation-validity', direction: 'minimize', before: 0, after: 0 },
- {
- name: `optimization:${presets[0].id}`,
- direction: 'minimize',
- before: 2,
- after: 1,
- },
- ],
- firstDecisiveComparator: `optimization:${presets[0].id}`,
- });
- 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.find((candidate) => candidate.status === 'chosen')).toMatchObject({
- technicalComparison: [
- { name: 'obligation-validity', direction: 'minimize', before: 0, after: 0 },
- {
- name: `optimization:${presets[0].id}`,
- direction: 'minimize',
- before: 1,
- after: 0,
- },
- ],
- firstDecisiveComparator: `optimization:${presets[0].id}`,
- });
- 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 () => {
@@ -478,6 +339,7 @@ describe('planner explanation and trace seam', () => {
const outcome = await calculation.outcome;
const explanation = await calculation.explanation;
expect(outcome.kind).toBe('Plan');
+ if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
expect(explanation?.kind).toBe('PlanExplanation');
if (explanation?.kind !== 'PlanExplanation') return;
@@ -498,7 +360,7 @@ describe('planner explanation and trace seam', () => {
expect(placement.firstDecisiveObjective).toBe('obligation-validity');
});
- it('records the balance order used for a shared ANY preference placement', async () => {
+ it('uses the earlier-expiring balance for shared ANY preferences', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-01-08' },
balances: [
@@ -528,9 +390,8 @@ describe('planner explanation and trace seam', () => {
],
});
const outcome = await calculation.outcome;
- const trace = await calculation.trace;
expect(outcome.kind).toBe('Plan');
- if (outcome.kind !== 'Plan' || !trace) return;
+ if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
const sharedPlacement = outcome.placements.find((placement) =>
placement.ruleIds.includes('any-preference'),
@@ -540,116 +401,9 @@ describe('planner explanation and trace seam', () => {
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({
- firstDecisiveComparator: 'preference:rtt-preference',
- placement: expect.objectContaining({ balanceId: 'rtt-first' }),
- });
- const compared = trace.phases
- .find((phase) => phase.name === 'preference:any-preference')
- ?.decisions.flatMap((decision) => decision.candidates);
- expect(compared).toContainEqual(
- expect.objectContaining({
- status: 'evaluated-rejected',
- placement: expect.objectContaining({ balanceId: 'rtt-second' }),
- technicalComparison: expect.arrayContaining([
- {
- name: 'preference:any-preference',
- direction: 'maximize',
- before: 1,
- after: 1,
- },
- {
- name: 'preference:rtt-preference',
- direction: 'maximize',
- before: 1,
- after: 1,
- },
- {
- name: 'balance-expiry',
- direction: 'minimize',
- before: '2025-01-05',
- after: '2025-01-06',
- },
- ]),
- firstDecisiveComparator: 'balance-expiry',
- }),
- );
});
- 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 === 'preference: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 === 'preference: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: 'preference:if-then-preference',
- direction: 'maximize',
- }),
- ]),
- );
- expect(
- (chosen?.decision.branch?.deltas ?? chosen?.decision.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 () => {
+ it('keeps an earned obligation entitlement and its consumer chronological', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-04-25', end: '2024-05-08' },
balances: [
@@ -680,29 +434,13 @@ describe('planner explanation and trace seam', () => {
],
});
const outcome = await calculation.outcome;
- const trace = await calculation.trace;
expect(outcome.kind).toBe('Plan');
- if (outcome.kind !== 'Plan' || !trace) return;
+ if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
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 () => {
@@ -737,7 +475,7 @@ describe('planner explanation and trace seam', () => {
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('Plan');
- if (outcome.kind !== 'Plan') return;
+ if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
const producer = outcome.placements.find((placement) =>
placement.ruleIds.includes('producer'),
);
@@ -802,7 +540,7 @@ describe('planner explanation and trace seam', () => {
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('Plan');
- if (outcome.kind !== 'Plan') return;
+ if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
const atRisk = outcome.placements.find(
(placement) =>
placement.balanceId === 'cp2' &&
@@ -823,7 +561,7 @@ describe('planner explanation and trace seam', () => {
).toBeUndefined();
});
- it('records earned balance availability metadata changes when a shared period extends', async () => {
+ it('keeps earned balance metadata consistent when a shared period extends', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-04-25', end: '2024-05-15' },
balances: [
@@ -863,30 +601,13 @@ describe('planner explanation and trace seam', () => {
],
});
const outcome = await calculation.outcome;
- const trace = await calculation.trace;
expect(outcome.kind).toBe('Plan');
- if (outcome.kind !== 'Plan' || !trace) return;
+ if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
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 () => {
@@ -907,7 +628,7 @@ describe('planner explanation and trace seam', () => {
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('Plan');
- if (outcome.kind !== 'Plan') return;
+ if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
const selected = outcome.placements.find((placement) =>
placement.ruleIds.includes('exact-date'),
);
@@ -1022,27 +743,6 @@ describe('planner explanation and trace seam', () => {
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 === 'optimization:default-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({
@@ -1052,7 +752,6 @@ describe('planner explanation and trace seam', () => {
});
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 () => {
@@ -1094,7 +793,7 @@ describe('planner explanation and trace seam', () => {
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;
+ let checkpointState: import('./leave-planner.core').PlanningStateSnapshot | undefined;
const request = {
horizon: { start: '2024-01-01', end: '2024-01-10' },
balances: [balance('cp', 'cp', 2, '2024-01-31')],
@@ -1124,7 +823,7 @@ describe('planner explanation and trace seam', () => {
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;
+ let completedState: import('./leave-planner.core').PlanningStateSnapshot | undefined;
const completed = core.calculatePlanningInternal(
{ ...request, progress: undefined },
(plan, state) => {
@@ -1144,116 +843,7 @@ describe('planner explanation and trace seam', () => {
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: presets,
- deadlineMs: 100_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);
- expect(artifacts?.timings?.traceTransferMs).toBeGreaterThan(request.deadlineMs);
- 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: [presets[0]],
- };
- 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,
- })),
- );
- expect(candidates.length).toBeGreaterThan(0);
- 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 () => {
+ it('explains incomplete calculations before and after a valid plan', async () => {
const early = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-01-10' },
balances: [balance('cp', 'cp', 1, '2024-01-31')],
@@ -1261,22 +851,9 @@ describe('planner explanation and trace seam', () => {
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 === 'optimization:default-unplaced-at-risk',
- )?.status,
- ).toBe('not-run');
const quality = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-02-01' },
@@ -1289,7 +866,6 @@ describe('planner explanation and trace seam', () => {
},
});
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');
@@ -1298,16 +874,5 @@ describe('planner explanation and trace seam', () => {
'obligations',
'optimization:default-unplaced-at-risk',
]);
- expect(qualityTrace?.completedStages).toEqual(qualityOutcome.completedStages);
- expect(
- qualityTrace?.phases.find(
- (phase) => phase.name === 'optimization:default-holiday-week-value',
- )?.status,
- ).toBe('interrupted');
- expect(
- qualityTrace?.phases.find(
- (phase) => phase.name === 'optimization:default-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
deleted file mode 100644
index 097cf88..0000000
--- a/src/app/services/leave-planner-quality-trace.spec.ts
+++ /dev/null
@@ -1,169 +0,0 @@
-import { createOptimizationPresetRules } from './rule-service';
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { createPlanningCalculation } from './leave-planner.service';
-import {
- calculatePlanningInternal,
- type PlanningArtifacts,
- type PlanningTraceCandidate,
- type PlanningTraceUpdate,
-} from './leave-planner.core';
-import { OPTIMIZATION_PRESETS } from './planning-objectives';
-
-beforeEach(() => vi.stubGlobal('window', undefined));
-afterEach(() => vi.unstubAllGlobals());
-
-const presets = createOptimizationPresetRules();
-
-describe('quality calculation trace', () => {
- it('keeps emitted comparison metadata unchanged when candidate snapshots are replaced', () => {
- const updates: PlanningTraceUpdate[] = [];
- const captured: string[] = [];
- let artifacts: PlanningArtifacts = {};
- let clock = 0;
- const clockSpy = vi.spyOn(performance, 'now').mockImplementation(() => (clock += 30));
- try {
- const outcome = calculatePlanningInternal(
- {
- calculationDate: '2026-11-01',
- horizon: { start: '2026-11-03', end: '2026-11-14' },
- balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2027-01-01' }],
- declaredDays: [
- { date: '2026-11-09', type: 'other' },
- { date: '2026-11-10', type: 'other' },
- ],
- rules: [
- {
- id: 'holiday',
- name: 'Holiday weeks',
- type: 'optimization',
- configuration: OPTIMIZATION_PRESETS.holidayWeekValue,
- },
- ...[3, 5].map((thresholdDays) => ({
- id: `deficit-${thresholdDays}`,
- name: `Deficit ${thresholdDays} days`,
- type: 'optimization',
- configuration: {
- ...OPTIMIZATION_PRESETS.shortSequenceDeficit,
- conditions: ['non-empty'],
- thresholdDays,
- },
- })),
- ],
- traceProgress: (_elapsed: number, update: PlanningTraceUpdate) => {
- updates.push(update);
- captured.push(JSON.stringify(update));
- },
- },
- undefined,
- (value) => (artifacts = value),
- );
- expect(outcome.kind).toBe('Plan');
- expect(artifacts.trace?.status).toBe('ready');
- expect(updates.map((update) => JSON.stringify(update))).toEqual(captured);
- const emitted = new Map();
- let replacements = 0;
- for (const update of updates)
- for (const operation of update.operations) {
- if (operation.kind !== 'candidate') continue;
- if (operation.mode === 'replace') replacements++;
- emitted.set(
- `${operation.phaseId}/${operation.decisionId}/${operation.index}`,
- operation.candidate,
- );
- }
- expect(replacements).toBeGreaterThan(0);
- expect(emitted.size).toBeGreaterThan(0);
- for (const phase of artifacts.trace!.phases)
- for (const decision of phase.decisions)
- decision.candidates.forEach((candidate, index) =>
- expect(emitted.get(`${phase.id}/${decision.id}/${index}`)).toEqual(
- candidate,
- ),
- );
- const comparison = [...emitted.values()].find(
- (candidate) => candidate.comparison,
- )?.comparison;
- expect(comparison?.after.objectives).toEqual(
- expect.arrayContaining([
- expect.objectContaining({
- id: 'optimization:deficit-3',
- label: 'Deficit 3 days',
- direction: 'minimize',
- }),
- expect.objectContaining({
- id: 'optimization:deficit-5',
- label: 'Deficit 5 days',
- direction: 'minimize',
- }),
- ]),
- );
- expect(
- [...emitted.values()].some(({ comparison }) => {
- const values = comparison?.after.objectives;
- return (
- values?.find(({ id }) => id === 'optimization:deficit-3')?.value === 0 &&
- values?.find(({ id }) => id === 'optimization:deficit-5')?.value === 1
- );
- }),
- ).toBe(true);
- } finally {
- clockSpy.mockRestore();
- }
- });
-
- 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: presets,
- });
- expect((await calculation.outcome).kind).toBe('Plan');
- const trace = await calculation.trace;
- expect(trace?.status).toBe('ready');
- const phase = trace?.phases.find(({ name }) => name === `optimization:${presets[0].id}`);
- const decision = phase?.decisions.find(({ candidates }) =>
- candidates.some(({ status, date }) => status === 'chosen' && date === '2024-01-02'),
- );
- const rejected = decision?.candidates.find(
- ({ date, comparison }) => date === '2024-01-05' && comparison,
- );
- expect(rejected?.status).toBe('evaluated-rejected');
- const sourceValue = (state: 'before' | 'after', id: string) =>
- rejected?.comparison?.[state].objectives.find((objective) => objective.id === id)
- ?.value;
- expect(sourceValue('before', `optimization:${presets[0].id}`)).toBe(2);
- expect(sourceValue('after', `optimization:${presets[0].id}`)).toBe(1);
- expect(sourceValue('before', `optimization:${presets[2].id}`)).toBe(0);
- expect(sourceValue('after', `optimization:${presets[2].id}`)).toBe(2);
- expect(rejected?.comparison?.firstDecisiveObjective).toBe(`optimization:${presets[0].id}`);
- // The January 2 candidate already gains three natural non-working days.
- // January 5 improves its source state, but loses to that current best candidate.
- expect(rejected?.technicalComparison).toContainEqual({
- name: `optimization:${presets[2].id}`,
- direction: 'maximize',
- before: 3,
- after: 2,
- });
- expect(
- rejected?.technicalComparison?.some(
- ({ name }) => name === `optimization:${presets[3].id}`,
- ),
- ).toBe(false);
- expect(rejected?.firstDecisiveComparator).toBe(`optimization:${presets[2].id}`);
- expect(rejected?.reason?.message).toContain('did not improve the current best result');
- expect(decision?.deltas).toContainEqual(
- expect.objectContaining({
- kind: 'placement-added',
- date: '2024-01-02',
- }),
- );
- });
-});
diff --git a/src/app/services/leave-planner-supporting-placements.spec.ts b/src/app/services/leave-planner-supporting-placements.spec.ts
index 103cb31..814181a 100644
--- a/src/app/services/leave-planner-supporting-placements.spec.ts
+++ b/src/app/services/leave-planner-supporting-placements.spec.ts
@@ -6,10 +6,9 @@ import {
type PlanOutcome,
type PlanningArtifacts,
type PlanningRequest,
- type PlanningTraceUpdate,
} from './leave-planner.core';
import { OPTIMIZATION_PRESETS } from './planning-objectives';
-import { applyReplayDeltas, createPlanningCalculation } from './leave-planner.service';
+import { createPlanningCalculation } from './leave-planner.service';
const economy: OptimizationPlanningRule = {
id: 'economy',
@@ -415,25 +414,20 @@ describe('supporting placement economy and provenance', () => {
});
it('reclassifies a deferred support addition when an eligible earlier-expiring balance replaces it', () => {
- let artifacts: PlanningArtifacts | undefined;
- const outcome = calculatePlanningInternal(
- {
- horizon: { start: '2024-05-06', end: '2024-05-11' },
- balances: [
- { id: 'deferred', type: 'cp', quantity: 3, expiryDate: '2025-01-01' },
- {
- id: 'at-risk',
- type: 'rtt',
- quantity: 1,
- availableFrom: '2024-05-07',
- expiryDate: '2024-05-07',
- },
- ],
- rules: [bundles[0].rule, economy],
- },
- undefined,
- (value) => (artifacts = value),
- );
+ const outcome = calculatePlanningInternal({
+ horizon: { start: '2024-05-06', end: '2024-05-11' },
+ balances: [
+ { id: 'deferred', type: 'cp', quantity: 3, expiryDate: '2025-01-01' },
+ {
+ id: 'at-risk',
+ type: 'rtt',
+ quantity: 1,
+ availableFrom: '2024-05-07',
+ expiryDate: '2024-05-07',
+ },
+ ],
+ rules: [bundles[0].rule, economy],
+ });
if (outcome.kind !== 'Plan') throw new Error('Expected a plan');
expect(
outcome.placements.map(({ date, balanceId, supporting }) => ({
@@ -448,20 +442,6 @@ describe('supporting placement economy and provenance', () => {
]);
expect(outcome.metrics.supportingPlacements).toBe(2);
expect(outcome.remainingBalances[0]).toMatchObject({ id: 'deferred', quantity: 1 });
- expect(
- artifacts?.trace?.phases.flatMap(({ decisions }) =>
- decisions.flatMap(({ deltas }) => deltas),
- ),
- ).toContainEqual(
- expect.objectContaining({
- kind: 'placement-updated',
- previousPlacement: expect.objectContaining({
- balanceId: 'deferred',
- supporting: true,
- }),
- placement: expect.objectContaining({ balanceId: 'at-risk', supporting: false }),
- }),
- );
});
it('compares new deferred support when at-risk holiday placements relocate into an explicit window', async () => {
@@ -491,13 +471,10 @@ describe('supporting placement economy and provenance', () => {
};
const economyFirst = plan({ ...request, rules: [holiday, economy, early] });
let artifacts: PlanningArtifacts | undefined;
- const updates: { update: PlanningTraceUpdate; snapshot: string }[] = [];
const windowFirst = calculatePlanningInternal(
{
...request,
rules: [holiday, early, economy],
- traceProgress: (_time: number, update: PlanningTraceUpdate) =>
- updates.push({ update, snapshot: JSON.stringify(update) }),
},
(checkpoint, state) => expect(state?.placements).toEqual(checkpoint.placements),
(value) => (artifacts = value),
@@ -547,12 +524,8 @@ describe('supporting placement economy and provenance', () => {
expect(windowFirst.metrics.supportingPlacements).toBe(2);
expect(windowFirst.preferences[0].satisfied).toBe(2);
expect(windowFirst.metrics.holidayWeekValue).toBe(3);
- if (
- artifacts?.explanation?.kind !== 'PlanExplanation' ||
- !artifacts.finalState ||
- !artifacts.trace
- )
- throw new Error('Expected matching explanation and trace artifacts');
+ if (artifacts?.explanation?.kind !== 'PlanExplanation' || !artifacts.finalState)
+ throw new Error('Expected matching explanation and state snapshots');
expect(artifacts.finalState.placements).toEqual(windowFirst.placements);
expect(artifacts.explanation.placements.map(({ placement }) => placement)).toEqual(
windowFirst.placements,
@@ -568,33 +541,6 @@ describe('supporting placement economy and provenance', () => {
expect(placement.contributions).not.toContainEqual(contribution);
}
}
- const replay = {
- balances: artifacts.trace.initialState.balances.map((balance) => ({ ...balance })),
- placements: new Map(
- artifacts.trace.initialState.placements.map((placement) => [
- placement.date,
- placement,
- ]),
- ),
- earnedActivations: [...artifacts.trace.initialState.earnedActivations],
- };
- for (const decision of artifacts.trace.phases.flatMap(({ decisions }) => decisions))
- applyReplayDeltas(replay, decision.deltas, artifacts.finalState);
- expect(
- [...replay.placements.values()].sort((a, b) => a.date.localeCompare(b.date)),
- ).toEqual(windowFirst.placements);
- expect(
- artifacts.trace.phases
- .filter(({ name }) => name === 'preference:early')
- .flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates)),
- ).toContainEqual(
- expect.objectContaining({
- status: 'chosen',
- placement: expect.objectContaining({ date: '2026-11-03', ruleIds: ['early'] }),
- }),
- );
- expect(updates.length).toBeGreaterThan(0);
- for (const { update, snapshot } of updates) expect(JSON.stringify(update)).toBe(snapshot);
let cancelled = false;
let cancelledArtifacts: PlanningArtifacts | undefined;
let published: PlanOutcome | undefined;
@@ -605,8 +551,6 @@ describe('supporting placement economy and provenance', () => {
progress: (stage: string) => {
if (stage === 'optimization:economy') cancelled = true;
},
- traceProgress: (_time: number, update: PlanningTraceUpdate) =>
- updates.push({ update, snapshot: JSON.stringify(update) }),
},
(checkpoint, state) => {
expect(state?.placements).toEqual(checkpoint.placements);
@@ -637,7 +581,6 @@ describe('supporting placement economy and provenance', () => {
expect.objectContaining({ kind: 'rule', ruleId: 'early' }),
);
}
- for (const { update, snapshot } of updates) expect(JSON.stringify(update)).toBe(snapshot);
});
it('keeps established maximum credit when a later earned period masks its local contribution', () => {
@@ -708,28 +651,6 @@ describe('supporting placement economy and provenance', () => {
});
});
- it('records the calendar credit and classification change in the original calendar-rule phase', () => {
- let artifacts: PlanningArtifacts | undefined;
- calculatePlanningInternal(
- { ...weekend, rules: [gain, economy, requested] },
- undefined,
- (value) => (artifacts = value),
- );
- const changes = artifacts?.trace?.phases
- .find(({ name }) => name === 'preference:requested')
- ?.decisions.flatMap(({ deltas }) => deltas);
- expect(changes).toContainEqual(
- expect.objectContaining({
- kind: 'placement-updated',
- previousPlacement: expect.objectContaining({ supporting: true }),
- placement: expect.objectContaining({
- ruleIds: ['gain', 'requested'],
- supporting: false,
- }),
- }),
- );
- });
-
it('keeps earned support chronological and preserves the captured economy rule in explanations and counterfactuals', async () => {
vi.stubGlobal('window', undefined);
try {
@@ -792,15 +713,6 @@ describe('supporting placement economy and provenance', () => {
expect(supporting.contributions).toContainEqual(
expect.objectContaining({ kind: 'supporting' }),
);
- const trace = await calculation.trace;
- expect(trace?.phases.map(({ name }) => name)).toEqual([
- 'validation',
- 'obligations',
- 'optimization:gain',
- 'optimization:economy',
- 'expiry',
- 'dates',
- ]);
const analysis = await calculation.analyzeCounterfactual(outcome.placements[1]);
if (analysis.kind !== 'Counterfactual') throw new Error('Expected a counterfactual');
expect(analysis.results.find(({ date }) => date === '2024-05-03')).toMatchObject({
diff --git a/src/app/services/leave-planner-work-sequences.spec.ts b/src/app/services/leave-planner-work-sequences.spec.ts
index 9c48287..1f74c19 100644
--- a/src/app/services/leave-planner-work-sequences.spec.ts
+++ b/src/app/services/leave-planner-work-sequences.spec.ts
@@ -407,7 +407,7 @@ describe('composed work-sequence preferences', () => {
},
);
- it('captures distinct threshold values in explanations, atomic trace and counterfactual comparisons', async () => {
+ it('captures distinct threshold values in explanations and date comparisons', async () => {
vi.stubGlobal('window', undefined);
try {
const five = structuredClone(deficit);
@@ -435,18 +435,6 @@ describe('composed work-sequence preferences', () => {
.filter(({ id }) => id.startsWith('optimization:'))
.map(({ value }) => value),
).toEqual([2, 4]);
- const trace = await calculation.trace;
- const chosen = trace?.phases
- .flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates))
- .find(
- (candidate) => candidate.status === 'chosen' && candidate.dates?.length === 3,
- );
- expect(
- chosen?.comparison?.before.objectives
- .filter(({ id }) => id.startsWith('optimization:'))
- .map(({ value }) => value),
- ).toEqual([0, 2]);
- expect(chosen?.comparison?.firstDecisiveObjective).toBe('optimization:deficit');
const counterfactual = await calculation.analyzeCounterfactual(outcome.placements[0]);
if (counterfactual.kind !== 'Counterfactual')
throw new Error('Expected a counterfactual');
diff --git a/src/app/services/leave-planner.core.ts b/src/app/services/leave-planner.core.ts
index d71317b..ddfec05 100644
--- a/src/app/services/leave-planner.core.ts
+++ b/src/app/services/leave-planner.core.ts
@@ -273,14 +273,7 @@ export interface PlanningSnapshot {
labels: Readonly>;
}
-export type TraceCandidateStatus =
- | 'chosen'
- | 'evaluated-rejected'
- | 'pruned'
- | 'not-examined'
- | 'ineligible';
-
-export interface PlanningTraceState {
+export interface PlanningStateSnapshot {
calculationDate?: string;
declaredDays?: readonly DeclaredNonWorkingDay[];
horizon: Readonly<{ start: string; end: string }>;
@@ -303,122 +296,8 @@ export interface PlanningTraceState {
}>[];
}
-export interface PlanningTraceDelta {
- kind:
- | 'placement-added'
- | 'placement-removed'
- | 'placement-moved'
- | 'placement-updated'
- | 'balance-changed'
- | 'balance-removed'
- | 'earned-activated';
- date?: string;
- fromDate?: string;
- toDate?: string;
- balanceId?: string;
- placement?: LeavePlacement;
- previousPlacement?: LeavePlacement;
- remainingBefore?: number;
- remainingAfter?: number;
- /** Full metadata when a generated entitlement first enters the traced state. */
- balance?: PlanningTraceState['balances'][number];
- activation?: Readonly<{
- id: string;
- ruleId: string;
- occurrence: string;
- dates: readonly string[];
- }>;
-}
-
-export interface PlanningTraceCandidate {
- status: TraceCandidateStatus;
- sourceDate?: string;
- date?: string;
- 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;
-}
-
-export interface PlanningTraceDecision {
- id: string;
- action: 'validate' | 'choose' | 'share' | 'add' | 'move' | 'remove' | 'activate' | 'reject';
- 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;
-}
-
-export interface PlanningTracePhase {
- id: string;
- name: string;
- completed: boolean;
- status: 'completed' | 'interrupted' | 'not-run';
- decisions: readonly PlanningTraceDecision[];
-}
-
-export interface PlanningTrace {
- status: 'ready' | 'incomplete' | 'failed';
- 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 =
@@ -433,17 +312,10 @@ export type CounterfactualReasonCode =
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;
@@ -461,19 +333,7 @@ export interface StaleCounterfactualAnalysis {
results: readonly [];
}
-export interface TraceCounterfactualSelection {
- decisionId: string;
- placement: LeavePlacement;
-}
-
-export type CounterfactualSelection = LeavePlacement | string | TraceCounterfactualSelection;
-
-export interface CounterfactualBaselineOptions {
- /** The placement released before testing alternatives; null means an additive decision. */
- removeDate: string | null;
- /** Preserve the exact pre-decision state as the comparison's before vector. */
- compareOriginalState: boolean;
-}
+export type CounterfactualSelection = LeavePlacement | string;
export interface PlanningMetrics {
unplacedAtRisk: number;
@@ -591,7 +451,6 @@ interface RuntimePlanningRequest {
labels?: unknown;
deadlineMs?: unknown;
progress?: unknown;
- traceProgress?: unknown;
}
interface RuntimeRecord {
@@ -606,7 +465,6 @@ interface RuntimeRecord {
labels?: unknown;
deadlineMs?: unknown;
progress?: unknown;
- traceProgress?: unknown;
id?: unknown;
name?: unknown;
type?: unknown;
@@ -660,800 +518,6 @@ interface CalendarPeriod {
}
const calendarPeriodCache = new WeakMap>();
-type MutableTraceCandidate = PlanningTraceCandidate & { candidates?: never };
-type MutableTraceDecision = {
- id: string;
- action: PlanningTraceDecision['action'];
- dates: string[];
- candidates: MutableTraceCandidate[];
- deltas: PlanningTraceDelta[];
- branch?: { parentDecisionId?: string; deltas: PlanningTraceDelta[] };
- reason?: PlanningReason;
-};
-type MutableTracePhase = {
- id: string;
- name: string;
- completed: boolean;
- status: PlanningTracePhase['status'];
- decisions: MutableTraceDecision[];
-};
-
-/**
- * Records observations while the core is producing a result. The recorder is
- * intentionally write-only from the search code; it never participates in a
- * candidate sort, deadline check, or state mutation.
- */
-class TraceRecorder {
- private readonly phases: MutableTracePhase[] = [];
- private readonly nonWorkingDates: readonly Readonly<{ date: string; reason: PlanningReason }>[];
- private phase: MutableTracePhase | undefined;
- private sequence = 0;
-
- private pendingOperations: PlanningTraceOperation[] = [];
- private readonly pendingCandidateOperations = new Map<
- PlanningTraceCandidate,
- Extract
- >();
- // Keep repeated scalar evidence shared while candidate identities remain independent.
- private readonly objectiveValues = new Map();
- private readonly objectiveVectors = new WeakMap<
- PlanningObjectiveVector,
- PlanningObjectiveVector
- >();
- private readonly comparisonDeltas = new Map();
- private readonly repeatedVectors = new Map();
- private repeatedVectorCount = 0;
- private readonly tieValues = new Map();
- private readonly placements = new Map();
- private readonly dates = new Map();
- private readonly identifiers = new Map();
- private readonly comparisons = new WeakMap<
- PlanningObjectiveVector,
- WeakMap
- >();
- private readonly technicalValues = new Map<
- string,
- NonNullable
- >();
- private readonly technicalEntries = new Map<
- string,
- NonNullable[number]
- >();
- private initialStateSent = false;
- private previousPlacements = new Map();
- private previousEarned = new Set();
- private previousBalances: ReadonlyMap;
-
- constructor(
- private readonly initialState: PlanningTraceState,
- private readonly plannedPhases: readonly string[],
- private readonly workingDays: ReadonlySet,
- private readonly streaming: boolean,
- ) {
- const declaredDates = new Set(initialState.declaredDays?.map(({ date }) => date));
- this.nonWorkingDates = dateRange(initialState.horizon.start, initialState.horizon.end)
- .filter((date) => !workingDays.has(date) || declaredDates.has(date))
- .map((date) => ({
- date,
- reason: declaredDates.has(date)
- ? reason(
- 'declared-day',
- 'The date is a declared non-working day fixed by the user.',
- )
- : reason('non-working', 'The date is a weekend or public holiday.'),
- }));
- this.previousBalances = new Map(
- initialState.balances.map((balance) => [balance.id, { ...balance }]),
- );
- }
-
- private capturePlacement(placement: LeavePlacement | undefined): LeavePlacement | undefined {
- if (!placement || Object.isFrozen(placement)) return placement;
- const key = JSON.stringify(placement);
- const shared = this.placements.get(key);
- if (shared) return shared;
- const ruleIds = [...placement.ruleIds];
- Object.freeze(ruleIds);
- const snapshot = Object.freeze({ ...placement, ruleIds });
- if (this.placements.size < 8192) this.placements.set(key, snapshot);
- return snapshot;
- }
-
- private captureDates(dates: readonly string[] | undefined): readonly string[] | undefined {
- if (!dates || Object.isFrozen(dates)) return dates;
- const key = JSON.stringify(dates);
- const shared = this.dates.get(key);
- if (shared) return shared;
- const snapshot = Object.freeze([...dates]);
- if (this.dates.size < 16384) this.dates.set(key, snapshot);
- return snapshot;
- }
-
- private captureIdentifier(identifier: string): string {
- const shared = this.identifiers.get(identifier);
- if (shared !== undefined) return shared;
- if (this.identifiers.size < 4096) this.identifiers.set(identifier, identifier);
- return identifier;
- }
-
- private captureTieValue(value: number | string): number | string {
- if (typeof value !== 'string') return value;
- const shared = this.tieValues.get(value);
- if (shared !== undefined) {
- this.tieValues.delete(value);
- this.tieValues.set(shared, shared);
- return shared;
- }
- // Bound the index; eviction changes only sharing, never the recorded scalar value.
- if (this.tieValues.size >= 8192) this.tieValues.delete(this.tieValues.keys().next().value!);
- this.tieValues.set(value, value);
- return value;
- }
-
- private captureVector(vector: PlanningObjectiveVector): PlanningObjectiveVector {
- const captured = this.objectiveVectors.get(vector);
- if (captured) return captured;
- // Use a retained tie value as the index rather than duplicating complete vectors as JSON keys.
- const dateValue = vector.objectives.find(({ id }) => id === 'placement-date')?.value;
- const key = typeof dateValue === 'string' ? dateValue : undefined;
- const bucket = key === undefined ? undefined : this.repeatedVectors.get(key);
- const shared = bucket?.find(
- (candidate) =>
- candidate.objectives.length === vector.objectives.length &&
- candidate.objectives.every((objective, index) => {
- const other = vector.objectives[index];
- return (
- objective.id === other.id &&
- objective.direction === other.direction &&
- objective.label === other.label &&
- Object.hasOwn(objective, 'label') === Object.hasOwn(other, 'label') &&
- Object.is(objective.value, other.value)
- );
- }),
- );
- if (shared) {
- const recordedKey = shared.objectives.find(({ id }) => id === 'placement-date')?.value;
- if (typeof recordedKey === 'string') {
- this.repeatedVectors.delete(recordedKey);
- this.repeatedVectors.set(recordedKey, bucket!);
- }
- this.objectiveVectors.set(vector, shared);
- return shared;
- }
- const objectives = vector.objectives.map((objective) => {
- const shareable =
- (objective.id === 'balance-expiry' && typeof objective.value === 'string') ||
- (typeof objective.value === 'number' &&
- Number.isFinite(objective.value) &&
- !Object.is(objective.value, -0));
- if (!shareable)
- return Object.freeze({
- ...objective,
- value: this.captureTieValue(objective.value),
- });
- const key = JSON.stringify(objective);
- const shared = this.objectiveValues.get(key);
- if (shared) return shared;
- const value = Object.freeze({
- ...objective,
- value: this.captureTieValue(objective.value),
- });
- if (this.objectiveValues.size < 4096) this.objectiveValues.set(key, value);
- return value;
- });
- const result = Object.freeze({ objectives: Object.freeze(objectives) });
- this.objectiveVectors.set(vector, result);
- const recordedKey = result.objectives.find(({ id }) => id === 'placement-date')?.value;
- if (typeof recordedKey === 'string') {
- if (this.repeatedVectorCount >= 65536) {
- const oldest = this.repeatedVectors.keys().next().value!;
- this.repeatedVectorCount -= this.repeatedVectors.get(oldest)!.length;
- this.repeatedVectors.delete(oldest);
- }
- const retained = this.repeatedVectors.get(recordedKey) ?? [];
- retained.push(result);
- this.repeatedVectors.delete(recordedKey);
- this.repeatedVectors.set(recordedKey, retained);
- this.repeatedVectorCount++;
- }
- return result;
- }
-
- private captureComparison(
- comparison: PlanningObjectiveComparison,
- ): PlanningObjectiveComparison {
- const before = this.captureVector(comparison.before);
- const after = this.captureVector(comparison.after);
- let alternatives = this.comparisons.get(before);
- const shared = alternatives?.get(after);
- if (shared) return shared;
- if (!alternatives) {
- alternatives = new WeakMap();
- this.comparisons.set(before, alternatives);
- }
- // Every recorded comparison comes from objectiveComparison of these complete vectors.
- const result = Object.freeze({
- ...comparison,
- before,
- after,
- firstDecisiveObjective: after.objectives.find(
- ({ id }) => id === comparison.firstDecisiveObjective,
- )?.id,
- delta: this.captureDeltas(comparison.delta),
- });
- alternatives.set(after, result);
- return result;
- }
-
- private captureDeltas(
- deltas: readonly PlanningObjectiveDelta[],
- ): readonly PlanningObjectiveDelta[] {
- const shareable = deltas.every(
- ({ value }) => value === null || (Number.isFinite(value) && !Object.is(value, -0)),
- );
- const key = shareable ? JSON.stringify(deltas) : undefined;
- const shared = key === undefined ? undefined : this.comparisonDeltas.get(key);
- if (shared) return shared;
- const result = Object.freeze(deltas.map((delta) => Object.freeze({ ...delta })));
- if (key !== undefined && this.comparisonDeltas.size < 4096)
- this.comparisonDeltas.set(key, result);
- return result;
- }
-
- private captureTechnicalValues(
- values: PlanningTraceCandidate['technicalComparison'],
- ): PlanningTraceCandidate['technicalComparison'] {
- if (!values || Object.isFrozen(values)) return values;
- const shareable = values.every(
- ({ before, after }) =>
- typeof before === 'number' &&
- typeof after === 'number' &&
- Number.isFinite(before) &&
- Number.isFinite(after) &&
- !Object.is(before, -0) &&
- !Object.is(after, -0),
- );
- const key = shareable ? JSON.stringify(values) : undefined;
- const shared = key === undefined ? undefined : this.technicalValues.get(key);
- if (shared) return shared;
- const result = Object.freeze(
- values.map((value) => {
- const shareable =
- (value.name === 'balance-expiry' &&
- typeof value.before === 'string' &&
- typeof value.after === 'string') ||
- (typeof value.before === 'number' &&
- typeof value.after === 'number' &&
- Number.isFinite(value.before) &&
- Number.isFinite(value.after) &&
- !Object.is(value.before, -0) &&
- !Object.is(value.after, -0));
- const key = shareable ? JSON.stringify(value) : undefined;
- const shared = key === undefined ? undefined : this.technicalEntries.get(key);
- if (shared) return shared;
- const result = Object.freeze({
- ...value,
- name: this.captureIdentifier(value.name),
- before: this.captureTieValue(value.before),
- after: this.captureTieValue(value.after),
- });
- if (key !== undefined && this.technicalEntries.size < 4096)
- this.technicalEntries.set(key, result);
- return result;
- }),
- );
- if (key !== undefined && this.technicalValues.size < 4096)
- this.technicalValues.set(key, result);
- return result;
- }
-
- startPhase(name: string): void {
- if (this.phase?.name === name) return;
- if (this.phase) {
- this.phase.completed = true;
- this.phase.status = 'completed';
- this.recordPhase(this.phase);
- }
- this.phase = {
- id: `phase:${this.phases.length + 1}`,
- name,
- completed: false,
- status: 'interrupted',
- 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: this.captureDates(candidate.dates),
- reason: candidate.reason ? { ...candidate.reason } : undefined,
- technicalComparison: this.captureTechnicalValues(candidate.technicalComparison),
- placement: this.capturePlacement(candidate.placement),
- };
- 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 {
- if (candidate.comparison) {
- candidate.comparison = this.captureComparison(candidate.comparison);
- }
- candidate.technicalComparison = this.captureTechnicalValues(candidate.technicalComparison);
- if (candidate.firstDecisiveComparator !== undefined)
- candidate.firstDecisiveComparator = this.captureIdentifier(
- candidate.firstDecisiveComparator,
- );
- if (candidate.placement) candidate.placement = this.capturePlacement(candidate.placement);
- if (candidate.dates) candidate.dates = this.captureDates(candidate.dates);
- 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
- : {
- calculationDate: this.initialState.calculationDate,
- horizon: { ...this.initialState.horizon },
- ...(this.initialState.declaredDays
- ? {
- declaredDays: this.initialState.declaredDays.map((day) => ({
- ...day,
- })),
- }
- : {}),
- 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(
- action: MutableTraceDecision['action'],
- dates: readonly string[] = [],
- ): MutableTraceDecision {
- if (!this.phase) this.startPhase('validation');
- const current = this.phase!;
- const decision: MutableTraceDecision = {
- id: `decision:${++this.sequence}`,
- action,
- dates: [...dates],
- candidates: [],
- deltas: [],
- branch: undefined,
- };
- current.decisions.push(decision);
- this.recordDecisionUpdate(current, decision);
- return decision;
- }
-
- private latestDecision(
- action: MutableTraceDecision['action'] = 'choose',
- ): MutableTraceDecision {
- const current = this.phase;
- const latest = current?.decisions[current.decisions.length - 1];
- return latest ?? this.decision(action);
- }
-
- private candidateDecision(dates: readonly string[] = []): MutableTraceDecision {
- const latest = this.latestDecision();
- return latest.deltas.length || latest.branch ? this.decision('choose', dates) : latest;
- }
-
- private recordChosenCandidate(placement: LeavePlacement, decision: MutableTraceDecision): void {
- const alreadyRecorded = decision.candidates.some(
- (candidate) =>
- candidate.status === 'chosen' &&
- candidate.placement?.date === placement.date &&
- candidate.placement.balanceId === placement.balanceId,
- );
- if (!alreadyRecorded)
- this.recordNewCandidate(decision, {
- status: 'chosen',
- date: placement.date,
- placement,
- });
- }
-
- recordCandidate(candidate: PlanningTraceCandidate, copy = true): MutableTraceCandidate {
- const decision = this.candidateDecision(
- candidate.dates ?? (candidate.date ? [candidate.date] : []),
- );
- const mutable = copy
- ? {
- ...candidate,
- dates: this.captureDates(candidate.dates),
- reason: candidate.reason ? { ...candidate.reason } : undefined,
- technicalComparison: this.captureTechnicalValues(candidate.technicalComparison),
- placement: this.capturePlacement(candidate.placement),
- }
- : (candidate as MutableTraceCandidate);
- this.recordNewCandidate(decision, mutable);
- return mutable;
- }
-
- recordDecision(
- action: MutableTraceDecision['action'],
- dates: readonly string[],
- candidate?: PlanningTraceCandidate,
- branch?: PlanningTraceDecision['branch'],
- ): MutableTraceCandidate | undefined {
- const decision = this.decision(action, dates);
- 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;
- this.recordNewCandidate(decision, mutable);
- return mutable;
- }
-
- recordBranchTransition(
- dates: readonly string[],
- parentDecisionId: string | undefined,
- deltas: readonly PlanningTraceDelta[],
- candidate?: MutableTraceCandidate,
- ): string {
- const decision = candidate
- ? (this.evaluationLocation(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;
- }
-
- private evaluationLocation(
- candidate: MutableTraceCandidate,
- ): { phase: MutableTracePhase; decision: MutableTraceDecision; index: number } | undefined {
- // Immediate updates find the newest candidate first; late handles need no permanent index.
- for (let phaseIndex = this.phases.length - 1; phaseIndex >= 0; phaseIndex--) {
- const phase = this.phases[phaseIndex];
- for (
- let decisionIndex = phase.decisions.length - 1;
- decisionIndex >= 0;
- decisionIndex--
- ) {
- const decision = phase.decisions[decisionIndex];
- const index = decision.candidates.lastIndexOf(candidate);
- if (index >= 0) return { phase, decision, index };
- }
- }
- return undefined;
- }
-
- decisionIdFor(candidate: MutableTraceCandidate): string | undefined {
- return this.evaluationLocation(candidate)?.decision.id;
- }
-
- recordEvaluation(candidate: MutableTraceCandidate): void {
- const decision = this.candidateDecision(
- candidate.dates ?? (candidate.date ? [candidate.date] : []),
- );
- this.recordNewCandidate(decision, candidate);
- }
-
- updateEvaluation(candidate: MutableTraceCandidate): void {
- if (candidate.placement) candidate.placement = this.capturePlacement(candidate.placement);
- candidate.technicalComparison = this.captureTechnicalValues(candidate.technicalComparison);
- if (candidate.firstDecisiveComparator !== undefined)
- candidate.firstDecisiveComparator = this.captureIdentifier(
- candidate.firstDecisiveComparator,
- );
- const location = this.evaluationLocation(candidate);
- if (location)
- this.recordCandidateUpdate(
- location.phase,
- location.decision,
- location.index,
- 'replace',
- candidate,
- );
- }
-
- chooseEvaluation(candidate: MutableTraceCandidate): void {
- candidate.status = 'chosen';
- this.updateEvaluation(candidate);
- }
-
- recordPlan(
- placements: readonly LeavePlacement[],
- balances: PlanningTraceState['balances'],
- earnedActivations: PlanningTraceState['earnedActivations'],
- acceptedCandidate?: MutableTraceCandidate,
- ): void {
- const current = new Map(placements.map((placement) => [placement.date, placement]));
- const removed = [...this.previousPlacements.values()].filter(
- (placement) => !current.has(placement.date),
- );
- const added = placements.filter(
- (placement) => !this.previousPlacements.has(placement.date),
- );
- const updated = [...current].filter(([date, placement]) => {
- const previous = this.previousPlacements.get(date);
- return previous && JSON.stringify(previous) !== JSON.stringify(placement);
- });
- const removedBalances = [...this.previousBalances.values()].filter(
- (previous) => !balances.some((balance) => balance.id === previous.id),
- );
- const balancesChanged =
- removedBalances.length > 0 ||
- 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 = earnedActivations.some(
- (activation) => !this.previousEarned.has(activation.id),
- );
- if (
- !removed.length &&
- !added.length &&
- !updated.length &&
- !balancesChanged &&
- !earnedChanged
- )
- return;
- const action =
- removed.length && added.length
- ? 'move'
- : added.length
- ? 'add'
- : removed.length
- ? 'remove'
- : updated.length
- ? 'share'
- : 'activate';
- const dates = [
- ...removed.map((placement) => placement.date),
- ...added.map((placement) => placement.date),
- ...updated.map(([date]) => date),
- ];
- const decision =
- (acceptedCandidate && this.evaluationLocation(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) {
- const placement = added.find(
- (candidate) =>
- !movedAdded.has(candidate.date) &&
- candidate.balanceId === previous.balanceId &&
- candidate.type === previous.type,
- );
- if (!placement) continue;
- movedAdded.add(placement.date);
- movedRemoved.add(previous.date);
- this.recordDelta(decision, {
- kind: 'placement-moved',
- fromDate: previous.date,
- toDate: placement.date,
- balanceId: placement.balanceId,
- previousPlacement: previous,
- placement,
- });
- this.recordChosenCandidate(placement, decision);
- }
- for (const placement of removed) {
- if (movedRemoved.has(placement.date)) continue;
- this.recordDelta(decision, {
- kind: 'placement-removed',
- date: placement.date,
- balanceId: placement.balanceId,
- previousPlacement: placement,
- });
- }
- for (const placement of added) {
- if (movedAdded.has(placement.date)) continue;
- this.recordDelta(decision, {
- kind: 'placement-added',
- date: placement.date,
- balanceId: placement.balanceId,
- placement,
- });
- this.recordChosenCandidate(placement, decision);
- }
- for (const [date, placement] of updated) {
- const previous = this.previousPlacements.get(date)!;
- this.recordDelta(decision, {
- kind: 'placement-updated',
- date,
- balanceId: placement.balanceId,
- previousPlacement: previous,
- placement,
- });
- this.recordChosenCandidate(placement, decision);
- }
- for (const balance of balances) {
- 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 },
- });
- }
- for (const balance of removedBalances) {
- this.recordDelta(decision, {
- kind: 'balance-removed',
- balanceId: balance.id,
- remainingBefore: balance.remaining,
- });
- }
- for (const activation of earnedActivations) {
- if (!this.previousEarned.has(activation.id))
- this.recordDelta(decision, {
- kind: 'earned-activated',
- balanceId: activation.id,
- activation: { ...activation, dates: [...activation.dates] },
- });
- }
- this.previousPlacements = current;
- this.previousBalances = new Map(balances.map((balance) => [balance.id, { ...balance }]));
- this.previousEarned = new Set(earnedActivations.map((activation) => activation.id));
- }
-
- finish(
- status: PlanningTrace['status'],
- completedStages: readonly string[],
- error?: PlanningReason,
- ): PlanningTrace {
- 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;
- 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) => ({
- ...decision,
- dates: [...decision.dates],
- // Search is complete; later stream drains only emit detached snapshots.
- candidates: [...decision.candidates],
- deltas: decision.deltas.map((delta) => ({ ...delta })),
- })),
- })),
- completedStages: [...completedStages],
- error,
- };
- }
-}
-
interface SearchContext {
declaredDays: readonly DeclaredNonWorkingDay[];
declaredDates: ReadonlySet;
@@ -1477,18 +541,9 @@ interface SearchContext {
protectedTypesByDate: Map>;
preferenceDemandByDate: Map;
anyPreferenceDemandByDate: Map;
- trace?: TraceRecorder;
- traceTime: number;
- traceCaptureTime: number;
- traceTransferTime: number;
- traceReportedTime: number;
observerTime: number;
- obligationSolutionBranches: WeakMap;
completedPreferenceStates: Map;
completedGenericState?: string;
- traceBranchParentId?: string;
- traceBranchActive?: boolean;
- traceError?: PlanningReason;
/** Supplied metrics were evaluated after reconciling earned entitlement dates. */
retain?: (
state: State,
@@ -1507,75 +562,17 @@ type PlanningCalendar = Pick<
| 'workingPrefix'
>;
-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();
}
-function observeTrace(context: SearchContext, observe: (trace: TraceRecorder) => void): void {
- if (!context.trace) return;
- const started = now();
- try {
- observe(context.trace);
- } catch (error) {
- context.traceError = reason(
- 'trace-error',
- error instanceof Error ? error.message : String(error),
- );
- context.trace = undefined;
- } finally {
- const elapsed = now() - started;
- context.traceTime += elapsed;
- context.traceCaptureTime += elapsed;
- if (
- context.trace &&
- context.traceCaptureTime - context.traceReportedTime >= 25 &&
- typeof context.request.traceProgress === 'function'
- ) {
- 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;
- }
- }
- }
-}
-
function observeCallback(context: SearchContext, callback: () => void): void {
const started = now();
try {
@@ -1585,32 +582,6 @@ function observeCallback(context: SearchContext, callback: () => void): void {
}
}
-function recordTraceCandidate(
- context: SearchContext,
- candidate: PlanningTraceCandidate,
-): MutableTraceCandidate | undefined {
- // These internal callsites pass fresh literals; retain the full snapshot copy for plan data.
- let recorded: MutableTraceCandidate | undefined;
- observeTrace(context, (trace) => {
- recorded = trace.recordCandidate(candidate, false);
- });
- return recorded;
-}
-
-function recordTraceIneligible(
- context: SearchContext,
- date: string,
- code: string,
- message: string,
- ruleIds?: string[],
-): void {
- recordTraceCandidate(context, {
- status: 'ineligible',
- date,
- reason: reason(code, message, ruleIds),
- });
-}
-
class DeadlineReached extends Error {}
class UserCancelled extends Error {}
@@ -1658,7 +629,6 @@ function runtimeRequest(value: unknown, calculationDate?: string): RuntimePlanni
labels: source.labels,
deadlineMs: source.deadlineMs,
progress: source.progress,
- traceProgress: source.traceProgress,
};
}
@@ -1769,7 +739,7 @@ function cloneState(state: State): State {
};
}
-function planningTraceState(state: State, context: SearchContext): PlanningTraceState {
+function planningStateSnapshot(state: State, context: SearchContext): PlanningStateSnapshot {
return {
calculationDate: context.request.calculationDate,
horizon: context.horizon,
@@ -1788,202 +758,6 @@ function planningTraceState(state: State, context: SearchContext): PlanningTrace
};
}
-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 remainingIds = new Set(after.balances.map((balance) => balance.id));
- for (const balance of before.balances) {
- if (!remainingIds.has(balance.id))
- deltas.push({
- kind: 'balance-removed',
- balanceId: balance.id,
- remainingBefore: balance.remaining,
- });
- }
- 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 retainAndRecordBranchMutation(
- context: SearchContext,
- before: State,
- after: State,
- parentDecisionId: string | undefined,
- candidate: MutableTraceCandidate | undefined,
- dates: readonly string[],
-): string | undefined {
- let decisionId: string | undefined;
- observeTrace(context, (trace) => {
- const placement = candidate?.placement && after.placements.get(candidate.placement.date);
- if (candidate && placement)
- candidate.placement = { ...placement, ruleIds: [...placement.ruleIds].sort() };
- decisionId = trace.recordBranchTransition(
- dates,
- parentDecisionId,
- planningTraceDeltas(before, after, dates),
- candidate,
- );
- });
- context.retain?.(after);
- return decisionId;
-}
-
-function retainAndRecordState(
- context: SearchContext,
- state: State,
- acceptedCandidate?: MutableTraceCandidate,
-): void {
- observeTrace(context, (trace) => {
- const snapshot = planningTraceState(state, context);
- const placement =
- acceptedCandidate?.placement && state.placements.get(acceptedCandidate.placement.date);
- if (acceptedCandidate && placement)
- acceptedCandidate.placement = { ...placement, ruleIds: [...placement.ruleIds].sort() };
- if (acceptedCandidate) trace.chooseEvaluation(acceptedCandidate);
- trace.recordPlan(
- snapshot.placements,
- snapshot.balances,
- snapshot.earnedActivations,
- acceptedCandidate,
- );
- });
- context.retain?.(state);
-}
-
function normalizeType(value: unknown): LeaveType | null {
const normalized = String(value ?? '').toLowerCase();
return VALID_TYPES.has(normalized as LeaveType) ? (normalized as LeaveType) : null;
@@ -2646,17 +1420,6 @@ function selectedOccurrencePattern(
);
}
-function rejectDeclaredDate(context: SearchContext, date: string): boolean {
- if (!context.declaredDates.has(date)) return false;
- recordTraceIneligible(
- context,
- date,
- 'declared-day',
- 'The date is a declared non-working day fixed by the user.',
- );
- return true;
-}
-
function assignDateVariants(
state: State,
date: string,
@@ -2665,18 +1428,10 @@ function assignDateVariants(
context: SearchContext,
): State[] {
context.check();
- if (rejectDeclaredDate(context, date)) return [];
+ if (context.declaredDates.has(date)) return [];
const existing = state.placements.get(date);
if (existing) {
if (!selectorMatches(selector, existing.type)) {
- recordTraceCandidate(context, {
- status: 'ineligible',
- date,
- reason: reason(
- 'type-incompatible',
- 'The existing placement has an incompatible leave type.',
- ),
- });
return [];
}
if (ruleId) {
@@ -2685,26 +1440,11 @@ function assignDateVariants(
rule?.type === 'time_window' &&
(compareDate(date, rule.startDate!) < 0 || compareDate(date, rule.endDate!) > 0)
) {
- recordTraceCandidate(context, {
- status: 'ineligible',
- date,
- reason: reason('rule-invalid', 'The date does not satisfy the planning rule.', [
- ruleId,
- ]),
- });
return [];
}
}
const result = cloneState(state);
if (ruleId) result.placements.get(date)!.ruleIds.add(ruleId);
- recordTraceCandidate(context, {
- status: 'evaluated-rejected',
- date,
- placement: {
- ...result.placements.get(date)!,
- ruleIds: [...result.placements.get(date)!.ruleIds],
- },
- });
return [result];
}
const balances = state.balances
@@ -2737,15 +1477,6 @@ function assignDateVariants(
}
return compareDate(a.expiryDate, b.expiryDate) || compareText(a.id, b.id);
});
- if (!balances.length)
- recordTraceCandidate(context, {
- status: 'evaluated-rejected',
- date,
- reason: reason(
- 'balance-unavailable',
- 'No compatible leave balance can fund this candidate date.',
- ),
- });
return balances.map((balance) => {
const result = cloneState(state);
const target = result.balances.find((item) => item.id === balance.id)!;
@@ -2757,14 +1488,6 @@ function assignDateVariants(
ruleIds: ruleId ? new Set([ruleId]) : new Set(),
supporting: false,
});
- recordTraceCandidate(context, {
- status: 'evaluated-rejected',
- date,
- placement: {
- ...result.placements.get(date)!,
- ruleIds: [...result.placements.get(date)!.ruleIds],
- },
- });
return result;
});
}
@@ -2775,20 +1498,10 @@ function assignDateToBalance(
balanceId: string,
context: SearchContext,
ruleIds = new Set(),
- selectedEvaluation?: (candidate: MutableTraceCandidate | undefined) => void,
- selectionFacts?: Pick<
- PlanningTraceCandidate,
- 'technicalComparison' | 'firstDecisiveComparator'
- >,
): State | null {
context.check();
- if (rejectDeclaredDate(context, date)) return null;
+ if (context.declaredDates.has(date)) return null;
if (state.placements.has(date)) {
- recordTraceCandidate(context, {
- status: 'ineligible',
- date,
- reason: reason('occupied', 'Another placement already uses this date.'),
- });
return null;
}
const balance = state.balances.find((item) => item.id === balanceId);
@@ -2798,14 +1511,6 @@ function assignDateToBalance(
compareDate(balance.availableFrom, date) > 0 ||
compareDate(date, balance.expiryDate) > 0
) {
- recordTraceCandidate(context, {
- status: 'evaluated-rejected',
- date,
- reason: reason(
- 'balance-unavailable',
- 'The selected leave balance cannot fund this date.',
- ),
- });
return null;
}
const result = cloneState(state);
@@ -2818,16 +1523,6 @@ function assignDateToBalance(
ruleIds: new Set(ruleIds),
supporting: false,
});
- const evaluation = recordTraceCandidate(context, {
- status: 'evaluated-rejected',
- date,
- ...selectionFacts,
- placement: {
- ...result.placements.get(date)!,
- ruleIds: [...result.placements.get(date)!.ruleIds],
- },
- });
- selectedEvaluation?.(evaluation);
return result;
}
@@ -2971,7 +1666,6 @@ function* assignDatesVariants(
if (context.declaredDates.has(date)) {
if (declaredPatternDay(date, rule.placeType, context))
return yield* assignDatesVariants(state, rest, rule, context);
- rejectDeclaredDate(context, date);
return [];
}
const result: State[] = [];
@@ -2989,41 +1683,14 @@ function* satisfyObligations(
index = 0,
solutions?: State[],
solutionLimit = OBLIGATION_SOLUTION_LIMIT,
- branchPath: readonly ObligationBranchReference[] = [],
- parentDecisionId?: string,
chooseOrder = true,
): PlanningSteps {
yield;
context.check();
if (index >= requirements.length) {
const finalized = finalizeRuleCandidate(state, context, state);
- 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) {
- 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);
- }
+ if (solutions) solutions.push(finalized);
context.retain?.(finalized);
return solutions ? null : finalized;
}
@@ -3074,8 +1741,6 @@ function* satisfyObligations(
index,
solutions,
solutionLimit,
- branchPath,
- parentDecisionId,
false,
);
if (complete && !solutions) return complete;
@@ -3091,8 +1756,6 @@ function* satisfyObligations(
index + 1,
solutions,
solutionLimit,
- branchPath,
- parentDecisionId,
);
if (complete && !solutions) return complete;
}
@@ -3117,13 +1780,6 @@ function* satisfyObligations(
context,
);
if (!candidate) continue;
- const branch = recordTraceBranchDecision(
- context,
- state,
- candidate,
- parentDecisionId,
- pattern,
- );
const complete = yield* satisfyObligations(
requirements,
candidate,
@@ -3131,18 +1787,9 @@ 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) {
- recordTraceCandidate(context, {
- status: 'pruned',
- reason: reason(
- 'obligation-solution-limit',
- 'Further obligation branches were not examined after the solution limit.',
- ),
- });
return null;
}
}
@@ -3161,13 +1808,6 @@ function* satisfyObligations(
if (picked.length === needed) {
const finalized = finalizeEarnedState(current, context);
if (!finalized) return null;
- const branch = recordTraceBranchDecision(
- context,
- state,
- finalized,
- parentDecisionId,
- picked,
- );
const complete = yield* satisfyObligations(
requirements,
finalized,
@@ -3175,22 +1815,11 @@ function* satisfyObligations(
index + 1,
solutions,
solutionLimit,
- branch ? [...branchPath, branch] : branchPath,
- branch?.decisionId ?? parentDecisionId,
);
if (complete && !solutions) return complete;
return null;
}
if (candidates.length - position < needed - picked.length) {
- recordTraceCandidate(context, {
- status: 'pruned',
- dates: picked,
- reason: reason(
- 'obligation-capacity-pruned',
- 'Too few candidate dates remain to complete this obligation branch.',
- [requirement.rule.id],
- ),
- });
return null;
}
for (let i = position; i < candidates.length; i++) {
@@ -3206,13 +1835,6 @@ function* satisfyObligations(
const complete = yield* choose(i + 1, [...picked, candidates[i]], next);
if (complete && !solutions) return complete;
if (solutions && solutionLimit > 0 && solutions.length >= solutionLimit) {
- recordTraceCandidate(context, {
- status: 'pruned',
- reason: reason(
- 'obligation-solution-limit',
- 'Further obligation branches were not examined after the solution limit.',
- ),
- });
return null;
}
}
@@ -3222,13 +1844,6 @@ function* satisfyObligations(
return yield* 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));
- retainAndRecordState(context, branch.stateAfter, branch.candidate);
- }
-}
-
function releasePlacement(state: State, date: string): State {
// Removing a slot never edits surviving placements. Later assignments and
// metadata reconciliation still detach their placements before changing them.
@@ -3398,9 +2013,8 @@ function* relocateForAddition(
balanceId: string,
ruleIds: Set,
supporting: boolean,
- accept: (candidate: State, dates: string[]) => boolean,
+ accept: (candidate: State) => boolean,
visited = new Set(),
- path: string[] = [],
failedReachability = new Set(),
failedSearches = new Set(),
): PlanningSteps {
@@ -3432,15 +2046,6 @@ function* relocateForAddition(
]),
]);
if (failedSearches.has(key)) {
- recordTraceCandidate(context, {
- status: 'pruned',
- date,
- sourceDate: path[0],
- reason: reason(
- 'duplicate-relocation-state',
- 'This identical relocation state was already examined without a valid improving endpoint.',
- ),
- });
return false;
}
const assigned = assignDateToBalance(
@@ -3452,9 +2057,8 @@ function* relocateForAddition(
);
if (!assigned) return false;
assigned.placements.get(date)!.supporting = supporting;
- const dates = [...path, date];
if (!existing) {
- const accepted = accept(assigned, dates);
+ const accepted = accept(assigned);
if (!accepted) failedSearches.add(key);
return accepted;
}
@@ -3485,7 +2089,6 @@ function* relocateForAddition(
existing.supporting,
accept,
nextVisited,
- dates,
failedReachability,
failedSearches,
)
@@ -3592,40 +2195,6 @@ function finalizeRuleCandidate(state: State, context: SearchContext, before: Sta
return finalized && !unsatisfiedObligationCount(finalized, context) ? finalized : null;
}
-function recordRuleEvaluation(
- after: State,
- context: SearchContext,
- dates: string[],
- beforeVector: PlanningObjectiveVector,
- afterVector: PlanningObjectiveVector,
- sourceDate?: string,
- incumbentVector = beforeVector,
-): MutableTraceCandidate | undefined {
- let evaluation: MutableTraceCandidate | undefined;
- observeTrace(context, (trace) => {
- const facts = objectiveComparisonFacts(beforeVector.objectives, afterVector.objectives);
- const placement = after.placements.get(dates[dates.length - 1]);
- evaluation = {
- status: 'evaluated-rejected',
- dates,
- date: dates[dates.length - 1],
- sourceDate,
- comparison: objectiveComparison(beforeVector, afterVector),
- technicalComparison: facts.values,
- firstDecisiveComparator: facts.firstDecisiveComparator,
- ...(placement
- ? { placement: { ...placement, ruleIds: [...placement.ruleIds].sort() } }
- : {}),
- };
- trace.recordEvaluation(evaluation);
- });
- // The global retained plan has already examined this valid local incumbent.
- // Candidates that cannot beat it do not need a duplicate global comparison.
- if (compareObjectiveLists(afterVector.objectives, incumbentVector.objectives) < 0)
- context.retain?.(after, undefined, afterVector);
- return evaluation;
-}
-
function typedCalendarDates(context: SearchContext): Set {
const typedDates = new Set();
for (const rule of context.rules) {
@@ -3648,7 +2217,7 @@ function* calendarPlacementCandidates(
targetDates: string[],
ruleId: string,
context: SearchContext,
- accept: (candidate: State, dates: string[], sourceDate?: string, addition?: boolean) => boolean,
+ accept: (candidate: State, addition?: boolean) => boolean,
): PlanningSteps {
const missing = targetDates.filter(
(date) => !state.placements.has(date) && !context.declaredDates.has(date),
@@ -3674,7 +2243,6 @@ function* calendarPlacementCandidates(
const fill = function* (
index: number,
current: State,
- sources: string[] = [],
additions: string[] = [],
): PlanningSteps {
yield;
@@ -3708,7 +2276,7 @@ function* calendarPlacementCandidates(
const balance = current.balances.find((item) => item.id === placement.balanceId)!;
placement.supporting = benefit && !isAtRisk(balance, context);
}
- const accepted = accept(current, missing, sources[0], additions.length > 0);
+ const accepted = accept(current, additions.length > 0);
if (!accepted) failed.add(key);
return accepted;
}
@@ -3724,7 +2292,7 @@ function* calendarPlacementCandidates(
new Set([ruleId]),
);
if (!assigned) continue;
- if (yield* fill(index + 1, assigned, sources, [...additions, date])) return true;
+ if (yield* fill(index + 1, assigned, [...additions, date])) return true;
}
const donors = new Set();
for (const donor of rankedDonors) {
@@ -3757,7 +2325,7 @@ function* calendarPlacementCandidates(
);
if (!assigned) continue;
assigned.placements.get(date)!.supporting = source.supporting;
- if (yield* fill(index + 1, assigned, [...sources, source.date], additions)) return true;
+ if (yield* fill(index + 1, assigned, additions)) return true;
}
failed.add(key);
return false;
@@ -3769,7 +2337,7 @@ function* holidayWeekPlacementCandidates(
state: State,
ruleId: string,
context: SearchContext,
- accept: (candidate: State, dates: string[], sourceDate?: string, addition?: boolean) => boolean,
+ accept: (candidate: State, addition?: boolean) => boolean,
): PlanningSteps {
for (const week of context.holidayWeeks)
yield* calendarPlacementCandidates(state, week.dates, ruleId, context, accept);
@@ -3779,7 +2347,7 @@ function* timeOffPeriodPlacementCandidates(
state: State,
ruleId: string,
context: SearchContext,
- accept: (candidate: State, dates: string[], sourceDate?: string, addition?: boolean) => boolean,
+ accept: (candidate: State, addition?: boolean) => boolean,
): PlanningSteps {
const periods = periodData(state, context);
for (let index = 1; index < periods.length; index++) {
@@ -3796,33 +2364,22 @@ function evaluateRuleCandidate(
candidate: State | null,
context: SearchContext,
throughIndex: number,
- dates: string[],
- sourceDate?: string,
addition = false,
baseline = activeObjectiveVector(before, context, throughIndex),
- beforeVector = objectiveVector(before, context),
- incumbentVector = beforeVector,
-):
- | { state: State; vector: PlanningObjectiveVector; evaluation?: MutableTraceCandidate }
- | undefined {
+ incumbentVector = objectiveVector(before, context),
+): { state: State; vector: PlanningObjectiveVector } | undefined {
if (!candidate) return undefined;
const state = finalizeRuleCandidate(candidate, context, before);
if (!state) return undefined;
const active = activeObjectiveVector(state, context);
const comparison = compareObjectiveLists(active.slice(0, throughIndex + 1), baseline);
const vector = objectiveVector(state, context, active);
- const evaluation = recordRuleEvaluation(
- state,
- context,
- dates,
- beforeVector,
- vector,
- sourceDate,
- incumbentVector,
- );
+ // Preserve the best valid candidate even if the active prefix rejects it.
+ if (compareObjectiveLists(vector.objectives, incumbentVector.objectives) < 0)
+ context.retain?.(state, undefined, vector);
// shortcut: single additions cannot cross equal-value plateaus; add coordinated balance bundles when a measured configuration needs them.
if (comparison > 0 || (addition && comparison === 0)) return undefined;
- return { state, vector, evaluation };
+ return { state, vector };
}
/** Only the complete sequence change is compared; a partial gap fill can worsen the deficit. */
@@ -3830,7 +2387,7 @@ function* workSequencePlacementCandidates(
state: State,
rule: NormalizedOptimizationRule,
context: SearchContext,
- consider: (candidate: State, dates: string[]) => boolean,
+ consider: (candidate: State) => boolean,
): PlanningSteps {
const configuration = rule.configuration;
if (configuration.direction !== 'minimize') return;
@@ -3930,7 +2487,7 @@ function* workSequencePlacementCandidates(
const balance = candidate.balances.find((item) => item.id === placement.balanceId)!;
placement.supporting = supporting && !isAtRisk(balance, context);
}
- const accepted = consider(candidate, dates);
+ const accepted = consider(candidate);
if (!accepted) failedAssignments.add(key);
return accepted;
}
@@ -4035,22 +2592,6 @@ function* optimizeRulePlacements(
(bound, index) => bound !== undefined && active[index].value === bound,
)
) {
- recordTraceCandidate(context, {
- status: 'not-examined',
- reason: reason(
- throughIndex === 0
- ? 'quantity-prefix-lower-bound'
- : preferencePrefix.some(
- (item) =>
- item.type === 'optimization' &&
- item.configuration.elements === 'public-holiday-weeks',
- )
- ? 'satisfied-holiday-prefix-bound'
- : 'satisfied-window-prefix-bound',
- 'The active quantity, window and holiday-week prefix has reached every configured bound; later preference stages and final tie-breaks remain to be evaluated.',
- preferencePrefix.map(({ id }) => id),
- ),
- });
return result;
}
const genericStateKey =
@@ -4070,24 +2611,14 @@ function* optimizeRulePlacements(
let beforeCalendar: OptimizationObservations | undefined;
let best = result;
let bestVector = beforeVector;
- let bestEvaluation: MutableTraceCandidate | undefined;
- let bestDates: string[] = [];
- const consider = (
- candidate: State | null,
- dates: string[],
- sourceDate?: string,
- addition = false,
- ): boolean => {
+ const consider = (candidate: State | null, addition = false): boolean => {
const evaluated = evaluateRuleCandidate(
result,
candidate,
context,
throughIndex,
- dates,
- sourceDate,
addition,
baseline,
- beforeVector,
bestVector,
);
if (!evaluated) return false;
@@ -4095,25 +2626,9 @@ function* optimizeRulePlacements(
evaluated.vector.objectives,
bestVector.objectives,
);
- if (evaluated.evaluation) {
- const facts = objectiveComparisonFacts(
- bestVector.objectives,
- evaluated.vector.objectives,
- );
- evaluated.evaluation.technicalComparison = facts.values;
- evaluated.evaluation.firstDecisiveComparator = facts.firstDecisiveComparator;
- if (compared >= 0)
- evaluated.evaluation.reason = reason(
- 'incumbent-comparison',
- 'This candidate did not improve the current best result.',
- );
- observeTrace(context, (trace) => trace.updateEvaluation(evaluated.evaluation!));
- }
if (compared >= 0) return true;
best = evaluated.state;
bestVector = evaluated.vector;
- bestEvaluation = evaluated.evaluation;
- bestDates = dates;
return true;
};
const dates = [...context.workingDays].sort(
@@ -4142,8 +2657,8 @@ function* optimizeRulePlacements(
rule?.type === 'optimization' &&
rule.configuration.elements === 'work-sequences'
)
- yield* workSequencePlacementCandidates(result, rule, context, (candidate, dates) =>
- consider(candidate, dates, undefined, true),
+ yield* workSequencePlacementCandidates(result, rule, context, (candidate) =>
+ consider(candidate, true),
);
if (
allowAdd &&
@@ -4169,23 +2684,6 @@ function* optimizeRulePlacements(
allowAdd &&
best !== result &&
compareObjectiveLists(bestVector.objectives.slice(1, throughIndex + 2), baseline) < 0;
- if (deferGeneric)
- recordTraceCandidate(context, {
- status: 'not-examined',
- reason: reason(
- 'strict-prefix-bundle-progress',
- 'A finalized rule-specific bundle strictly improved the active prefix; generic alternatives for the previous state were deferred before continuing from that improvement.',
- rule ? [rule.id] : undefined,
- ),
- });
- if (reuseGeneric)
- recordTraceCandidate(context, {
- status: 'not-examined',
- reason: reason(
- 'unchanged-generic-state',
- 'The configured sampled generic neighborhood was already exhausted for this unchanged state; rule-specific bundles were examined separately.',
- ),
- });
for (const date of reuseGeneric || deferGeneric ? [] : dates) {
yield;
context.check();
@@ -4226,22 +2724,6 @@ function* optimizeRulePlacements(
.join('')}`
: undefined;
if (fundingKey !== undefined && evaluatedFunding.has(fundingKey)) {
- recordTraceCandidate(context, {
- status: 'not-examined',
- date,
- placement: {
- date,
- balanceId: balance.id,
- type: balance.type,
- supporting: false,
- ruleIds: [rule!.id],
- },
- reason: reason(
- 'equivalent-date-funding',
- 'An earlier valid finalized funder has identical active objective values and final ties for this date.',
- [rule!.id],
- ),
- });
continue;
}
const assigned = assignDateToBalance(
@@ -4259,13 +2741,13 @@ function* optimizeRulePlacements(
context,
);
}
- const evaluated = consider(assigned, [date], undefined, true);
+ const evaluated = consider(assigned, true);
if (evaluated && fundingKey !== undefined) evaluatedFunding.add(fundingKey);
}
}
if (!existing || (addingBalanceQuantity && best !== result)) continue;
const without = releasePlacement(result, date);
- consider(without, [date], date);
+ consider(without);
for (const balance of without.balances) {
yield;
if (
@@ -4285,7 +2767,7 @@ function* optimizeRulePlacements(
if (reassigned)
reassigned.placements.get(date)!.supporting =
existing.supporting && !isAtRisk(balance, context);
- consider(reassigned, [date], date);
+ consider(reassigned);
}
for (const destination of moveDates) {
yield;
@@ -4309,7 +2791,7 @@ function* optimizeRulePlacements(
second.placements.get(destination)!.supporting = existing.supporting;
second.placements.get(date)!.supporting = other.supporting;
}
- consider(second, [date, destination], date);
+ consider(second);
} else {
const moved = assignDateToBalance(
without,
@@ -4327,33 +2809,9 @@ function* optimizeRulePlacements(
beforeVector.objectives.slice(-2),
) >= 0
) {
- recordTraceCandidate(context, {
- status: 'pruned',
- date: destination,
- sourceDate: date,
- reason: reason(
- 'unchanged-objective-tie-bound',
- 'The move preserves every active rule value and cannot improve the final tie-breaks.',
- ),
- });
continue;
}
- consider(moved, [destination], date);
- }
- }
- if (moveDates.length < context.workingDays.length) {
- const examined = new Set(moveDates);
- for (const destination of context.workingDays) {
- if (examined.has(destination)) continue;
- recordTraceCandidate(context, {
- status: 'not-examined',
- date: destination,
- sourceDate: date,
- reason: reason(
- 'loaded-plan-sampling',
- 'This move destination was not examined in a loaded plan.',
- ),
- });
+ consider(moved);
}
}
}
@@ -4381,13 +2839,13 @@ function* optimizeRulePlacements(
balance.id,
rule && sourceRuleApplies ? new Set([rule.id]) : new Set(),
false,
- (candidate, dates) => {
+ (candidate) => {
if (rule?.type === 'optimization' && !isAtRisk(balance, context)) {
beforeCalendar ??= optimizationObservations(result, context);
candidate.placements.get(date)!.supporting =
supportingCalendarBenefit(beforeCalendar, candidate, context);
}
- return consider(candidate, dates, date, true);
+ return consider(candidate, true);
},
);
}
@@ -4399,17 +2857,7 @@ function* optimizeRulePlacements(
return result;
}
best = reconcileOptimizationProvenance(best, context, result, bestVector.objectives);
- if (context.traceBranchActive) {
- const parent = retainAndRecordBranchMutation(
- context,
- result,
- best,
- context.traceBranchParentId,
- bestEvaluation,
- bestDates,
- );
- context.traceBranchParentId = parent ?? context.traceBranchParentId;
- } else retainAndRecordState(context, best, bestEvaluation);
+ context.retain?.(best);
result = best;
}
}
@@ -4526,8 +2974,6 @@ function* preferenceIfThen(
let bestState = state;
let bestCount = 0;
const baseline = activeObjectiveVector(state, context, preferenceIndex);
- let bestBranch: ObligationBranchReference | undefined;
- let bestEvaluation: MutableTraceCandidate | undefined;
const search = function* (index: number, current: State, count: number): PlanningSteps {
yield;
context.check();
@@ -4548,37 +2994,11 @@ function* preferenceIfThen(
);
})
) {
- 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 = finalizeRuleCandidate(current, context, state);
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;
}
if (
@@ -4592,40 +3012,9 @@ function* preferenceIfThen(
context.retain?.(finalized);
const better =
compareObligationPreviews({ state: finalized }, { state: bestState }, context) < 0;
- let branch: ObligationBranchReference | undefined;
- let evaluation: MutableTraceCandidate | undefined;
- if (context.trace) {
- 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 = () =>
- obligationPreviewComparisonFacts(
- { state: finalized },
- { state: beforeState },
- context,
- );
- if (context.traceBranchActive)
- branch = recordTraceBranchDecision(
- context,
- state,
- finalized,
- context.traceBranchParentId,
- candidateDates,
- comparison,
- );
- else evaluation = recordTraceIfThenEvaluation(context, candidateDates, comparison);
- }
if (better) {
bestCount = count;
bestState = finalized;
- bestBranch = branch;
- bestEvaluation = evaluation;
}
return;
}
@@ -4654,12 +3043,7 @@ function* preferenceIfThen(
yield* search(index + 1, current, count);
};
yield* search(0, state, 0);
- if (bestBranch) {
- observeTrace(context, (trace) => trace.chooseEvaluation(bestBranch!.candidate));
- context.traceBranchParentId = bestBranch.decisionId;
- } else if (!context.traceBranchActive) {
- retainAndRecordState(context, bestState, bestEvaluation);
- }
+ context.retain?.(bestState);
return { state: bestState, count: bestCount };
}
@@ -4825,14 +3209,6 @@ function* applyPreferenceStep(
context,
);
if (context.completedPreferenceStates.get(index) === preferenceStateKey(result)) {
- recordTraceCandidate(context, {
- status: 'not-examined',
- reason: reason(
- 'unchanged-rule-state',
- `Rule "${rule.name}" already evaluated this unchanged state; its earlier decisions apply.`,
- [rule.id],
- ),
- });
return;
}
if (rule.type === 'optimization')
@@ -4989,25 +3365,6 @@ function tieBreakObjectives(state: State): PlanningObjective[] {
];
}
-function objectiveComparisonFacts(
- before: readonly PlanningObjective[],
- after: readonly PlanningObjective[],
-): {
- values: NonNullable;
- firstDecisiveComparator?: string;
-} {
- const first = firstDifferentObjective(after, before);
- return {
- values: after.slice(0, first < 0 ? after.length : first + 1).map((objective, index) => ({
- name: objective.id,
- direction: objective.direction,
- before: before[index].value,
- after: objective.value,
- })),
- firstDecisiveComparator: first < 0 ? undefined : after[first].id,
- };
-}
-
function obligationPreviewObjectives(
preview: { state: State },
context: SearchContext,
@@ -5027,24 +3384,6 @@ function compareObligationPreviews(
);
}
-function obligationPreviewComparisonFacts(
- a: { state: State },
- b: { state: State },
- context: SearchContext,
-): {
- values: NonNullable;
- firstDecisiveComparator?: string;
-} {
- return objectiveComparisonFacts(
- obligationPreviewObjectives(b, context),
- obligationPreviewObjectives(a, context),
- );
-}
-
-function recordTraceDecision(context: SearchContext, dates: readonly string[]): void {
- observeTrace(context, (trace) => trace.recordDecision('choose', dates));
-}
-
function isAtRisk(balance: InternalBalance, context: SearchContext): boolean {
return (
compareDate(balance.expiryDate, context.horizon.start) >= 0 &&
@@ -5705,17 +4044,11 @@ function makePlan(
completedStages: [...stages],
lastCompletedStage: stages[stages.length - 1] ?? 'validation',
};
- retainAndRecordState(context, state);
+ context.retain?.(state);
return plan;
}
function markProgress(context: SearchContext, stage: string): void {
- if (context.trace && stage !== 'complete') {
- observeTrace(context, (trace) => {
- trace.startPhase(stage);
- trace.recordDecision(stage === 'validation' ? 'validate' : 'choose', []);
- });
- }
if (typeof context.request.progress === 'function')
observeCallback(context, () =>
(context.request.progress as (stage: string) => void)(stage),
@@ -5840,11 +4173,10 @@ function obligationConflict(
};
}
-export type PlanningCheckpoint = (plan: PlanOutcome, state?: PlanningTraceState) => void;
+export type PlanningCheckpoint = (plan: PlanOutcome, state?: PlanningStateSnapshot) => void;
export interface PlanningArtifacts {
explanation?: PlanningExplanation;
- trace?: PlanningTrace;
- finalState?: PlanningTraceState;
+ finalState?: PlanningStateSnapshot;
explanationError?: PlanningReason;
timings?: PlanningArtifactTimings;
}
@@ -5853,7 +4185,7 @@ export type PlanningOutcomeCallback = (outcome: PlanningOutcome) => void;
export type PlanningExplanationCallback = (
explanation: PlanningExplanation | undefined,
explanationError: PlanningReason | undefined,
- finalState: PlanningTraceState | undefined,
+ finalState: PlanningStateSnapshot | undefined,
) => void;
/** @internal Synchronous adapter retained for non-browser callers and deterministic checks. */
@@ -5983,25 +4315,6 @@ function* calculatePlanningSteps(
explanationCallback?.(explanation, undefined, undefined);
artifactsCallback?.({
explanation,
- trace: {
- status: 'incomplete',
- initialState: {
- calculationDate: source.calculationDate,
- horizon,
- balances: normalized.balances.map((balance) => ({
- ...balance,
- remaining: balance.quantity,
- })),
- ...(normalized.declaredDays.length
- ? { declaredDays: normalized.declaredDays }
- : {}),
- placements: [],
- earnedActivations: [],
- },
- phases: [],
- completedStages: outcome.completedStages,
- error: outcome.reasons[0],
- },
});
return outcome;
}
@@ -6034,49 +4347,14 @@ function* calculatePlanningSteps(
protectedTypesByDate: new Map(),
preferenceDemandByDate: new Map(),
anyPreferenceDemandByDate: new Map(),
- traceTime: 0,
- traceCaptureTime: 0,
- traceTransferTime: 0,
- traceReportedTime: 0,
observerTime: 0,
- obligationSolutionBranches: new WeakMap(),
completedPreferenceStates: new Map(),
} as SearchContext;
- const traceStarted = now();
- const traceRecorder = artifactsCallback
- ? new TraceRecorder(
- {
- calculationDate: source.calculationDate,
- horizon,
- balances: normalized.balances.map((balance) => ({
- ...balance,
- remaining: balance.quantity,
- })),
- ...(normalized.declaredDays.length
- ? { declaredDays: normalized.declaredDays }
- : {}),
- placements: [],
- earnedActivations: [],
- },
- planningStages(context),
- new Set(workingDays),
- typeof context.request.traceProgress === 'function',
- )
- : undefined;
- 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.observerTime >=
- context.deadline
- )
+ if (now() - context.started - context.observerTime >= context.deadline)
throw new DeadlineReached();
}
};
@@ -6109,13 +4387,11 @@ function* calculatePlanningSteps(
: finalizeEarnedState(candidate, context);
if (!finalized) return;
if (unsatisfiedObligationCount(finalized, context)) return;
- const deltas = retained
- ? planningTraceDeltas(retained.state, finalized, [
- ...retained.state.placements.keys(),
- ...finalized.placements.keys(),
- ])
- : [];
- if (retained && !deltas.length) return;
+ if (
+ retained &&
+ preferenceStateKey(retained.state) === preferenceStateKey(finalized)
+ )
+ return;
const vector = candidateVector ?? objectiveVector(finalized, context);
const compared = retained
? compareObjectiveLists(vector.objectives, retained.vector.objectives)
@@ -6124,42 +4400,21 @@ function* calculatePlanningSteps(
const provenanceRefresh =
compared === 0 &&
!!retained &&
- deltas.every(
- ({ kind, placement, previousPlacement }) =>
- kind === 'placement-updated' &&
- placement?.date === previousPlacement?.date &&
- placement?.balanceId === previousPlacement?.balanceId &&
- placement?.type === previousPlacement?.type &&
- placement?.supporting === previousPlacement?.supporting,
- ) &&
+ JSON.stringify(retained.state.balances) ===
+ JSON.stringify(finalized.balances) &&
+ retained.state.placements.size === finalized.placements.size &&
+ [...retained.state.placements].every(([date, previous]) => {
+ const placement = finalized.placements.get(date);
+ return (
+ placement &&
+ placement.balanceId === previous.balanceId &&
+ placement.type === previous.type &&
+ placement.supporting === previous.supporting
+ );
+ }) &&
JSON.stringify(retained.state.earnedActivations) ===
JSON.stringify(finalized.earnedActivations);
const chosen = compared < 0 || provenanceRefresh;
- if (retained) {
- observeTrace(context, (trace) => {
- const facts = objectiveComparisonFacts(
- retained!.vector.objectives,
- vector.objectives,
- );
- trace.recordCandidate(
- {
- status: chosen ? 'chosen' : 'evaluated-rejected',
- dates: [...finalized.placements.keys()],
- technicalComparison: facts.values,
- firstDecisiveComparator: facts.firstDecisiveComparator,
- reason: reason(
- 'incumbent-comparison',
- compared < 0
- ? 'This valid candidate became the best available partial plan.'
- : provenanceRefresh
- ? 'This identical plan refreshed only the retained rule provenance.'
- : 'This valid candidate did not improve the best available partial plan.',
- ),
- },
- false,
- );
- });
- }
if (chosen) {
const firstValidCandidate = !retained;
const candidatePreferences = preferenceResultsForState(finalized, context);
@@ -6187,7 +4442,7 @@ function* calculatePlanningSteps(
observeCallback(context, () =>
checkpointCallback(
makePlan(retained!.state, context, retained!.preferences, stages),
- planningTraceState(retained!.state, context),
+ planningStateSnapshot(retained!.state, context),
),
);
}
@@ -6198,15 +4453,14 @@ function* calculatePlanningSteps(
const finish = (outcome: PlanningOutcome, explanationState?: State): PlanningOutcome => {
outcome.calculationDate = source.calculationDate;
const planningFinishedAt = now();
- const planningExcludedTime = context.traceTime + context.observerTime;
+ const planningExcludedTime = 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;
+ let finalState: PlanningStateSnapshot | undefined;
try {
if (outcome.kind === 'Plan' && explanationState)
explanation = planExplanation(
@@ -6258,7 +4512,7 @@ function* calculatePlanningSteps(
);
}
try {
- if (explanationState) finalState = planningTraceState(explanationState, context);
+ if (explanationState) finalState = planningStateSnapshot(explanationState, context);
} catch (error) {
explanationError ??= reason(
'explanation-error',
@@ -6266,8 +4520,6 @@ function* calculatePlanningSteps(
);
}
if (explanation) explanation.calculationDate = source.calculationDate;
- let explanationDeliveryTime: number;
- const explanationDeliveryStarted = now();
try {
explanationCallback?.(explanation, explanationError, finalState);
} catch (error) {
@@ -6275,97 +4527,13 @@ function* calculatePlanningSteps(
'explanation-error',
error instanceof Error ? error.message : String(error),
);
- } finally {
- explanationDeliveryTime = now() - explanationDeliveryStarted;
}
- let trace: PlanningTrace | undefined;
- try {
- trace = traceRecorder?.finish(
- context.traceError
- ? 'failed'
- : outcome.kind === 'PlanningIncomplete'
- ? 'incomplete'
- : 'ready',
- outcome.kind === 'Plan'
- ? outcome.completedStages
- : outcome.kind === 'PlanningIncomplete'
- ? outcome.completedStages
- : stages,
- context.traceError ??
- (outcome.kind === 'PlanningIncomplete' ? outcome.reasons[0] : undefined),
- );
- } catch (error) {
- const traceError = reason(
- 'trace-error',
- error instanceof Error ? error.message : String(error),
- );
- trace = {
- status: 'failed',
- initialState: {
- calculationDate: source.calculationDate,
- horizon,
- balances: normalized.balances.map((balance) => ({
- ...balance,
- remaining: balance.quantity,
- })),
- ...(normalized.declaredDays.length
- ? { declaredDays: normalized.declaredDays }
- : {}),
- placements: [],
- earnedActivations: [],
- },
- 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,
explanationError,
timings: {
planningWorkMs: Math.max(0, planningFinishedAt - started - planningExcludedTime),
- traceCaptureMs: context.traceCaptureTime,
- traceMaterializationMs,
- traceTransferMs: context.traceTransferTime,
},
});
return outcome;
@@ -6376,16 +4544,9 @@ function* calculatePlanningSteps(
checkpoint = makePlan(state, context, preferences, stages);
checkpointState = cloneState(state);
if (checkpointCallback) {
- let checkpointSnapshot: PlanningTraceState;
- const captureStarted = now();
- try {
- checkpointSnapshot = planningTraceState(checkpointState, context);
- } finally {
- const captureTime = now() - captureStarted;
- context.traceTime += captureTime;
- context.traceCaptureTime += captureTime;
- }
- observeCallback(context, () => checkpointCallback(checkpoint!, checkpointSnapshot));
+ observeCallback(context, () =>
+ checkpointCallback(checkpoint!, planningStateSnapshot(checkpointState!, context)),
+ );
}
};
try {
@@ -6405,7 +4566,7 @@ function* calculatePlanningSteps(
state = addEarnedBalance(state, rule, occurrence, pattern, context);
}
}
- if (state !== initialState) retainAndRecordState(context, state);
+ if (state !== initialState) context.retain?.(state);
}
// Equivalent obligation placements can share compatible dates with active preferences.
context.protectedTypesByDate = futurePreferenceProtection(normalized.rules, 0, context);
@@ -6493,17 +4654,6 @@ function* calculatePlanningSteps(
previewSolutions = ranked
.slice(0, WINDOW_OBLIGATION_PREVIEW_LIMIT)
.map(({ state }) => state);
- for (const candidate of ranked.slice(WINDOW_OBLIGATION_PREVIEW_LIMIT)) {
- yield;
- recordTraceCandidate(context, {
- status: 'not-examined',
- dates: [...candidate.state.placements.keys()],
- reason: reason(
- 'window-obligation-preview-sampling',
- 'This feasible large-window solution did not receive an optimization preview.',
- ),
- });
- }
}
// A completed enumeration with one finalized solution has no alternative to rank.
const soleSolution =
@@ -6525,64 +4675,29 @@ function* calculatePlanningSteps(
for (const obligationSolution of soleSolution ? [] : previewSolutions) {
yield;
context.check();
- 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 preview = yield* applyPreferenceRules(
- cloneState(obligationSolution),
- 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, context)
- : undefined;
- const branch: ObligationBranchReference | undefined =
- compared !== undefined
- ? recordTraceBranchDecision(
- context,
- previewState,
- previewState,
- context.traceBranchParentId,
- [],
- () =>
- obligationPreviewComparisonFacts(
- candidate,
- bestPreview!,
- context,
- ),
- )
- : undefined;
- const previewCandidate: ObligationPreview = {
- ...candidate,
- previewBranch: branch,
- };
- if (!bestPreview || compared! < 0) bestPreview = previewCandidate;
- } finally {
- context.traceBranchActive = previousBranchActive;
- context.traceBranchParentId = previousBranchParent;
- }
+ const preview = yield* applyPreferenceRules(
+ cloneState(obligationSolution),
+ 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, context)
+ : undefined;
+ if (!bestPreview || compared! < 0) bestPreview = candidate;
}
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();
for (let preferenceIndex = 0; preferenceIndex < preferenceRules.length; preferenceIndex++) {
@@ -6645,11 +4760,6 @@ function* calculatePlanningSteps(
),
}));
checkpoint = makePlan(checkpointState, context, partialPreferences, stages);
- recordTraceDecision(
- context,
- checkpoint.placements.map(({ date }) => date),
- );
- retainAndRecordState(context, checkpointState);
}
const interruption =
error instanceof UserCancelled
@@ -6680,7 +4790,7 @@ function* calculatePlanningSteps(
export function explainIncompleteCheckpoint(
request: unknown,
incumbent: PlanOutcome,
- checkpoint: PlanningTraceState,
+ checkpoint: PlanningStateSnapshot,
interruption: PlanningReason,
): PlanningArtifacts {
try {
@@ -6726,12 +4836,7 @@ export function explainIncompleteCheckpoint(
protectedTypesByDate: new Map>(),
preferenceDemandByDate: new Map(),
anyPreferenceDemandByDate: new Map(),
- traceTime: 0,
- traceCaptureTime: 0,
- traceTransferTime: 0,
- traceReportedTime: 0,
observerTime: 0,
- obligationSolutionBranches: new WeakMap(),
completedPreferenceStates: new Map(),
} as SearchContext;
context.holidayWeeks = holidayWeekCandidates(context);
@@ -6773,7 +4878,7 @@ export function explainIncompleteCheckpoint(
uncompletedStages,
},
},
- finalState: planningTraceState(state, context),
+ finalState: planningStateSnapshot(state, context),
};
} catch (error) {
return {
@@ -6831,19 +4936,14 @@ function counterfactualContext(
protectedTypesByDate: new Map>(),
preferenceDemandByDate: new Map(),
anyPreferenceDemandByDate: new Map(),
- traceTime: 0,
- traceCaptureTime: 0,
- traceTransferTime: 0,
- traceReportedTime: 0,
observerTime: 0,
- obligationSolutionBranches: new WeakMap(),
completedPreferenceStates: new Map(),
} as SearchContext;
context.holidayWeeks = holidayWeekCandidates(context);
return context;
}
-function stateFromPlanningTrace(state: PlanningTraceState): State {
+function stateFromPlanningSnapshot(state: PlanningStateSnapshot): State {
return {
balances: state.balances.map((balance) => ({ ...balance })),
placements: new Map(
@@ -6859,71 +4959,6 @@ function stateFromPlanningTrace(state: PlanningTraceState): State {
};
}
-/** 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, sourceState.calculationDate);
- 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 (context.declaredDates.has(date))
- ineligible = counterfactualReason(
- 'declared-day',
- 'The date is a declared non-working day fixed by the user.',
- );
- 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;
}
@@ -7093,8 +5128,7 @@ export function analyzeCounterfactualInternal(
request: unknown,
plan: PlanOutcome,
selected: LeavePlacement,
- finalStateSnapshot?: PlanningTraceState,
- options?: CounterfactualBaselineOptions,
+ finalStateSnapshot?: PlanningStateSnapshot,
): CounterfactualAnalysis {
const source = runtimeRequest(
request,
@@ -7104,7 +5138,7 @@ export function analyzeCounterfactualInternal(
const horizon = normalized.horizon ?? plan.horizon;
const context = counterfactualContext(source, normalized, horizon);
const finalState: State = finalStateSnapshot
- ? stateFromPlanningTrace(finalStateSnapshot)
+ ? stateFromPlanningSnapshot(finalStateSnapshot)
: (() => {
const balances = normalized.balances.map((balance) => ({
...balance,
@@ -7145,12 +5179,12 @@ export function analyzeCounterfactualInternal(
})();
const comparisonState = cloneState(finalState);
const baseline = cloneState(finalState);
- const removeDate = options ? options.removeDate : selected.date;
+ const removeDate = selected.date;
const removed = removeDate ? baseline.placements.get(removeDate) : undefined;
if (removeDate) baseline.placements.delete(removeDate);
const released = baseline.balances.find((balance) => balance.id === selected.balanceId);
if (released && removed?.balanceId === selected.balanceId) released.remaining++;
- const beforeState = options?.compareOriginalState ? comparisonState : baseline;
+ const beforeState = baseline;
const before = objectiveVector(beforeState, context);
const results: CounterfactualDateResult[] = [];
for (const date of dateRange(horizon.start, horizon.end)) {
diff --git a/src/app/services/leave-planner.service.spec.ts b/src/app/services/leave-planner.service.spec.ts
index 24c9d66..ecac615 100644
--- a/src/app/services/leave-planner.service.spec.ts
+++ b/src/app/services/leave-planner.service.spec.ts
@@ -1,7 +1,6 @@
import { createOptimizationPresetRules } from './rule-service';
import { vi } from 'vitest';
import {
- applyReplayDeltas,
PlanningOutcome,
PlanningRequest,
createPlanningCalculation,
@@ -9,13 +8,10 @@ import {
} from './leave-planner.service';
import type {
CounterfactualAnalysis,
- HistoricalEligibilityReason,
PlanOutcome,
PlanningReason,
- PlanningTraceState,
- PlanningTraceUpdate,
+ PlanningStateSnapshot,
} from './leave-planner.service';
-import type { CounterfactualBaselineOptions } from './leave-planner.core';
import { OPTIMIZATION_PRESETS } from './planning-objectives';
const supportingEconomy = {
@@ -798,8 +794,6 @@ describe('declared holiday patterns and captured entitlement date', () => {
declaredDays: fixed,
ifThenContributions: result.ifThenContributions,
});
- const trace = await calculation.trace;
- expect(trace?.initialState.calculationDate).toBe('2024-05-01');
const comparison = await calculation.analyzeCounterfactual(result.placements[0]);
expect(comparison.kind).toBe('Counterfactual');
if (comparison.kind === 'Counterfactual') {
@@ -813,21 +807,6 @@ describe('declared holiday patterns and captured entitlement date', () => {
'eligible',
);
}
- const consumerDecision = trace?.phases
- .flatMap(({ decisions }) => decisions)
- .find(({ deltas }) =>
- deltas.some(({ placement }) => placement?.balanceId.startsWith('earned:')),
- );
- expect(consumerDecision).toBeDefined();
- if (consumerDecision)
- expect(
- (
- await calculation.analyzeCounterfactual({
- decisionId: consumerDecision.id,
- placement: result.placements[0],
- })
- ).kind,
- ).toBe('Counterfactual');
} finally {
vi.unstubAllGlobals();
}
@@ -1150,42 +1129,30 @@ describe('planLeave', () => {
it('keeps the joint search exhaustive when fixed credits leave a small number of new placements', async () => {
const core = await import('./leave-planner.core');
- let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
const result = isPlan(
- core.calculatePlanningInternal(
- {
- horizon: { start: '2024-01-08', end: '2024-04-17' },
- balances: [
- balance('cp', 'cp', 1, '2025-01-01'),
- balance('rtt', 'rtt', 1, '2025-01-01'),
- ],
- declaredDays: [
- { date: '2024-01-08', type: 'cp' },
- { date: '2024-01-09', type: 'rtt' },
- ],
- rules: ['cp', 'rtt'].map((placeType) => ({
- id: placeType,
- type: 'time_window',
- force: 'obligation',
- startDate: '2024-01-08',
- endDate: '2024-04-16',
- placeDays: 2,
- placeType,
- })),
- },
- undefined,
- (value) => (artifacts = value),
- ),
+ core.calculatePlanningInternal({
+ horizon: { start: '2024-01-08', end: '2024-04-17' },
+ balances: [
+ balance('cp', 'cp', 1, '2025-01-01'),
+ balance('rtt', 'rtt', 1, '2025-01-01'),
+ ],
+ declaredDays: [
+ { date: '2024-01-08', type: 'cp' },
+ { date: '2024-01-09', type: 'rtt' },
+ ],
+ rules: ['cp', 'rtt'].map((placeType) => ({
+ id: placeType,
+ type: 'time_window',
+ force: 'obligation',
+ startDate: '2024-01-08',
+ endDate: '2024-04-16',
+ placeDays: 2,
+ placeType,
+ })),
+ }),
);
expect(result.placements).toHaveLength(2);
expect(result.placements.map(({ type }) => type).sort()).toEqual(['cp', 'rtt']);
- const evaluated =
- artifacts.trace?.phases.flatMap((phase) =>
- phase.decisions.flatMap((decision) => decision.candidates),
- ) ?? [];
- expect(evaluated.some(({ reason }) => reason?.code === 'obligation-solution-limit')).toBe(
- false,
- );
expect(
result.timeWindowContributions?.map(({ declaredDays }) => declaredDays.length),
).toEqual([1, 1]);
@@ -1267,7 +1234,6 @@ describe('planLeave', () => {
],
bottleneck: { code: 'joint-date-conflict' },
});
- expect(artifacts.trace?.initialState.declaredDays).toEqual(declaredDays);
});
it('distinguishes insufficient compatible leave from malformed rules without converting a fixed RTT', async () => {
@@ -2908,7 +2874,7 @@ describe('planLeave', () => {
});
describe('planLeave transport', () => {
- it('keeps a responsive phase active beyond repeated liveness windows and delivers its trace later', async () => {
+ it('keeps a responsive phase active beyond repeated liveness windows', async () => {
const request = { horizon, balances: [], rules: [] };
const expected = isPlan(await runInNonBrowser(request));
const workers: ResponsiveWorker[] = [];
@@ -2930,9 +2896,7 @@ describe('planLeave transport', () => {
const calculation = createPlanningCalculation(request);
const worker = workers[0];
let outcomeReady = false;
- let traceReady = false;
void calculation.outcome.then(() => (outcomeReady = true));
- void calculation.trace.then(() => (traceReady = true));
for (let tick = 0; tick < 10; tick++) {
await vi.advanceTimersByTimeAsync(10_000);
worker.emit({ type: 'heartbeat' });
@@ -2942,10 +2906,8 @@ describe('planLeave transport', () => {
worker.emit({ type: 'result', outcome: expected });
expect(await calculation.outcome).toEqual(expected);
await vi.advanceTimersByTimeAsync(60_000);
- expect(traceReady).toBe(false);
expect(worker.terminate).not.toHaveBeenCalled();
worker.emit({ type: 'artifacts', artifacts: {} });
- await calculation.trace;
expect(await calculation.outcome).toEqual(expected);
expect(worker.postMessage).toHaveBeenCalledOnce();
expect(worker.terminate).toHaveBeenCalledOnce();
@@ -3003,7 +2965,7 @@ describe('planLeave transport', () => {
}
});
- it('keeps the delivered checkpoint prefix and recovers its explanation after watchdog failure', async () => {
+ it('keeps the delivered checkpoint 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' },
@@ -3011,7 +2973,7 @@ describe('planLeave transport', () => {
rules: [],
};
let checkpoint: PlanOutcome | undefined;
- let checkpointState: PlanningTraceState | undefined;
+ let checkpointState: PlanningStateSnapshot | undefined;
const expected = core.calculatePlanningInternal(request, (plan, state) => {
checkpoint = plan;
checkpointState = state;
@@ -3023,49 +2985,6 @@ describe('planLeave transport', () => {
completedStages: ['validation', 'obligations', 'optimization:default-unplaced-at-risk'],
lastCompletedStage: 'optimization:default-unplaced-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;
@@ -3083,19 +3002,16 @@ describe('planLeave transport', () => {
type: 'explain-checkpoint';
request: unknown;
plan: PlanOutcome;
- state: PlanningTraceState;
+ state: PlanningStateSnapshot;
interruption: PlanningReason;
},
): void {
if (message.type === 'plan') {
- queueMicrotask(() => {
+ queueMicrotask(() =>
this.onmessage?.({
data: { type: 'checkpoint', plan: checkpoint, state: checkpointState },
- } as MessageEvent);
- this.onmessage?.({
- data: { type: 'trace-update', totalMs: 0, update: prefix },
- } as MessageEvent);
- });
+ } as MessageEvent),
+ );
return;
}
if (message.type === 'explain-checkpoint')
@@ -3128,10 +3044,6 @@ describe('planLeave transport', () => {
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')
@@ -3181,46 +3093,6 @@ describe('planLeave transport', () => {
}
});
- 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: [] }),
- );
- class TraceAwareWorker {
- onmessage: ((event: MessageEvent) => void) | null = null;
- onerror: (() => void) | null = null;
-
- postMessage(): void {
- 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);
- }, 300);
- }
-
- terminate(): void {}
- }
- vi.stubGlobal('Worker', TraceAwareWorker);
- try {
- expect(
- await planLeave({
- horizon,
- balances: [balance('cp', 'cp', 1)],
- rules: [],
- deadlineMs: 100,
- }),
- ).toEqual(expected);
- } finally {
- vi.unstubAllGlobals();
- }
- });
-
it.each([undefined, 25_000])(
'sends a detached, callback-free request with budget %s',
async (deadlineMs) => {
@@ -3381,25 +3253,6 @@ describe('planning calculation context', () => {
]),
},
});
- const trace = await calculation.trace;
- expect(trace?.initialState.declaredDays).toEqual(originalDeclarations);
- const deltas =
- trace?.phases.flatMap((phase) =>
- phase.decisions.flatMap((decision) => decision.deltas),
- ) ?? [];
- const placements = deltas.filter((delta) => delta.kind === 'placement-added');
- expect(placements.length).toBeGreaterThan(0);
- for (const delta of placements) {
- expect(delta.placement?.date).not.toBe('2024-01-10');
- }
- const debits = deltas.filter(
- (delta) =>
- delta.kind === 'balance-changed' &&
- delta.remainingBefore! > delta.remainingAfter!,
- );
- expect(debits.length).toBeGreaterThan(0);
- for (const debit of debits)
- expect(debit.remainingBefore! - debit.remainingAfter!).toBe(1);
const before = structuredClone(outcome);
const analysis = await calculation.analyzeCounterfactual(outcome.placements[0]);
expect(analysis.results.find(({ date }) => date === '2024-01-10')).toMatchObject({
@@ -3498,33 +3351,6 @@ describe('planning calculation context', () => {
'optimization:default-unplaced-at-risk',
);
}
- const trace = await calculation.trace;
- expect(trace?.initialState.declaredDays).toEqual(original);
- const evaluated = trace?.phases.flatMap((phase) =>
- phase.decisions.flatMap((decision) => decision.candidates),
- );
- const fixedDates = new Set(original?.map(({ date }) => date));
- for (const candidate of evaluated?.filter(({ status }) => status === 'chosen') ?? [])
- expect(
- (candidate.dates ?? [candidate.date]).some(
- (date) => date && fixedDates.has(date),
- ),
- ).toBe(false);
- expect(
- evaluated?.some(
- (candidate) =>
- candidate.comparison?.after.objectives.some(
- (objective) =>
- objective.id === 'optimization:default-holiday-week-value' &&
- objective.value === 3,
- ) &&
- candidate.comparison.after.objectives.some(
- (objective) =>
- objective.id === 'optimization:default-non-working-day-gain' &&
- objective.value === 5,
- ),
- ),
- ).toBe(true);
const before = structuredClone(outcome);
const analysis = await calculation.analyzeCounterfactual(outcome.placements[0]);
const restored = analysis.results.find(
@@ -3551,7 +3377,7 @@ describe('planning calculation context', () => {
}
});
- it('detaches declared dates and types across results, explanations, trace and comparisons', async () => {
+ it('detaches declared dates and types across results, explanations and comparisons', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
@@ -3579,19 +3405,12 @@ describe('planning calculation context', () => {
declaredDays: original,
placements: [{ placement: { date: '2024-01-04' } }],
});
- const trace = await calculation.trace;
- expect(trace?.initialState.declaredDays).toEqual(original);
- expect(trace?.nonWorkingDates).toContainEqual({
- date: '2024-01-02',
- reason: expect.objectContaining({ code: 'declared-day' }),
- });
const frozenOutcome = structuredClone(outcome);
const analysis = await calculation.analyzeCounterfactual(outcome.placements[0]);
for (const { date } of original!)
expect(analysis.results.find((result) => result.date === date)).toMatchObject({
status: 'ineligible',
reason: { code: 'declared-day' },
- historicalStatus: 'ineligible',
});
expect(outcome).toEqual(frozenOutcome);
} finally {
@@ -3599,7 +3418,7 @@ describe('planning calculation context', () => {
}
});
- it('keeps a multi-year horizon attached to its immutable outcome, explanation and original trace', async () => {
+ it('keeps a multi-year horizon attached to its immutable outcome and explanation', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
@@ -3641,123 +3460,11 @@ describe('planning calculation context', () => {
placement: { date: '2026-12-24', balanceId: 'cp', ruleIds: ['final-year'] },
balance: { availableFrom: '2026-12-24', expiryDate: '2026-12-24' },
});
- expect((await calculation.trace)?.initialState.horizon).toEqual(
- calculation.snapshot.horizon,
- );
} finally {
vi.stubGlobal('window', browserWindow);
}
});
- 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 = {
- horizon: { start: '2024-01-01', end: '2024-02-01' },
- balances: [balance('cp', 'cp', 3, '2024-01-31')],
- rules: [],
- };
- const withoutTrace = core.calculatePlanningInternal(request);
- let traceStatus: string | undefined;
- const withTrace = core.calculatePlanningInternal(request, undefined, (artifacts) => {
- traceStatus = artifacts.trace?.status;
- });
-
- expect(withTrace).toEqual(withoutTrace);
- expect(traceStatus).toBe('ready');
- });
-
it('keeps an immutable submitted snapshot beside the outcome', async () => {
const request = {
horizon,
@@ -3978,74 +3685,6 @@ describe('planning calculation context', () => {
}
});
- it('allows trace counterfactuals while later obligations are still pending', async () => {
- const browserWindow = globalThis.window;
- vi.stubGlobal('window', undefined);
- try {
- const calculation = createPlanningCalculation({
- horizon: { start: '2024-05-01', end: '2024-05-10' },
- balances: [balance('cp', 'cp', 2, '2024-12-31')],
- rules: [
- {
- id: 'early-window',
- type: 'time_window',
- force: 'obligation',
- startDate: '2024-05-02',
- endDate: '2024-05-03',
- placeDays: 1,
- placeType: 'cp',
- },
- {
- id: 'late-window',
- type: 'time_window',
- force: 'obligation',
- startDate: '2024-05-06',
- endDate: '2024-05-07',
- placeDays: 1,
- placeType: 'cp',
- },
- ],
- });
- const outcome = await calculation.outcome;
- const trace = await calculation.trace;
- expect(outcome.kind).toBe('Plan');
- if (outcome.kind !== 'Plan' || !trace) return;
- const earlyPlacement = outcome.placements.find((placement) =>
- placement.ruleIds.includes('early-window'),
- );
- expect(earlyPlacement).toBeDefined();
- if (!earlyPlacement) return;
- const decision = trace.phases
- .flatMap((phase) => phase.decisions)
- .find((candidate) =>
- candidate.deltas.some(
- (delta) =>
- delta.kind === 'placement-added' &&
- delta.placement?.ruleIds.includes('early-window'),
- ),
- );
- expect(decision).toBeDefined();
- if (!decision) return;
-
- const analysis = await calculation.analyzeCounterfactual({
- decisionId: decision.id,
- placement: earlyPlacement,
- });
- expect(analysis.kind).toBe('Counterfactual');
- if (analysis.kind !== 'Counterfactual') return;
- expect(
- ['2024-05-02', '2024-05-03'].map(
- (date) => analysis.results.find((result) => result.date === date)?.status,
- ),
- ).toEqual(['eligible', 'eligible']);
- expect(
- analysis.results.find((result) => result.date === earlyPlacement.date)?.status,
- ).toBe('eligible');
- } finally {
- vi.stubGlobal('window', browserWindow);
- }
- });
-
it('preserves supporting provenance in counterfactual placements', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
@@ -4076,7 +3715,7 @@ describe('planning calculation context', () => {
}
});
- it('cancels superseded planning and trace workers without changing the latest result', async () => {
+ it('cancels superseded planning workers without changing the latest result', async () => {
const browserWindow = globalThis.window;
const expected = isPlan(
await runInNonBrowser({
@@ -4085,17 +3724,6 @@ describe('planning calculation context', () => {
rules: [],
}),
);
- const trace = {
- status: 'ready' as const,
- initialState: {
- horizon: expected.horizon,
- balances: [],
- placements: [],
- earnedActivations: [],
- },
- phases: [],
- completedStages: [],
- };
const workers: Array<{ terminated: boolean }> = [];
class SupersededWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
@@ -4119,7 +3747,7 @@ describe('planning calculation context', () => {
data: { type: 'result', outcome: expected },
} as MessageEvent);
this.onmessage?.({
- data: { type: 'artifacts', artifacts: { trace } },
+ data: { type: 'artifacts', artifacts: {} },
} as MessageEvent);
});
}
@@ -4154,12 +3782,9 @@ describe('planning calculation context', () => {
expect(pendingOutcome.kind).toBe('PlanningIncomplete');
if (pendingOutcome.kind === 'PlanningIncomplete')
expect(pendingOutcome.reasons[0].code).toBe('stale-calculation');
- expect((await pending.trace)?.error?.code).toBe('stale-calculation');
expect(await materializing.outcome).toEqual(expected);
- expect((await materializing.trace)?.error?.code).toBe('stale-calculation');
expect(await materializing.explanationError).toBeUndefined();
expect(await latest.outcome).toEqual(expected);
- expect((await latest.trace)?.status).toBe('ready');
expect(workers[2].terminated).toBe(true);
} finally {
vi.unstubAllGlobals();
@@ -4197,17 +3822,6 @@ describe('planning calculation context', () => {
};
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',
@@ -4229,19 +3843,8 @@ describe('planning calculation context', () => {
data: { type: 'result', outcome: expected },
} as MessageEvent);
this.onmessage?.({
- data: {
- type: 'explanation',
- explanation: { kind: 'PlanExplanation', placements: [] },
- },
+ data: { type: 'artifacts', artifacts: {} },
} as MessageEvent);
- this.onmessage?.({
- data: {
- type: 'artifacts-start',
- artifacts: {},
- trace,
- },
- } as MessageEvent);
- this.onmessage?.({ data: { type: 'artifacts-complete' } } as MessageEvent);
});
return;
}
@@ -4272,7 +3875,6 @@ describe('planning calculation context', () => {
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.',
);
@@ -4293,17 +3895,6 @@ describe('planning calculation context', () => {
rules: [presets[3]],
};
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;
@@ -4321,9 +3912,8 @@ describe('planning calculation context', () => {
data: { type: 'result', outcome: expected },
} as MessageEvent);
this.onmessage?.({
- data: { type: 'artifacts-start', artifacts: {}, trace },
+ data: { type: 'artifacts', artifacts: {} },
} as MessageEvent);
- this.onmessage?.({ data: { type: 'artifacts-complete' } } as MessageEvent);
});
}
@@ -4337,7 +3927,6 @@ describe('planning calculation context', () => {
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);
@@ -4397,645 +3986,6 @@ describe('planning calculation context', () => {
expect(
analysis.results.find((result) => result.date === '2024-04-25')?.reason?.code,
).toBe('unavailable');
- const trace = await calculation.trace;
- const earnedBalanceDelta = trace?.phases
- .flatMap((phase) => phase.decisions)
- .flatMap((decision) => decision.deltas)
- .find((delta) => delta.balance?.id === earned.balanceId);
- expect(earnedBalanceDelta?.balance).toMatchObject({
- id: earned.balanceId,
- type: earned.type,
- earned: true,
- });
- } finally {
- vi.stubGlobal('window', browserWindow);
- }
- });
-
- it('replays a trace decision from its pre-decision state', async () => {
- const browserWindow = globalThis.window;
- vi.stubGlobal('window', undefined);
- try {
- const calculation = createPlanningCalculation({
- horizon: { start: '2024-01-01', end: '2024-02-01' },
- balances: [balance('cp', 'cp', 2, '2024-01-31')],
- rules: [
- presets[0],
- ...[
- {
- id: 'wide',
- type: 'time_window',
- force: 'obligation',
- startDate: '2024-01-01',
- endDate: '2024-01-31',
- placeDays: 1,
- placeType: 'cp',
- },
- ],
- ],
- });
- const outcome = await calculation.outcome;
- const trace = await calculation.trace;
- expect(outcome.kind).toBe('Plan');
- if (outcome.kind !== 'Plan' || !trace) return;
- const decision = trace.phases
- .flatMap((phase) => phase.decisions)
- .find((candidate) =>
- candidate.deltas.some((delta) => delta.kind === 'placement-added'),
- );
- const placement = decision?.deltas.find(
- (delta) => delta.kind === 'placement-added',
- )?.placement;
- expect(decision).toBeDefined();
- expect(placement).toBeDefined();
- if (!decision || !placement) return;
- const laterPlacement = outcome.placements.find(
- (candidate) => candidate.date !== placement.date,
- );
- expect(laterPlacement).toBeDefined();
- if (!laterPlacement) return;
-
- const analysis = await calculation.analyzeCounterfactual({
- decisionId: decision.id,
- placement,
- });
- expect(analysis.kind).toBe('Counterfactual');
- if (analysis.kind !== 'Counterfactual') return;
- expect(
- analysis.results.find((result) => result.date === laterPlacement.date)?.status,
- ).toBe('eligible');
- expect(
- analysis.results.find((result) => result.date === '2024-01-06')?.historicalStatus,
- ).toBe('ineligible');
- } finally {
- vi.stubGlobal('window', browserWindow);
- }
- });
-
- 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: [presets[0]],
- });
- 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: [presets[3]],
- };
- 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 core = await import('./leave-planner.core');
- const request: PlanningRequest = {
- horizon: { start: '2024-01-01', end: '2024-01-10' },
- balances: [balance('cp', 'cp', 1, '2024-01-10')],
- rules: [presets[0]],
- };
- let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
- const outcome = core.calculatePlanningInternal(
- request,
- undefined,
- (value) => (artifacts = value),
- );
- expect(outcome.kind).toBe('Plan');
- const trace = artifacts.trace && {
- ...artifacts.trace,
- phases: [...artifacts.trace.phases],
- };
- artifacts = { ...artifacts, trace };
- if (outcome.kind !== 'Plan' || !trace)
- throw new Error('Expected an actual plan and trace.');
- const decision = trace.phases
- .flatMap((phase) => phase.decisions)
- .find((item) => item.deltas.some((delta) => delta.kind === 'placement-added'));
- const placement = decision?.deltas.find(
- (delta) => delta.kind === 'placement-added',
- )?.placement;
- if (!decision || !placement) throw new Error('Expected an actual placement decision.');
- // An unrelated decision's rejection must not become the selected decision's history.
- const unrelatedDate = '2024-01-04';
- decision.candidates = decision.candidates.filter(
- (candidate) =>
- !(candidate.dates ?? (candidate.date ? [candidate.date] : [])).includes(
- unrelatedDate,
- ),
- );
- trace.phases.push({
- id: 'unrelated-phase',
- name: 'unrelated',
- completed: true,
- status: 'completed',
- decisions: [
- {
- id: 'unrelated-decision',
- action: 'reject',
- dates: [unrelatedDate],
- deltas: [],
- candidates: [
- {
- status: 'ineligible',
- date: unrelatedDate,
- reason: {
- code: 'rule-invalid',
- message: 'Another decision rejected this date.',
- },
- },
- ],
- },
- ],
- });
- class HistoricalTraceWorker {
- onmessage: ((event: MessageEvent) => void) | null = null;
- onerror: (() => void) | null = null;
- postMessage(): void {
- queueMicrotask(() => {
- this.onmessage?.({ data: { type: 'result', outcome } } as MessageEvent);
- this.onmessage?.({ data: { type: 'artifacts', artifacts } } as MessageEvent);
- });
- }
- terminate(): void {}
- }
- const browserWindow = globalThis.window;
- vi.stubGlobal('window', {});
- vi.stubGlobal('Worker', HistoricalTraceWorker);
- try {
- const calculation = createPlanningCalculation(request);
- await calculation.trace;
- vi.stubGlobal('window', undefined);
- const analysis = await calculation.analyzeCounterfactual({
- decisionId: decision.id,
- placement,
- });
- expect(analysis.kind).toBe('Counterfactual');
- if (analysis.kind !== 'Counterfactual') return;
- expect(analysis.results.find((result) => result.date === unrelatedDate)).toMatchObject({
- historicalStatus: 'not-examined',
- historicalReason: undefined,
- });
- expect(
- analysis.results.find((result) => result.date === '2024-01-06')?.historicalStatus,
- ).toBe('ineligible');
- } finally {
- vi.unstubAllGlobals();
- vi.stubGlobal('window', browserWindow);
- }
- });
-
- it('reconstructs the returned plan from the original trace deltas', async () => {
- const browserWindow = globalThis.window;
- vi.stubGlobal('window', undefined);
- try {
- const calculation = createPlanningCalculation({
- horizon: { start: '2024-01-01', end: '2024-02-01' },
- balances: [balance('cp', 'cp', 4, '2024-01-31')],
- rules: presets,
- });
- 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 placements = new Map(
- trace.initialState.placements.map((placement) => [placement.date, placement]),
- );
- for (const delta of trace.phases.flatMap((phase) =>
- phase.decisions.flatMap((decision) => decision.deltas),
- )) {
- if (delta.previousPlacement) placements.delete(delta.previousPlacement.date);
- if (delta.placement) placements.set(delta.placement.date, delta.placement);
- }
- expect([...placements.values()].sort((a, b) => a.date.localeCompare(b.date))).toEqual(
- outcome.placements,
- );
- expect(trace.phases.map((phase) => phase.name)).toEqual(
- expect.arrayContaining([
- 'obligations',
- 'optimization:default-unplaced-at-risk',
- 'optimization:default-holiday-week-value',
- 'optimization:default-non-working-day-gain',
- 'optimization:default-longest-work-sequence',
- 'optimization:default-short-sequence-deficit',
- 'optimization:default-supporting-placements',
- 'expiry',
- 'dates',
- ]),
- );
- expect(
- trace.phases
- .flatMap((phase) => phase.decisions)
- .flatMap((decision) => decision.candidates).length,
- ).toBeGreaterThan(20);
- } finally {
- vi.stubGlobal('window', browserWindow);
- }
- });
-
- it('records loaded-plan pruning and full quality comparisons without truncation', async () => {
- const browserWindow = globalThis.window;
- vi.stubGlobal('window', undefined);
- try {
- const calculation = createPlanningCalculation({
- horizon: { start: '2024-01-01', end: '2024-04-01' },
- balances: [balance('cp', 'cp', 25, '2024-03-31')],
- rules: presets,
- });
- expect((await calculation.outcome).kind).toBe('Plan');
- const trace = await calculation.trace;
- const decisions = trace?.phases.flatMap((phase) => phase.decisions) ?? [];
- const candidates = decisions.flatMap((decision) => decision.candidates);
- expect(
- candidates.some(
- (candidate) =>
- candidate.status === 'not-examined' &&
- candidate.reason?.code === 'loaded-plan-sampling',
- ),
- ).toBe(true);
- const comparison = candidates.find((candidate) => candidate.comparison)?.comparison;
- expect(comparison?.before.objectives.length).toBeGreaterThan(1);
- expect(comparison?.after.objectives.length).toBe(comparison?.before.objectives.length);
- expect(
- candidates.some((candidate) => candidate.comparison && candidate.sourceDate),
- ).toBe(true);
- const chosenComparisons = decisions.flatMap((decision) =>
- decision.candidates
- .filter((candidate) => candidate.status === 'chosen' && candidate.comparison)
- .map((candidate) => ({ decision, candidate })),
- );
- expect(chosenComparisons.length).toBeGreaterThan(0);
- for (const { decision, candidate: chosen } of chosenComparisons) {
- const changedDates = chosen.dates ?? (chosen.date ? [chosen.date] : []);
- expect(
- decision.deltas.some(
- (delta) =>
- (delta.kind === 'placement-moved' &&
- changedDates.includes(delta.toDate ?? '')) ||
- (delta.kind === 'placement-added' &&
- changedDates.includes(delta.date ?? '')) ||
- (delta.kind === 'placement-removed' &&
- changedDates.includes(delta.date ?? '')) ||
- (delta.kind === 'placement-updated' &&
- changedDates.includes(delta.date ?? '')),
- ),
- ).toBe(true);
- }
- const phasesWithMultipleAcceptedMutations = trace?.phases.filter((phase) => {
- const acceptedMutations = phase.decisions.filter(
- (decision) =>
- decision.candidates.some(
- (candidate) => candidate.status === 'chosen' && candidate.comparison,
- ) &&
- decision.deltas.some(
- (delta) =>
- delta.kind === 'placement-moved' ||
- delta.kind === 'placement-added' ||
- delta.kind === 'placement-removed' ||
- delta.kind === 'placement-updated',
- ),
- );
- return acceptedMutations.length > 1;
- });
- expect(phasesWithMultipleAcceptedMutations?.length).toBeGreaterThan(0);
} finally {
vi.stubGlobal('window', browserWindow);
}
@@ -5067,30 +4017,12 @@ describe('planning calculation context', () => {
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan') return;
expect(outcome.placements[0].balanceId).toBe('earlier');
-
- const trace = await calculation.trace;
- const candidates =
- trace?.phases
- .find((phase) => phase.name === 'preference:window')
- ?.decisions.flatMap((decision) => decision.candidates)
- .filter((candidate) => candidate.date === '2024-01-02' && candidate.placement)
- .map((candidate) => ({
- balanceId: candidate.placement!.balanceId,
- status: candidate.status,
- reason: candidate.reason?.code,
- })) ?? [];
- expect(candidates).toEqual(
- expect.arrayContaining([
- { balanceId: 'earlier', status: 'chosen', reason: undefined },
- { balanceId: 'later', status: 'evaluated-rejected', reason: undefined },
- ]),
- );
} finally {
vi.stubGlobal('window', browserWindow);
}
});
- it('offers no explanation trace for invalid input and explains conflicts', async () => {
+ it('omits a plan explanation for invalid input and explains conflicts', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
@@ -5101,7 +4033,6 @@ describe('planning calculation context', () => {
});
expect((await invalidCalculation.outcome).kind).toBe('InvalidInput');
expect(await invalidCalculation.explanation).toBeUndefined();
- expect(await invalidCalculation.trace).toBeUndefined();
const conflictCalculation = createPlanningCalculation({
horizon,
@@ -5126,12 +4057,6 @@ describe('planning calculation context', () => {
expect(explanation.conflictingRules).toEqual([
{ id: 'cp-only', label: 'Paid leave only' },
]);
- const trace = await conflictCalculation.trace;
- expect(
- trace?.phases.some(
- (phase) => phase.name === 'optimization:default-holiday-week-value',
- ),
- ).toBe(false);
} finally {
vi.stubGlobal('window', browserWindow);
}
@@ -5151,9 +4076,6 @@ describe('planning calculation context', () => {
expect(outcome.kind).toBe('PlanningIncomplete');
const explanation = await calculation.explanation;
expect(explanation?.kind).toBe('IncompleteExplanation');
- const trace = await calculation.trace;
- expect(trace?.phases.some((phase) => phase.status === 'interrupted')).toBe(true);
- expect(trace?.phases.some((phase) => phase.status === 'not-run')).toBe(true);
} finally {
vi.stubGlobal('window', browserWindow);
}
@@ -5202,17 +4124,6 @@ describe('planning calculation context', () => {
const expected = isPlan(
await runInNonBrowser({ horizon, balances: [balance('cp', 'cp', 1)], rules: [] }),
);
- const trace = {
- status: 'ready' as const,
- initialState: {
- horizon,
- balances: [],
- placements: [],
- earnedActivations: [],
- },
- phases: [],
- completedStages: [],
- };
class DelayedArtifactsWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
@@ -5223,13 +4134,6 @@ describe('planning calculation context', () => {
data: { type: 'result', outcome: expected },
} as MessageEvent),
);
- setTimeout(
- () =>
- this.onmessage?.({
- data: { type: 'artifacts', artifacts: { trace } },
- } as MessageEvent),
- 20,
- );
}
terminate(): void {}
@@ -5241,386 +4145,13 @@ describe('planning calculation context', () => {
balances: [balance('cp', 'cp', 1)],
rules: [],
});
- let traceReady = false;
- void calculation.trace.then(() => (traceReady = true));
expect((await calculation.outcome).kind).toBe('Plan');
- expect(traceReady).toBe(false);
- expect((await calculation.trace)?.status).toBe('ready');
} finally {
vi.unstubAllGlobals();
}
});
- 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);
- vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance', 'Date'] });
- vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
- 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();
- await vi.advanceTimersByTimeAsync(10_001);
- await vi.runAllTimersAsync();
- 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.useRealTimers();
- vi.unstubAllGlobals();
- vi.stubGlobal('window', browserWindow);
- }
- });
-
- 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 () => {
+ it('keeps a valid outcome when explanation delivery fails afterward', async () => {
const expected = isPlan(
await runInNonBrowser({ horizon, balances: [balance('cp', 'cp', 1)], rules: [] }),
);
@@ -5647,7 +4178,6 @@ describe('planning calculation context', () => {
rules: [],
});
expect(await calculation.outcome).toEqual(expected);
- expect((await calculation.trace)?.status).toBe('failed');
expect((await calculation.explanationError)?.code).toBe('worker-error');
} finally {
vi.unstubAllGlobals();
diff --git a/src/app/services/leave-planner.service.ts b/src/app/services/leave-planner.service.ts
index ba65379..eb12651 100644
--- a/src/app/services/leave-planner.service.ts
+++ b/src/app/services/leave-planner.service.ts
@@ -1,12 +1,9 @@
import type {
CounterfactualAnalysis,
- CounterfactualBaselineOptions,
CounterfactualSelection,
InvalidInputOutcome,
LeaveBalanceInput,
LeavePlacement,
- LeaveType,
- DeclaredNonWorkingDay,
PlanOutcome,
PlanningArtifacts,
PlanningExplanation,
@@ -15,18 +12,9 @@ import type {
PlanningRequest,
PlanningSnapshot,
PlanningArtifactTimings,
- PlanningTrace,
- PlanningTraceCandidate,
- PlanningTraceDecision,
- PlanningTraceDelta,
- PlanningTracePhase,
- PlanningTraceUpdate,
PlanningReason,
- PlanningTraceState,
- HistoricalCounterfactualReason,
+ PlanningStateSnapshot,
StaleCounterfactualAnalysis,
- TraceCandidateStatus,
- TraceCounterfactualSelection,
} from './leave-planner.core';
import { planningDateInParis } from './planning-date';
@@ -65,14 +53,7 @@ export type {
PlanningRule,
PlanningSnapshot,
PlanningArtifactTimings,
- PlanningTrace,
- PlanningTraceCandidate,
- PlanningTraceDecision,
- PlanningTraceDelta,
- PlanningTracePhase,
- PlanningTraceState,
- PlanningTraceOperation,
- PlanningTraceUpdate,
+ PlanningStateSnapshot,
PreferenceResult,
RemainingBalance,
RemainingClassification,
@@ -81,14 +62,10 @@ export type {
TimeWindowContribution,
TimeWindowPlanningRule,
OptimizationPlanningRule,
- TraceCounterfactualSelection,
- TraceCandidateStatus,
} from './leave-planner.core';
// Responsive workers renew this inactivity timer regardless of calculation duration.
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;
@@ -304,44 +281,17 @@ function invalidDeadline(request: TransportSnapshot): InvalidInputOutcome | unde
type PlannerWorkerResponse =
| { type: 'heartbeat' }
| { type: 'progress'; stage: string }
- | { type: 'trace-overhead'; totalMs: number }
- | { type: 'checkpoint'; plan: PlanOutcome; state?: PlanningTraceState }
+ | { type: 'checkpoint'; plan: PlanOutcome; state?: PlanningStateSnapshot }
| { type: 'result'; outcome: PlanningOutcome }
| { type: 'artifacts'; artifacts: PlanningArtifacts }
| {
type: 'explanation';
explanation?: PlanningExplanation;
explanationError?: PlanningReason;
- finalState?: PlanningTraceState;
- }
- | {
- type: 'artifacts-start';
- artifacts: Omit<
- PlanningArtifacts,
- 'trace' | 'explanation' | 'explanationError' | 'finalState'
- >;
- trace?: Omit;
- }
- | {
- type: 'trace-update';
- totalMs: number;
- update: PlanningTraceUpdate;
- acknowledge?: boolean;
- }
- | {
- type: 'trace-status-index';
- statuses: ReadonlyMap;
- nonWorkingDates: readonly string[];
- }
- | { type: 'artifacts-complete'; traceTransferMs?: number }
- | {
- type: 'historical-eligibility-result';
- entries: readonly HistoricalEligibilityReason[];
+ finalState?: PlanningStateSnapshot;
}
| { type: 'error'; code?: string; message: string };
-export type HistoricalEligibilityReason = HistoricalCounterfactualReason;
-
interface PlanningRun {
outcome: Promise;
explanation: Promise;
@@ -351,61 +301,7 @@ interface PlanningRun {
cancel?: () => void;
}
-export type PlanningCalculationTimings = Partial & {
- traceTransferMs?: number;
-};
-
-type MutableTraceDecision = Omit & {
- candidates: PlanningTraceCandidate[];
- deltas: PlanningTraceDelta[];
-};
-type MutableTracePhase = Omit & {
- decisions: MutableTraceDecision[];
-};
-type MutableTrace = Omit & { phases: MutableTracePhase[] };
-
-function traceStateFor(snapshot: TransportSnapshot): PlanningTraceState {
- const horizon = snapshot.horizon ?? {};
- const balances = Array.isArray(snapshot.balances) ? snapshot.balances : [];
- return {
- calculationDate: snapshot.calculationDate,
- horizon: {
- start: String(horizon.start ?? ''),
- end: String(horizon.end ?? ''),
- },
- balances: balances.map((item) => {
- const balance = asRecord(item);
- return {
- id: String(balance?.id ?? ''),
- type: String(balance?.type ?? 'other') as LeaveType,
- quantity: typeof balance?.quantity === 'number' ? balance.quantity : 0,
- remaining: typeof balance?.quantity === 'number' ? balance.quantity : 0,
- availableFrom: String(balance?.availableFrom ?? horizon.start ?? ''),
- expiryDate: String(balance?.expiryDate ?? ''),
- earned: false,
- };
- }),
- ...(Array.isArray(snapshot.declaredDays)
- ? { declaredDays: snapshot.declaredDays as DeclaredNonWorkingDay[] }
- : {}),
- placements: [],
- earnedActivations: [],
- };
-}
-
-function fallbackTrace(
- snapshot: TransportSnapshot,
- status: PlanningTrace['status'],
- error?: PlanningReason,
-): PlanningTrace {
- return {
- status,
- initialState: traceStateFor(snapshot),
- phases: [],
- completedStages: [],
- error,
- };
-}
+export type PlanningCalculationTimings = Partial;
function interruptedOutcome(
checkpoint: PlanOutcome | undefined,
@@ -423,13 +319,11 @@ function interruptedOutcome(
const latestWorkerCancels = new Set<() => void>();
let latestCounterfactualCancel: (() => void) | undefined;
-let latestHistoricalEligibilityCancel: (() => void) | undefined;
function cancelLatestWorkers(): void {
for (const cancel of [...latestWorkerCancels]) cancel();
latestWorkerCancels.clear();
latestCounterfactualCancel = undefined;
- latestHistoricalEligibilityCancel = undefined;
}
function planInWorker(snapshot: TransportSnapshot): PlanningRun {
@@ -453,73 +347,44 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
const timings = new Promise((resolve) => {
resolveTimings = resolve;
});
- let supersede: (() => void) | undefined;
let cancel: (() => void) | undefined;
try {
const worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
type: 'module',
});
-
let outcomeSettled = false;
let cancellationRequested = false;
let cancelledRace: PlanningIncompleteOutcome | undefined;
let explanationSettled = false;
let artifactsSettled = false;
let checkpoint: PlanOutcome | undefined;
- let checkpointState: PlanningTraceState | undefined;
- let receivedArtifacts: Omit<
- PlanningArtifacts,
- 'trace' | 'explanation' | 'explanationError' | 'finalState'
- > = {};
+ let checkpointState: PlanningStateSnapshot | undefined;
let receivedExplanation: PlanningExplanation | undefined;
let receivedExplanationError: PlanningReason | undefined;
- let receivedFinalState: PlanningTraceState | undefined;
- let receivedTrace: MutableTrace | undefined;
- const phasesById = new Map();
- const decisionsById = new Map();
- let traceUpdates: (QueuedTraceUpdate | undefined)[] = [];
- let traceUpdateIndex = 0;
- let traceDrainTimer: ReturnType | undefined;
- let traceTransferMs = 0;
- let terminalTraceTransferMs: number | undefined;
- let artifactsCompleteReceived = false;
- let pendingFailure: { code: string; message: string } | undefined;
- let receivedStatuses: ReadonlyMap | undefined;
- let receivedNonWorkingDates: readonly string[] = [];
+ let receivedFinalState: PlanningStateSnapshot | undefined;
let livenessWatchdog: ReturnType;
- let workerStopped = false;
- const stopWorker = () => {
- if (workerStopped) return;
- workerStopped = true;
+ const terminate = () => {
worker.onmessage = null;
worker.onerror = null;
worker.terminate();
- };
- const terminate = () => {
- stopWorker();
clearTimeout(livenessWatchdog);
- if (traceDrainTimer !== undefined) clearTimeout(traceDrainTimer);
- traceDrainTimer = undefined;
- phasesById.clear();
- decisionsById.clear();
};
const finishOutcome = (result: PlanningOutcome) => {
- if (!outcomeSettled) {
- if (
- cancellationRequested &&
- (result.kind === 'Plan' || result.kind === 'PlanningConflict')
- ) {
- cancelledRace = interruptedOutcome(
- result.kind === 'Plan' ? result : checkpoint,
- 'user-cancelled',
- 'The calculation was cancelled by the user.',
- );
- result = cancelledRace;
- }
- outcomeSettled = true;
- clearTimeout(livenessWatchdog);
- resolveOutcome(result);
+ if (outcomeSettled) return;
+ if (
+ cancellationRequested &&
+ (result.kind === 'Plan' || result.kind === 'PlanningConflict')
+ ) {
+ cancelledRace = interruptedOutcome(
+ result.kind === 'Plan' ? result : checkpoint,
+ 'user-cancelled',
+ 'The calculation was cancelled by the user.',
+ );
+ result = cancelledRace;
}
+ outcomeSettled = true;
+ clearTimeout(livenessWatchdog);
+ resolveOutcome(result);
};
const cancelledExplanation = (
result: PlanningExplanation | undefined,
@@ -538,128 +403,40 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
const finishExplanation = (
result: PlanningExplanation | undefined,
error: PlanningReason | undefined,
- finalState?: PlanningTraceState,
+ finalState?: PlanningStateSnapshot,
) => {
- if (!explanationSettled) {
- if (!error) result = cancelledExplanation(result);
- explanationSettled = true;
- receivedExplanation = result;
- receivedExplanationError = error;
- receivedFinalState = finalState;
- resolveExplanation(result);
- resolveExplanationError(error);
- }
+ if (explanationSettled) return;
+ if (!error) result = cancelledExplanation(result);
+ explanationSettled = true;
+ receivedExplanation = result;
+ receivedExplanationError = error;
+ receivedFinalState = finalState;
+ resolveExplanation(result);
+ resolveExplanationError(error);
};
const finishArtifacts = (result: PlanningArtifacts, finishExplanationPromise = true) => {
- if (!artifactsSettled) {
- artifactsSettled = true;
- const complete = {
- ...result,
- explanation: result.explanation ?? receivedExplanation,
- explanationError: result.explanationError ?? receivedExplanationError,
- finalState: result.finalState ?? receivedFinalState,
- };
- if (cancelledRace) {
- if (!complete.explanationError)
- complete.explanation = cancelledExplanation(complete.explanation);
- if (complete.trace && complete.trace.status !== 'failed')
- complete.trace = {
- ...complete.trace,
- status: 'incomplete',
- error: cancelledRace.reasons[0],
- };
- }
- if (finishExplanationPromise)
- finishExplanation(
- complete.explanation,
- complete.explanationError,
- complete.finalState,
- );
- resolveArtifacts(complete);
- if (complete.trace && receivedStatuses) {
- historicalStatusCache.set(complete.trace, new Map(receivedStatuses));
- historicalNonWorkingCache.set(complete.trace, new Set(receivedNonWorkingDates));
- }
- resolveTimings({
- ...complete.timings,
- traceTransferMs:
- (terminalTraceTransferMs ?? complete.timings?.traceTransferMs ?? 0) +
- traceTransferMs,
- });
- terminate();
- if (supersede) latestWorkerCancels.delete(supersede);
- }
- };
- const partialArtifacts = (
- status: PlanningTrace['status'],
- error: PlanningReason,
- includeError = true,
- ) => {
- const trace = receivedTrace ?? fallbackTrace(snapshot, status, error);
- return {
- ...receivedArtifacts,
- trace: { ...trace, status, error },
- explanation: receivedExplanation,
- finalState: receivedFinalState,
- explanationError: includeError
- ? (receivedExplanationError ?? error)
- : receivedExplanationError,
+ if (artifactsSettled) return;
+ artifactsSettled = true;
+ const complete = {
+ ...result,
+ explanation: result.explanation ?? receivedExplanation,
+ explanationError: result.explanationError ?? receivedExplanationError,
+ finalState: result.finalState ?? receivedFinalState,
};
+ if (cancelledRace && !complete.explanationError)
+ complete.explanation = cancelledExplanation(complete.explanation);
+ if (finishExplanationPromise)
+ finishExplanation(
+ complete.explanation,
+ complete.explanationError,
+ complete.finalState,
+ );
+ resolveArtifacts(complete);
+ resolveTimings(complete.timings ?? {});
+ terminate();
+ latestWorkerCancels.delete(supersede);
};
- const applyTraceUpdate = (update: PlanningTraceUpdate): void => {
- if (!receivedTrace && update.initialState)
- receivedTrace = {
- status: 'incomplete',
- initialState: update.initialState,
- phases: [],
- completedStages: [],
- nonWorkingDates: update.nonWorkingDates,
- };
- if (!receivedTrace) throw new Error('Trace update arrived without its initial state.');
- if (update.nonWorkingDates) receivedTrace.nonWorkingDates = update.nonWorkingDates;
- const phases = receivedTrace.phases;
- for (const operation of update.operations) {
- if (operation.kind === 'phase') {
- const phase = phasesById.get(operation.phase.id);
- if (phase) Object.assign(phase, operation.phase);
- else {
- const created = { ...operation.phase, decisions: [] };
- phases.push(created);
- phasesById.set(created.id, created);
- }
- continue;
- }
- const phase = phasesById.get(operation.phaseId);
- if (!phase) throw new Error('Trace update referenced a missing phase.');
- const decisions = phase.decisions;
- if (operation.kind === 'decision') {
- const decision = decisionsById.get(operation.decision.id);
- if (decision) Object.assign(decision, operation.decision);
- else {
- const created = { ...operation.decision, candidates: [], deltas: [] };
- decisions.push(created);
- decisionsById.set(created.id, created);
- }
- continue;
- }
- const decision = decisionsById.get(operation.decisionId);
- if (!decision) throw new Error('Trace update referenced a missing decision.');
- if (operation.kind === 'candidate') {
- const candidates = decision.candidates;
- if (operation.mode === 'append' && operation.index === candidates.length)
- candidates.push(operation.candidate);
- else if (operation.mode === 'replace' && operation.index < candidates.length)
- candidates[operation.index] = operation.candidate;
- else throw new Error('Trace candidate update arrived out of order.');
- continue;
- }
- const deltas = decision.deltas;
- if (operation.index !== deltas.length)
- throw new Error('Trace delta update arrived out of order.');
- deltas.push(operation.delta);
- }
- };
- supersede = () => {
+ const supersede = () => {
if (artifactsSettled) return;
const hadOutcome = outcomeSettled;
const reason = {
@@ -673,22 +450,18 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
hadOutcome ? receivedExplanationError : reason,
receivedFinalState ?? checkpointState,
);
- traceUpdates = [];
- traceUpdateIndex = 0;
- finishArtifacts(
- hadOutcome
- ? partialArtifacts('failed', reason, false)
- : {
- trace: fallbackTrace(snapshot, 'failed', reason),
- explanationError: reason,
- },
- );
+ finishArtifacts({
+ explanation: receivedExplanation,
+ explanationError: hadOutcome ? receivedExplanationError : reason,
+ finalState: receivedFinalState ?? checkpointState,
+ });
};
- if (supersede) latestWorkerCancels.add(supersede);
- const finalizeFailure = (reason: { code: string; message: string }) => {
+ latestWorkerCancels.add(supersede);
+ const fail = (code: string, message: string) => {
+ const reason = { code, message };
+ if (!outcomeSettled) finishOutcome(interruptedOutcome(checkpoint, code, message));
if (!explanationSettled && checkpoint && checkpointState) {
- receivedFinalState ??= checkpointState;
- finishArtifacts(partialArtifacts('failed', reason), false);
+ finishArtifacts({ explanationError: reason, finalState: checkpointState }, false);
void explainCheckpointInWorker(
publicSnapshot(snapshot),
checkpoint,
@@ -712,21 +485,12 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
checkpointState,
),
);
- return;
- }
- finishExplanation(
- receivedExplanation,
- receivedExplanationError ?? reason,
- receivedFinalState ?? checkpointState,
- );
- finishArtifacts(partialArtifacts('failed', reason));
- };
- let scheduleTraceDrain: () => void = () => {};
- const fail = (code: string, message: string) => {
- if (!outcomeSettled) finishOutcome(interruptedOutcome(checkpoint, code, message));
- pendingFailure ??= { code, message };
- if (traceUpdateIndex < traceUpdates.length) scheduleTraceDrain();
- else finalizeFailure(pendingFailure);
+ } else
+ finishArtifacts({
+ explanation: receivedExplanation,
+ explanationError: receivedExplanationError ?? reason,
+ finalState: receivedFinalState ?? checkpointState,
+ });
};
const scheduleLivenessWatchdog = () => {
if (outcomeSettled || artifactsSettled) return;
@@ -740,78 +504,7 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
WORKER_LIVENESS_TIMEOUT_MS,
);
};
- const finishDeliveredTrace = () =>
- finishArtifacts({
- ...receivedArtifacts,
- trace: receivedTrace,
- explanation: receivedExplanation,
- explanationError: receivedExplanationError,
- finalState: receivedFinalState,
- });
- scheduleTraceDrain = () => {
- if (
- traceDrainTimer !== undefined ||
- artifactsSettled ||
- traceUpdateIndex >= traceUpdates.length
- )
- return;
- traceDrainTimer = setTimeout(() => {
- traceDrainTimer = undefined;
- const drainStarted = performance.now();
- let drained = 0;
- let acknowledged = 0;
- while (
- traceUpdateIndex < traceUpdates.length &&
- drained < MAX_TRACE_UPDATES_PER_DRAIN
- ) {
- if (
- drained > 0 &&
- performance.now() - drainStarted >= TRACE_DRAIN_TIME_BUDGET_MS
- )
- break;
- const updateIndex = traceUpdateIndex++;
- const queued = traceUpdates[updateIndex];
- traceUpdates[updateIndex] = undefined;
- const started = performance.now();
- let applied = false;
- try {
- if (queued) {
- applyTraceUpdate(queued.update);
- applied = true;
- }
- } catch (error) {
- traceUpdates = [];
- traceUpdateIndex = 0;
- pendingFailure = {
- code: 'trace-error',
- message: error instanceof Error ? error.message : String(error),
- };
- } finally {
- const duration = Math.max(0, performance.now() - started);
- traceTransferMs += duration;
- }
- if (applied && queued?.acknowledge) acknowledged++;
- drained++;
- }
- if (traceUpdateIndex >= traceUpdates.length) {
- traceUpdates = [];
- traceUpdateIndex = 0;
- }
- if (acknowledged && !workerStopped && !pendingFailure && !artifactsSettled) {
- try {
- worker.postMessage({ type: 'trace-ack', count: acknowledged });
- } catch {
- fail('worker-error', 'Planning trace acknowledgements could not be sent.');
- }
- }
- if (artifactsSettled) return;
- if (traceUpdateIndex < traceUpdates.length) scheduleTraceDrain();
- else if (pendingFailure) finalizeFailure(pendingFailure);
- else if (artifactsCompleteReceived) finishDeliveredTrace();
- }, 0);
- };
scheduleLivenessWatchdog();
-
cancel = () => {
if (outcomeSettled || cancellationRequested) return;
cancellationRequested = true;
@@ -833,10 +526,6 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
if (typeof snapshot.progress === 'function') snapshot.progress(message.stage);
return;
}
- if (message.type === 'trace-overhead') {
- scheduleLivenessWatchdog();
- return;
- }
if (message.type === 'checkpoint') {
scheduleLivenessWatchdog();
checkpoint = message.plan;
@@ -845,10 +534,7 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
}
if (message.type === 'result') {
finishOutcome(message.outcome);
- if (message.outcome.kind === 'InvalidInput') {
- finishExplanation(undefined, undefined);
- finishArtifacts({});
- }
+ if (message.outcome.kind === 'InvalidInput') finishArtifacts({});
return;
}
if (message.type === 'explanation') {
@@ -863,55 +549,13 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
finishArtifacts(message.artifacts);
return;
}
- if (message.type === 'trace-status-index') {
- const started = performance.now();
- receivedStatuses = message.statuses;
- receivedNonWorkingDates = message.nonWorkingDates;
- traceTransferMs += Math.max(0, performance.now() - started);
- return;
- }
- if (message.type === 'artifacts-start') {
- const started = performance.now();
- receivedArtifacts = message.artifacts;
- if (message.trace) {
- receivedTrace = {
- ...message.trace,
- initialState: receivedTrace?.initialState ?? message.trace.initialState,
- nonWorkingDates:
- receivedTrace?.nonWorkingDates ?? message.trace.nonWorkingDates,
- phases: receivedTrace?.phases ?? [],
- };
- }
- traceTransferMs += Math.max(0, performance.now() - started);
- return;
- }
- if (message.type === 'trace-update') {
- if (pendingFailure) return;
- traceUpdates.push({
- update: message.update,
- acknowledge: message.acknowledge === true,
- });
- scheduleTraceDrain();
- return;
- }
- if (message.type === 'artifacts-complete') {
- terminalTraceTransferMs = message.traceTransferMs;
- artifactsCompleteReceived = true;
- stopWorker();
- if (traceUpdateIndex < traceUpdates.length) scheduleTraceDrain();
- else finishDeliveredTrace();
- return;
- }
if (message.type === 'error') fail(message.code ?? 'worker-error', message.message);
else fail('worker-error', 'Planning worker returned an unexpected response.');
};
worker.onerror = () =>
fail('worker-error', 'Planning worker failed before returning explanation data.');
try {
- worker.postMessage({
- type: 'plan',
- request: { ...snapshot, progress: undefined },
- });
+ worker.postMessage({ type: 'plan', request: { ...snapshot, progress: undefined } });
} catch {
fail('worker-error', 'Planning input could not be sent to the isolated worker.');
}
@@ -922,10 +566,7 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
'Planning could not start because isolated execution is unavailable.',
);
resolveOutcome(result);
- resolveArtifacts({
- trace: fallbackTrace(snapshot, 'failed', result.reasons[0]),
- explanationError: result.reasons[0],
- });
+ resolveArtifacts({ explanationError: result.reasons[0] });
resolveExplanation(undefined);
resolveExplanationError(result.reasons[0]);
resolveTimings({});
@@ -940,7 +581,6 @@ function selectionFor(
plan: PlanOutcome,
selection: CounterfactualSelection,
): LeavePlacement | undefined {
- if (typeof selection === 'object' && 'decisionId' in selection) return selection.placement;
if (typeof selection !== 'string')
return plan.placements.find(
(candidate) =>
@@ -951,322 +591,6 @@ function selectionFor(
);
}
-function traceSelection(
- selection: CounterfactualSelection,
-): selection is TraceCounterfactualSelection {
- return typeof selection === 'object' && 'decisionId' in selection;
-}
-
-const traceStatusPriority = new Map([
- ['ineligible', 0],
- ['not-examined', 1],
- ['pruned', 2],
- ['evaluated-rejected', 3],
- ['chosen', 4],
-]);
-const historicalStatusCache = new WeakMap>();
-const historicalNonWorkingCache = new WeakMap>();
-const historicalDecisionCache = new WeakMap<
- PlanningTrace,
- { id: string; statuses: Map }
->();
-
-async function historicalStatuses(
- trace: PlanningTrace,
- decisionId?: string,
-): Promise