From 01c87d1e44eb54a9a44cfa5ea575254bb925a767 Mon Sep 17 00:00:00 2001 From: Nessar Date: Sat, 3 Oct 2026 16:26:56 +0200 Subject: [PATCH] feat(planning): complete verified explanations and trace delivery (#46) Implement plan explanations, original decision traces, calendar replay, and worker counterfactual analysis. Preserve incomplete checkpoints and conflict reasons, with accessible navigation and localized comparison details. Keep planning outcomes independent of tracing and bound worker delivery with receiver acknowledgements. Retain complete candidate history while releasing temporary buffers and benchmark references. Validated with 201 tests, app/spec type checks, lint, production build, baseline outcome comparisons, and repeated loaded browser measurements. Document performance and remaining limits. --- docs/benchmark/README.md | 80 +- .../benchmark/leave-planner-benchmark.spec.ts | 9 +- src/app/benchmark/leave-planner-benchmark.ts | 130 +- .../calendar-month/calendar-month.html | 2 +- .../calendar-month/calendar-month.ts | 27 +- .../calendar-year-planning.spec.ts | 1001 +++++-- .../calendar-year/calendar-year.html | 291 +- .../calendar-year/calendar-year.scss | 21 + .../components/calendar-year/calendar-year.ts | 512 +++- .../leave-planner-explanation.spec.ts | 1040 +++++++ .../leave-planner-quality-trace.spec.ts | 87 + src/app/services/leave-planner.core.ts | 2658 +++++++++++++++-- .../services/leave-planner.service.spec.ts | 1177 +++++++- src/app/services/leave-planner.service.ts | 1117 +++++-- src/app/services/leave-planner.worker.ts | 398 ++- src/app/services/translation-service.ts | 12 + src/i18n/de.json | 16 + src/i18n/en.json | 16 + src/i18n/es.json | 16 + src/i18n/fr.json | 16 + 20 files changed, 7625 insertions(+), 1001 deletions(-) create mode 100644 src/app/services/leave-planner-explanation.spec.ts create mode 100644 src/app/services/leave-planner-quality-trace.spec.ts diff --git a/docs/benchmark/README.md b/docs/benchmark/README.md index c0d02ea..cf85234 100644 --- a/docs/benchmark/README.md +++ b/docs/benchmark/README.md @@ -5,7 +5,9 @@ Open the deployed application at `/benchmark` on the reference phone. Tap report** and save the tab-separated output with the date and build identifier. The harness measures preparation, planning-to-outcome latency, later trace -delivery, and total wall time separately. It records the outcome, last +delivery, and total wall time separately. It also reports planning work, trace +capture, trace materialization, and trace transfer independently, plus the +largest extra delay of a 50 ms UI heartbeat. It records the outcome, last completed quality stage, placement count, determinism, trace status, and trace candidate count. `PlanningIncomplete` is not a conflict: its last completed stage and incumbent are retained. @@ -30,9 +32,18 @@ stage and incumbent are retained. The browser page uses the same `createPlanningCalculation(request)` context as the calendar and adds no solver or backend dependency. The ten-second release -ceiling applies to planning latency; trace delivery is reported separately and +ceiling applies to planning work; trace delivery is reported separately and cannot replace a valid planning outcome. +`planning_ms` is the observed latency until the outcome is usable; +`planning_work_ms` excludes trace capture. `trace_capture_ms` accounts for +instrumentation during planning, `trace_materialization_ms` accounts for +building the stable explanation and finishing the trace after the outcome. +`trace_transfer_ms` measures worker transfer and receiver assembly work; +`trace_delivery_ms` measures elapsed time until the complete trace is ready. `max_ui_delay_ms` includes +planning, trace delivery, and report preparation. Trace readiness has no +ten-second ceiling and never permits truncating actual candidate evaluations. + ## Hardware status No reference-phone run has been recorded. The project owner elected to close @@ -72,3 +83,68 @@ loaded year 1 0.3 5901.5 5677.7 11581.0 Plan dates 144 baseline ready 865117 loaded year 2 0.8 6167.5 7582.4 13752.4 Plan dates 144 yes ready 865117 loaded year 3 0.6 5738.3 6219.5 11962.5 Plan dates 144 yes ready 865117 ``` + +## Exact-trace verification — 2026-10-03 + +Working changes based on `0ce33cc9d6fe7381965a9162571abe205d0b7350`, +Chromium 149.0.7827.55 on the same AMD Ryzen 7 7800X3D desktop, +412×915 pixels, device scale 2.625, touch enabled, and 4× CPU throttling. +One warm-up preceded three recorded repetitions. This is desktop emulation; +no physical-phone result is claimed, and the earlier release exception is not +used as evidence for this implementation. + +All five scenarios matched their pre-change outcomes in a separate core +comparison, including traced versus untraced runs. Every repeated browser +outcome matched its first repetition. Each loaded run returned 144 placements +and retained all 868,919 actual trace candidate records with a ready trace. + +Loaded planning work took 5.81–6.18 seconds, below its ten-second budget. +Observed outcome latency was 10.36–10.85 seconds, including trace capture; +this latency is distinct from the planning-work budget. Complete trace delivery +followed 24.09–25.10 seconds later. No evaluation was truncated to meet a timing +target. The worst extra delay of the 50 ms heartbeat was 946.8 ms across the +loaded runs, including report preparation, compared with 4.65–5.49 seconds +before acknowledged delivery in the same final-trace harness. + +The worker stages snapshots during planning and delivers at most sixteen +64-operation packets before waiting for receiver acknowledgements. The +receiver applies bounded batches, releases consumed packets, and yields +between tasks. The worker stops after its terminal packet; benchmark helpers +keep completed trace references out of the next repetition. These changes +avoid the observed message-flood pauses and repeated-run renderer crashes. +The complete trace still requires substantial memory: a separate unthrottled +core profile retained approximately 1.06 GB of heap for the loaded trace. +These results do not establish physical-device performance. + +A separate rendered-calendar run at the same emulated mobile size used twelve +balances of twelve days each. It produced 144 placements, 113 gained +non-working days, and a longest work sequence of five. Full trace navigation +and date comparison completed without browser errors. Maximum extra heartbeat +delays were 487.4 ms during planning and calendar rendering, 380.8 ms during +trace exploration, and 540.3 ms during comparison. Keyboard navigation, +dialog focus restoration, background comparison without an implicit modal, +and responsive widths of 375, 768, and 1440 pixels also passed. + +The final suite passes 201 tests across 23 files, application and specification +type checks, ESLint, and the production build. Build warnings remain for the +2.55 MB initial bundle (2.50 MB warning budget) and 13.41 kB calendar stylesheet +(8.00 kB warning budget); hard budgets are not exceeded. + +```tsv +scenario repetition preparation_ms planning_ms trace_delivery_ms wall_ms outcome last_stage placements deterministic trace_status trace_candidates max_ui_delay_ms planning_work_ms trace_capture_ms trace_materialization_ms trace_transfer_ms +representative year 1 0.0 622.4 716.9 1340.3 Plan dates 30 baseline ready 29296 18.2 265.0 256.4 7.4 166.4 +representative year 2 0.0 593.5 695.8 1289.4 Plan dates 30 yes ready 29296 14.5 255.3 233.8 7.5 184.1 +representative year 3 0.0 592.0 721.0 1313.2 Plan dates 30 yes ready 29296 11.5 256.1 234.4 9.2 191.8 +typed obligations 1 0.1 259.7 324.3 584.6 Plan dates 10 baseline ready 13005 11.8 106.8 56.6 3.7 70.2 +typed obligations 2 0.1 266.2 330.6 596.9 Plan dates 10 yes ready 13005 11.7 110.6 60.3 4.1 81.9 +typed obligations 3 0.1 259.6 341.8 601.5 Plan dates 10 yes ready 13005 15.7 112.3 54.7 4.0 75.9 +chained earned entitlements 1 0.1 94.5 3.0 97.6 Plan dates 3 baseline ready 15 0.8 3.4 1.3 0.9 0.3 +chained earned entitlements 2 0.0 100.2 2.5 102.7 Plan dates 3 yes ready 15 0.2 3.6 1.4 0.7 0.1 +chained earned entitlements 3 0.0 96.0 1.5 97.5 Plan dates 3 yes ready 15 0.1 3.2 1.5 0.7 0.1 +deliberate conflict 1 0.0 94.4 2.4 96.8 PlanningConflict 0 baseline ready 4 0.2 0.9 0.8 0.3 0.2 +deliberate conflict 2 0.0 93.2 1.0 95.0 PlanningConflict 0 yes ready 4 0.2 1.1 0.5 0.2 0.0 +deliberate conflict 3 0.1 92.8 1.6 95.2 PlanningConflict 0 yes ready 4 1.4 1.0 0.7 0.2 0.1 +loaded year 1 0.0 10852.1 25102.6 36002.9 Plan dates 144 baseline ready 868919 946.8 6014.8 4730.6 948.3 8862.1 +loaded year 2 0.1 10364.4 24816.9 35216.6 Plan dates 144 yes ready 868919 791.2 5813.1 4440.6 296.7 8693.3 +loaded year 3 0.1 10807.8 24089.4 34933.6 Plan dates 144 yes ready 868919 721.7 6176.0 4526.6 978.7 8408.0 +``` diff --git a/src/app/benchmark/leave-planner-benchmark.spec.ts b/src/app/benchmark/leave-planner-benchmark.spec.ts index 3f8bcf8..2bbaf7b 100644 --- a/src/app/benchmark/leave-planner-benchmark.spec.ts +++ b/src/app/benchmark/leave-planner-benchmark.spec.ts @@ -7,6 +7,7 @@ import { } from './leave-planner-benchmark'; describe('leave planner benchmark protocol', () => { + // Warm-up and two repetitions include complete trace delivery after planning. it('keeps five deterministic scenarios and exercises a later earned consumer', async () => { const scenarios = benchmarkScenarios(); expect(scenarios).toHaveLength(5); @@ -62,6 +63,11 @@ describe('leave planner benchmark protocol', () => { expect(loaded[1].deterministic).toBe('yes'); expect(loaded[0].planningMs).toBeGreaterThanOrEqual(0); expect(loaded[0].traceDeliveryMs).toBeGreaterThanOrEqual(0); + expect(loaded[0].planningWorkMs).toBeGreaterThanOrEqual(0); + expect(loaded[0].planningWorkMs).toBeLessThanOrEqual(loaded[0].planningMs); + expect(loaded[0].traceCaptureMs).toBeGreaterThan(0); + expect(loaded[0].traceMaterializationMs).toBeGreaterThanOrEqual(0); + expect(loaded[0].maxUiDelayMs).toBeGreaterThanOrEqual(0); expect(loaded[0].wallMs).toBeGreaterThanOrEqual( loaded[0].preparationMs + loaded[0].planningMs + loaded[0].traceDeliveryMs, ); @@ -70,9 +76,10 @@ describe('leave planner benchmark protocol', () => { const report = benchmarkReport(measurements); expect(report.split('\n')).toHaveLength(11); expect(report).toContain('planning_ms\ttrace_delivery_ms'); + expect(report).toContain('planning_work_ms\ttrace_capture_ms'); expect(report).toContain('\tbaseline\t'); } finally { vi.stubGlobal('window', browserWindow); } - }, 30_000); + }, 90_000); }); diff --git a/src/app/benchmark/leave-planner-benchmark.ts b/src/app/benchmark/leave-planner-benchmark.ts index b1c60ab..5184cb9 100644 --- a/src/app/benchmark/leave-planner-benchmark.ts +++ b/src/app/benchmark/leave-planner-benchmark.ts @@ -16,6 +16,11 @@ export interface BenchmarkMeasurement { preparationMs: number; planningMs: number; traceDeliveryMs: number; + maxUiDelayMs: number; + planningWorkMs?: number; + traceCaptureMs?: number; + traceMaterializationMs?: number; + traceTransferMs?: number; wallMs: number; outcome: PlanningOutcome['kind']; lastCompletedStage?: string; @@ -167,63 +172,93 @@ function resultSummary(outcome: PlanningOutcome): { export async function runLeavePlannerBenchmark(repetitions = 1): Promise { const scenarios = benchmarkScenarios(); - for (const scenario of scenarios) { - const calculation = createPlanningCalculation(structuredClone(scenario.request)); - await calculation.outcome; - await calculation.trace; - } + for (const scenario of scenarios) await warmUpScenario(scenario); const measurements: BenchmarkMeasurement[] = []; for (const scenario of scenarios) { let baseline: string | undefined; for (let repetition = 1; repetition <= repetitions; repetition++) { - const wallStart = performance.now(); - const preparationStart = performance.now(); - const request = structuredClone(scenario.request); - const preparationMs = performance.now() - preparationStart; - const calculationStart = performance.now(); - const calculation = createPlanningCalculation(request); - const outcome = await calculation.outcome; - const planningMs = performance.now() - calculationStart; - const traceStart = performance.now(); - const trace = await calculation.trace; - const traceDeliveryMs = performance.now() - traceStart; - const summary = resultSummary(outcome); - const stable = JSON.stringify(outcome); - const deterministic = - baseline === undefined ? 'baseline' : stable === baseline ? 'yes' : 'no'; - const traceCandidates = - trace?.phases.reduce( - (total, phase) => - total + - phase.decisions.reduce( - (decisionTotal, decision) => decisionTotal + decision.candidates.length, - 0, - ), - 0, - ) ?? 0; - const wallMs = performance.now() - wallStart; - measurements.push({ - scenario: scenario.name, - repetition, - preparationMs, - planningMs, - traceDeliveryMs, - wallMs, - ...summary, - deterministic, - traceStatus: trace?.status ?? 'not-produced', - traceCandidates, - }); - baseline ??= stable; + const result = await measureRepetition(scenario, repetition, baseline); + measurements.push(result.measurement); + baseline ??= result.stable; } } return measurements; } +async function warmUpScenario(scenario: BenchmarkScenario): Promise { + const calculation = createPlanningCalculation(structuredClone(scenario.request)); + await calculation.outcome; + await calculation.trace; +} + +async function measureRepetition( + scenario: BenchmarkScenario, + repetition: number, + baseline: string | undefined, +): Promise<{ measurement: BenchmarkMeasurement; stable: string }> { + const wallStart = performance.now(); + let lastTick = wallStart; + let maxUiDelayMs = 0; + const measureDelay = () => { + const current = performance.now(); + maxUiDelayMs = Math.max(maxUiDelayMs, current - lastTick - 50); + lastTick = current; + }; + const heartbeat = setInterval(measureDelay, 50); + try { + const preparationStart = performance.now(); + const request = structuredClone(scenario.request); + const preparationMs = performance.now() - preparationStart; + const calculationStart = performance.now(); + const calculation = createPlanningCalculation(request); + const outcome = await calculation.outcome; + const planningMs = performance.now() - calculationStart; + const traceStart = performance.now(); + const trace = await calculation.trace; + const traceDeliveryMs = performance.now() - traceStart; + const timings = await calculation.timings; + const summary = resultSummary(outcome); + const stable = JSON.stringify(outcome); + const deterministic = + baseline === undefined ? 'baseline' : stable === baseline ? 'yes' : 'no'; + const traceCandidates = + trace?.phases.reduce( + (total, phase) => + total + + phase.decisions.reduce( + (decisionTotal, decision) => decisionTotal + decision.candidates.length, + 0, + ), + 0, + ) ?? 0; + const wallMs = performance.now() - wallStart; + measureDelay(); + return { + measurement: { + scenario: scenario.name, + repetition, + preparationMs, + planningMs, + traceDeliveryMs, + maxUiDelayMs, + ...timings, + wallMs, + ...summary, + deterministic, + traceStatus: trace?.status ?? 'not-produced', + traceCandidates, + }, + stable, + }; + } finally { + clearInterval(heartbeat); + } +} + export function benchmarkReport(measurements: BenchmarkMeasurement[]): string { const header = - 'scenario\trepetition\tpreparation_ms\tplanning_ms\ttrace_delivery_ms\twall_ms\toutcome\tlast_stage\tplacements\tdeterministic\ttrace_status\ttrace_candidates'; + 'scenario\trepetition\tpreparation_ms\tplanning_ms\ttrace_delivery_ms\twall_ms\toutcome\tlast_stage\tplacements\tdeterministic\ttrace_status\ttrace_candidates\tmax_ui_delay_ms\tplanning_work_ms\ttrace_capture_ms\ttrace_materialization_ms\ttrace_transfer_ms'; return [ header, ...measurements.map((item) => @@ -240,6 +275,11 @@ export function benchmarkReport(measurements: BenchmarkMeasurement[]): string { item.deterministic, item.traceStatus, item.traceCandidates, + item.maxUiDelayMs.toFixed(1), + item.planningWorkMs?.toFixed(1) ?? '', + item.traceCaptureMs?.toFixed(1) ?? '', + item.traceMaterializationMs?.toFixed(1) ?? '', + item.traceTransferMs?.toFixed(1) ?? '', ].join('\t'), ), ].join('\n'); diff --git a/src/app/components/calendar-month/calendar-month.html b/src/app/components/calendar-month/calendar-month.html index ca4d1c7..e56ea26 100644 --- a/src/app/components/calendar-month/calendar-month.html +++ b/src/app/components/calendar-month/calendar-month.html @@ -56,7 +56,7 @@ [disabled]="!isInHorizon(day)" [matTooltip]="dayTooltip" [matTooltipShowDelay]="500" - [attr.aria-label]="getDayAriaLabel(day)" + [attr.aria-label]="getDayAriaLabel(day, dayTooltip)" [attr.aria-current]="dateKey === currentDate ? 'date' : null" (click)="dateSelected.emit(dateKey)" > diff --git a/src/app/components/calendar-month/calendar-month.ts b/src/app/components/calendar-month/calendar-month.ts index 79c1d21..4a598e4 100644 --- a/src/app/components/calendar-month/calendar-month.ts +++ b/src/app/components/calendar-month/calendar-month.ts @@ -1,4 +1,12 @@ -import { Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnInit, + Output, + inject, +} from '@angular/core'; import { CalendarService, Month, SelectedDates } from '../../services/calendar-service'; import { MatTooltip } from '@angular/material/tooltip'; import { CalendarSettingsService } from '../../services/calendar-settings-service'; @@ -20,6 +28,7 @@ export interface CalendarDayStatus { imports: [MatTooltip], templateUrl: './calendar-month.html', styleUrl: './calendar-month.scss', + changeDetection: ChangeDetectionStrategy.OnPush, }) export class CalendarMonth implements OnInit { protected calendarSettingsService = inject(CalendarSettingsService); @@ -44,6 +53,8 @@ export class CalendarMonth implements OnInit { currentMonth: Date = new Date(); weeksInAMonth: Month = []; + private dayFormatterLocale = ''; + private dayFormatter?: Intl.DateTimeFormat; @Input({ required: true }) selectedDates!: SelectedDates; @@ -121,16 +132,18 @@ export class CalendarMonth implements OnInit { : '~×'; } - getDayAriaLabel(day: Date): string { - const parts = [ - day.toLocaleDateString(this.translationService.locale(), { + getDayAriaLabel(day: Date, tooltip: string | null = this.getDayTooltip(day)): string { + const locale = this.translationService.locale(); + if (!this.dayFormatter || locale !== this.dayFormatterLocale) { + this.dayFormatterLocale = locale; + this.dayFormatter = new Intl.DateTimeFormat(locale, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', - }), - ]; - const tooltip = this.getDayTooltip(day); + }); + } + const parts = [this.dayFormatter.format(day)]; if (tooltip) parts.push(tooltip); if (!this.isInHorizon(day)) parts.push(this.translationService.tr().planning.outsideHorizon); diff --git a/src/app/components/calendar-year/calendar-year-planning.spec.ts b/src/app/components/calendar-year/calendar-year-planning.spec.ts index b19b2e1..96ddf99 100644 --- a/src/app/components/calendar-year/calendar-year-planning.spec.ts +++ b/src/app/components/calendar-year/calendar-year-planning.spec.ts @@ -11,6 +11,9 @@ import { } from '../../services/calendar-service'; import { CounterfactualAnalysis, + CounterfactualSelection, + HistoricalEligibilityReason, + LeavePlacement, PlanOutcome, PlanningCalculationContext, PlanningConflictOutcome, @@ -127,10 +130,21 @@ function selectedDatesForPlan(value: PlanOutcome, includeHoliday = false): Selec return selected; } +function placementForSelection(selection: CounterfactualSelection): LeavePlacement { + if (typeof selection === 'string') + return ( + plan.placements.find( + (candidate) => candidate.date === selection || candidate.balanceId === selection, + ) ?? plan.placements[0] + ); + return 'decisionId' in selection ? selection.placement : selection; +} + function calculationFor( outcome: PlanningOutcome, stableExplanation?: PlanningExplanation, trace?: PlanningTrace, + tracePromise: Promise = Promise.resolve(trace), ): PlanningCalculationContext { const counterfactual: CounterfactualAnalysis = { kind: 'Counterfactual', @@ -178,8 +192,12 @@ function calculationFor( outcome: Promise.resolve(outcome), explanation: Promise.resolve(stableExplanation), explanationError: Promise.resolve(undefined), - trace: Promise.resolve(trace), - analyzeCounterfactual: vi.fn(async () => counterfactual), + trace: tracePromise, + analyzeCounterfactual: vi.fn(async (selection: CounterfactualSelection) => ({ + ...counterfactual, + placement: placementForSelection(selection), + })), + historicalEligibility: vi.fn(async () => [] as readonly HistoricalEligibilityReason[]), }; } @@ -187,8 +205,14 @@ function calendarFor( outcome: PlanningOutcome, stableExplanation?: PlanningExplanation, trace?: PlanningTrace, + tracePromise?: Promise, ) { - const calculation = calculationFor(outcome, stableExplanation, trace); + const calculation = calculationFor( + outcome, + stableExplanation, + trace, + tracePromise ?? Promise.resolve(trace), + ); return { calculation, calendar: { @@ -200,6 +224,178 @@ function calendarFor( }; } +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function activateWithKeyboard(element: HTMLElement, key: 'Enter' | ' ' = 'Enter'): void { + element.focus(); + element.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); + // jsdom does not implement native keyboard activation for buttons or summaries. + element.click(); +} + +function jointTrace(): PlanningTrace { + const placementComparison = + explanation.kind === 'PlanExplanation' ? explanation.placements[0].comparison : undefined; + return { + status: 'ready', + initialState: { + horizon: plan.horizon, + balances: [ + { + id: 'cp', + type: 'cp', + quantity: 2, + remaining: 2, + availableFrom: '2024-01-01', + expiryDate: '2024-12-31', + earned: false, + }, + { + id: 'earned:spring:2024', + type: 'rtt', + quantity: 2, + remaining: 2, + availableFrom: '2024-05-04', + expiryDate: '2025-05-04', + earned: true, + sourceRuleId: 'spring', + }, + ], + placements: [plan.placements[1]], + earnedActivations: [ + { + id: 'earned:spring:2024', + ruleId: 'spring', + occurrence: '2024', + dates: ['2024-05-03', '2024-05-06'], + }, + ], + }, + phases: [ + { + id: 'phase:obligations', + name: 'obligations', + completed: true, + status: 'completed', + decisions: [ + { + id: 'decision:prior', + action: 'validate', + dates: ['2024-05-01'], + candidates: [], + deltas: [ + { + kind: 'balance-changed', + balanceId: 'cp', + remainingBefore: 2, + remainingAfter: 1, + }, + ], + }, + { + id: 'decision:joint', + action: 'choose', + dates: ['2024-05-03', '2024-05-06'], + branch: { + parentDecisionId: 'decision:prior', + deltas: [ + { + kind: 'placement-added', + date: '2024-05-03', + balanceId: 'cp', + remainingBefore: 1, + remainingAfter: 0, + placement: plan.placements[0], + }, + { + kind: 'balance-changed', + balanceId: 'earned:spring:2024', + remainingBefore: 2, + remainingAfter: 2, + balance: { + id: 'earned:spring:2024', + type: 'rtt', + quantity: 2, + remaining: 2, + availableFrom: '2024-05-07', + expiryDate: '2025-05-07', + earned: true, + sourceRuleId: 'spring', + }, + }, + ], + }, + candidates: [ + { + status: 'evaluated-rejected', + dates: ['2024-05-10', '2024-05-13'], + reason: { + code: 'joint-date-conflict', + message: 'This branch cannot satisfy both required dates.', + }, + technicalComparison: [ + { + name: 'unplaced-at-risk', + direction: 'minimize', + before: 2, + after: 2, + }, + { + name: 'preference:spring', + direction: 'maximize', + before: 1, + after: 0, + }, + ], + firstDecisiveComparator: 'preference:spring', + comparison: placementComparison, + }, + { + status: 'chosen', + dates: ['2024-05-03', '2024-05-06'], + comparison: placementComparison, + }, + ], + deltas: [ + { + kind: 'placement-added', + date: '2024-05-03', + balanceId: 'cp', + remainingBefore: 1, + remainingAfter: 0, + placement: plan.placements[0], + }, + ], + }, + { + id: 'decision:later', + action: 'reject', + dates: ['2024-05-20'], + candidates: [], + deltas: [], + }, + ], + }, + { + id: 'phase:dates', + name: 'dates', + completed: true, + status: 'completed', + decisions: [], + }, + ], + completedStages: ['obligations', 'dates'], + }; +} + describe('CalendarYear planning outcome boundary', () => { it.each([ ['InvalidInput', invalid], @@ -253,6 +449,218 @@ describe('CalendarYear planning outcome boundary', () => { expect(summaryValues).toEqual(['2', '4', '6']); }); + it('shows muted, disabled context dates in an adjacent year', async () => { + if (explanation.kind !== 'PlanExplanation') throw new Error('Expected plan explanation'); + const boundaryPlan: PlanOutcome = { + ...plan, + horizon: { start: '2024-01-01', end: '2024-01-08' }, + placements: [{ ...plan.placements[0], date: '2024-01-02' }], + timeOffPeriods: [ + { + start: '2023-12-30', + end: '2024-01-02', + duration: 4, + placementDates: ['2024-01-02'], + nonWorkingDates: ['2023-12-30', '2023-12-31', '2024-01-01'], + nonWorkingDayGain: 3, + }, + ], + }; + const boundaryExplanation: PlanningExplanation = { + ...explanation, + placements: [ + { + ...explanation.placements[0], + placement: boundaryPlan.placements[0], + timeOffPeriod: boundaryPlan.timeOffPeriods[0], + }, + ], + }; + const { calendar, calculation } = calendarFor(boundaryPlan, boundaryExplanation); + await TestBed.configureTestingModule({ + imports: [CalendarYear], + providers: [ + provideZonelessChangeDetection(), + { provide: CalendarService, useValue: calendar }, + ], + }).compileComponents(); + TestBed.inject(TranslationService).setLanguage('en'); + const fixture = TestBed.createComponent(CalendarYear); + fixture.detectChanges(); + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve, 0)); + fixture.detectChanges(); + + const root = fixture.nativeElement as HTMLElement; + expect(root.querySelector('[aria-label="December 2023"]')).not.toBeNull(); + const contextDate = root.querySelector( + 'button[data-date="2023-12-30"]', + )!; + expect(contextDate.classList).toContain('context-only'); + expect(contextDate.disabled).toBe(true); + expect(contextDate.getAttribute('aria-label')).toContain( + 'Context date outside the planning horizon', + ); + expect( + root.querySelector('button[data-date="2024-01-02"]')?.disabled, + ).toBe(false); + contextDate.click(); + expect(calculation.analyzeCounterfactual).not.toHaveBeenCalled(); + }); + + it('keeps the final plan visible and navigates its accessible explanation without losing focus', async () => { + const first = explanation.placements[0]; + const explained: PlanningExplanation = { + ...explanation, + placements: [ + { + ...first, + placement: { ...first.placement, ruleIds: ['spring'] }, + ruleIds: ['spring'], + supporting: true, + timeOffPeriod: { + start: '2024-05-03', + end: '2024-05-06', + duration: 4, + placementDates: ['2024-05-03', '2024-05-06'], + nonWorkingDates: ['2024-05-04', '2024-05-05'], + nonWorkingDayGain: 2, + }, + remainingBalance: { + id: 'cp', + type: 'cp', + quantity: 1, + availableFrom: '2024-01-01', + expiryDate: '2024-12-31', + classification: 'at-risk', + reason: { code: 'at-risk', message: 'Expires within the horizon.' }, + earned: false, + }, + contributions: [ + { kind: 'entitlement', code: 'balance', label: 'Paid leave' }, + { kind: 'rule', code: 'rule', ruleId: 'spring', label: 'Spring rule' }, + { + kind: 'at-risk', + code: 'at-risk', + label: 'Protects leave at risk of expiry.', + }, + { + kind: 'supporting', + code: 'supporting', + label: 'Supports the surrounding time-off plan.', + }, + { + kind: 'time-off-period', + code: 'time-off-period', + label: 'Contributes to the time-off period.', + }, + { + kind: 'metric', + code: 'non-working-day-gain', + label: 'Weekend and holiday days', + value: 1, + }, + ], + comparison: { + ...first.comparison, + firstDecisiveObjective: 'non-working-day-gain', + }, + }, + ...explanation.placements.slice(1), + ], + }; + const { calendar, calculation } = calendarFor(plan, explained); + calculation.snapshot.rules = [ + { + id: 'spring', + name: 'Spring rule', + type: 'time_window', + force: 'preference', + startDate: '2024-05-01', + endDate: '2024-05-06', + placeDays: 2, + placeType: 'cp', + }, + ]; + calendar.selectedDatesForPlan.mockImplementation((value) => + selectedDatesForPlan(value, true), + ); + await TestBed.configureTestingModule({ + imports: [CalendarYear], + providers: [ + provideZonelessChangeDetection(), + { provide: CalendarService, useValue: calendar }, + ], + }).compileComponents(); + const fixture = TestBed.createComponent(CalendarYear); + fixture.detectChanges(); + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve, 0)); + fixture.detectChanges(); + + const root = fixture.nativeElement as HTMLElement; + const planDetails = root.querySelector('.plan-details')!; + const traceDetails = root.querySelector('.trace-explorer')!; + expect(planDetails.open).toBe(true); + expect(traceDetails.open).toBe(false); + expect(root.querySelector('.plan-insights')!.firstElementChild).toBe(planDetails); + expect(root.querySelector('.leave-story')?.textContent).toContain('Spring rule'); + expect(root.querySelector('.leave-story')?.textContent).toContain('Protects leave at risk'); + expect(root.querySelector('.leave-story')?.textContent).toContain('could be saved'); + expect(root.querySelector('.break-equation span:last-child strong')?.textContent).toBe('4'); + expect(root.querySelector('.break-story')?.textContent).toContain('days off in a row'); + expect(root.querySelector('.decisive-summary')?.textContent).toContain( + 'Weekend & holiday days in your breaks', + ); + expect(root.querySelectorAll('button[data-date="2024-05-03"].selected')).toHaveLength(1); + expect(root.querySelectorAll('button[data-date="2024-05-06"].selected')).toHaveLength(1); + expect(root.querySelector('.technical-details')?.textContent).toContain( + 'The planner’s priorities', + ); + const technicalSummary = root.querySelector('.technical-details summary')!; + expect(technicalSummary.parentElement?.matches('details[open]')).toBe(false); + activateWithKeyboard(technicalSummary, ' '); + fixture.detectChanges(); + expect(technicalSummary.parentElement?.matches('details[open]')).toBe(true); + + const next = root.querySelector('button[data-step="next"]')!; + activateWithKeyboard(next); + fixture.detectChanges(); + expect(next.getAttribute('aria-disabled')).toBe('true'); + expect(next.disabled).toBe(false); + expect(document.activeElement).toBe(next); + expect( + root.querySelector('output[aria-current="step"]')?.getAttribute('aria-label'), + ).toContain('6 May 2024'); + await fixture.whenStable(); + fixture.detectChanges(); + expect(document.activeElement).toBe(next); + expect(calculation.analyzeCounterfactual).toHaveBeenCalledWith(plan.placements[1]); + expect(root.querySelector('[role="dialog"]')).toBeNull(); + + const firstButton = root.querySelector('button[data-step="first"]')!; + activateWithKeyboard(firstButton); + fixture.detectChanges(); + expect(document.activeElement).toBe(firstButton); + const previous = root.querySelector('button[data-step="previous"]')!; + activateWithKeyboard(previous); + fixture.detectChanges(); + expect(previous.getAttribute('aria-disabled')).toBe('true'); + expect(previous.disabled).toBe(false); + expect(document.activeElement).toBe(previous); + const lastButton = root.querySelector('button[data-step="last"]')!; + activateWithKeyboard(lastButton); + fixture.detectChanges(); + expect(lastButton.getAttribute('aria-disabled')).toBe('true'); + expect(document.activeElement).toBe(lastButton); + const date = root.querySelector('button[data-date="2024-05-06"]')!; + activateWithKeyboard(date, ' '); + fixture.detectChanges(); + expect(document.activeElement).toBe(date); + expect(date.getAttribute('aria-current')).toBe('date'); + expect(date.getAttribute('aria-label')).toContain('Paid Leave'); + }); + it('keeps an incomplete incumbent visible and exposes its interrupted-stage details', async () => { const outcome: PlanningIncompleteOutcome = { kind: 'PlanningIncomplete', @@ -294,7 +702,7 @@ describe('CalendarYear planning outcome boundary', () => { expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(2); }); - it('keeps the calendar clear until a placement is selected and alternatives are requested', async () => { + it('shows the initial placement and starts alternatives on selection without opening the dialog', async () => { const { calendar, calculation } = calendarFor(plan, explanation); calendar.selectedDatesForPlan.mockImplementation((value) => selectedDatesForPlan(value, true), @@ -317,9 +725,10 @@ describe('CalendarYear planning outcome boundary', () => { }, ], }; - vi.spyOn(calculation, 'analyzeCounterfactual').mockResolvedValue( - counterfactualWithoutHistoricalStatus, - ); + vi.spyOn(calculation, 'analyzeCounterfactual').mockImplementation(async (selection) => ({ + ...counterfactualWithoutHistoricalStatus, + placement: placementForSelection(selection), + })); await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [ @@ -334,16 +743,19 @@ describe('CalendarYear planning outcome boundary', () => { fixture.detectChanges(); const details = fixture.nativeElement.querySelector('.plan-details') as HTMLDetailsElement; - expect(details.open).toBe(false); + expect(details.open).toBe(true); expect(calculation.analyzeCounterfactual).not.toHaveBeenCalled(); - expect(fixture.nativeElement.querySelector('button.current')).toBeNull(); + expect(fixture.nativeElement.querySelector('button.current')?.dataset.date).toBe( + '2024-05-03', + ); const placement = fixture.nativeElement.querySelector( 'button[data-date="2024-05-03"]', ) as HTMLButtonElement; placement.click(); fixture.detectChanges(); expect(details.open).toBe(true); - expect(calculation.analyzeCounterfactual).not.toHaveBeenCalled(); + expect(calculation.analyzeCounterfactual).toHaveBeenCalledWith(plan.placements[0]); + expect(fixture.nativeElement.querySelector('[role="dialog"]')).toBeNull(); expect(fixture.nativeElement.querySelector('.technical-details').open).toBe(false); const compare = fixture.nativeElement.querySelector( '.compare-dates-button', @@ -390,12 +802,17 @@ describe('CalendarYear planning outcome boundary', () => { expect(fixture.nativeElement.querySelector('button.current')?.dataset.date).toBe( '2024-05-06', ); - - expect(fixture.componentInstance['counterfactual']()).toBeNull(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('button.current')?.dataset.date).toBe( + '2024-05-06', + ); details.open = false; details.dispatchEvent(new Event('toggle')); fixture.detectChanges(); - expect(fixture.nativeElement.querySelector('button.current')).toBeNull(); + expect(fixture.nativeElement.querySelector('button.current')?.dataset.date).toBe( + '2024-05-06', + ); }); it('explains a whole break and compares another date against the current plan', async () => { @@ -628,16 +1045,6 @@ describe('CalendarYear planning outcome boundary', () => { expect(element.querySelector('.counterfactual-result')?.textContent).toContain( 'No leave is needed.', ); - TestBed.inject(TranslationService).setLanguage('fr'); - fixture.detectChanges(); - expect(element.querySelector('.day-impact')?.textContent).toContain( - 'Congés risquant d’expirer', - ); - expect(element.querySelector('.counterfactual-result')?.textContent).toContain( - 'Aucun congé à poser.', - ); - TestBed.inject(TranslationService).setLanguage('en'); - fixture.detectChanges(); await fixture.whenStable(); }); @@ -691,14 +1098,163 @@ describe('CalendarYear planning outcome boundary', () => { await fixture.whenStable(); fixture.detectChanges(); expect( - document.querySelector('.counterfactual-panel .inline-error')?.textContent, + document.querySelector('.counterfactual-panel .counterfactual-inline-error') + ?.textContent, ).toContain('Date alternatives could not be calculated.'); expect(document.querySelector('.comparison-done')).not.toBeNull(); - document.querySelector('.comparison-done')!.click(); + document.querySelector('.comparison-close')!.click(); await fixture.whenStable(); await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull()); }); + it('announces trace loading and completion while keeping the completed plan available', async () => { + const traceDelivery = deferred(); + const { calendar } = calendarFor(plan, explanation, undefined, traceDelivery.promise); + await TestBed.configureTestingModule({ + imports: [CalendarYear], + providers: [ + provideZonelessChangeDetection(), + { provide: CalendarService, useValue: calendar }, + ], + }).compileComponents(); + TestBed.inject(TranslationService).setLanguage('en'); + const fixture = TestBed.createComponent(CalendarYear); + fixture.detectChanges(); + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve, 0)); + fixture.detectChanges(); + + const root = fixture.nativeElement as HTMLElement; + const liveMessage = root.querySelector('.calendar-year > p[aria-live]')!; + const traceDetails = root.querySelector('.trace-explorer')!; + activateWithKeyboard(traceDetails.querySelector('summary')!, ' '); + fixture.detectChanges(); + expect(traceDetails.querySelector('[role="status"]')?.textContent).toContain( + 'decision trace is still loading', + ); + expect(liveMessage.getAttribute('aria-atomic')).toBe('true'); + expect(root.querySelector('.plan-summary')).not.toBeNull(); + + traceDelivery.resolve(jointTrace()); + await fixture.whenStable(); + fixture.detectChanges(); + expect(liveMessage.textContent).toContain('decision trace has loaded'); + await vi.waitFor(() => expect(traceDetails.querySelector('[role="status"]')).toBeNull()); + root.querySelector( + '.trace-decisions nav button[aria-label="Next decision"]', + )!.click(); + fixture.detectChanges(); + expect(root.querySelector('.candidate-list details')).not.toBeNull(); + }); + + it('announces trace delivery failure without discarding the completed plan', async () => { + const traceDelivery = deferred(); + const { calendar } = calendarFor(plan, explanation, undefined, traceDelivery.promise); + await TestBed.configureTestingModule({ + imports: [CalendarYear], + providers: [ + provideZonelessChangeDetection(), + { provide: CalendarService, useValue: calendar }, + ], + }).compileComponents(); + TestBed.inject(TranslationService).setLanguage('en'); + const fixture = TestBed.createComponent(CalendarYear); + fixture.detectChanges(); + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve, 0)); + fixture.detectChanges(); + const root = fixture.nativeElement as HTMLElement; + const traceDetails = root.querySelector('.trace-explorer')!; + activateWithKeyboard(traceDetails.querySelector('summary')!, ' '); + fixture.detectChanges(); + await new Promise((resolve) => setTimeout(resolve, 0)); + fixture.detectChanges(); + traceDelivery.reject(new Error('trace transfer failed')); + await vi.waitFor(() => { + fixture.detectChanges(); + expect(root.querySelector('.calendar-year > p[aria-live]')?.textContent).toContain( + 'No decision trace is available', + ); + }); + expect(root.querySelector('.calendar-year > p[aria-live]')?.textContent).toContain( + 'No decision trace is available', + ); + expect(traceDetails.querySelector('[role="alert"]')?.textContent).toContain( + 'trace transfer failed', + ); + expect(root.querySelector('.plan-summary')).not.toBeNull(); + }); + + it('announces background counterfactual failure and retries from explicit comparison', async () => { + const { calendar, calculation } = calendarFor(plan, explanation); + const firstAttempt = deferred(); + const retryAttempt = deferred(); + vi.spyOn(calculation, 'analyzeCounterfactual') + .mockImplementationOnce(() => firstAttempt.promise) + .mockImplementationOnce(() => retryAttempt.promise); + await TestBed.configureTestingModule({ + imports: [CalendarYear], + providers: [ + provideZonelessChangeDetection(), + { provide: CalendarService, useValue: calendar }, + ], + }).compileComponents(); + TestBed.inject(TranslationService).setLanguage('en'); + const fixture = TestBed.createComponent(CalendarYear); + fixture.detectChanges(); + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve, 0)); + fixture.detectChanges(); + + const root = fixture.nativeElement as HTMLElement; + const plannedDate = root.querySelector( + 'button[data-date="2024-05-03"]', + )!; + activateWithKeyboard(plannedDate); + fixture.detectChanges(); + expect(root.querySelector('.counterfactual-progress')?.textContent).toContain( + 'Analyzing every date', + ); + expect(root.querySelector('.calendar-year > p[aria-live]')?.textContent).toContain( + 'Analyzing every date', + ); + expect(document.activeElement).toBe(plannedDate); + + firstAttempt.reject(new Error('worker unavailable')); + await fixture.whenStable(); + fixture.detectChanges(); + expect( + root.querySelector('.counterfactual-inline-error[role="alert"]')?.textContent, + ).toContain('worker unavailable'); + expect(root.querySelector('.calendar-year > p[aria-live]')?.textContent).toContain( + 'Date alternatives could not be calculated.', + ); + expect(document.activeElement).toBe(plannedDate); + + root.querySelector('.compare-dates-button')!.click(); + fixture.detectChanges(); + expect(document.querySelector('.comparison-loading')).not.toBeNull(); + const focusedBeforeResult = document.activeElement; + retryAttempt.resolve({ + kind: 'Counterfactual', + calculationId: 'test', + placement: plan.placements[0], + hypothetical: true, + baseline: objectiveVector, + results: [ + { date: '2024-05-03', status: 'eligible' }, + { date: '2024-05-06', status: 'eligible' }, + ], + }); + await fixture.whenStable(); + fixture.detectChanges(); + expect(document.querySelector('.counterfactual-result')).not.toBeNull(); + expect(root.querySelector('.calendar-year > p[aria-live]')?.textContent).toContain( + 'Date alternatives are ready.', + ); + expect(document.activeElement).toBe(focusedBeforeResult); + }); + it('renders conflict causes and replays an atomic trace decision', async () => { const conflictExplanation: PlanningExplanation = { kind: 'ConflictExplanation', @@ -713,6 +1269,7 @@ describe('CalendarYear planning outcome boundary', () => { { provide: CalendarService, useValue: setup.calendar }, ], }).compileComponents(); + TestBed.inject(TranslationService).setLanguage('en'); let fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); await fixture.whenStable(); @@ -726,65 +1283,25 @@ describe('CalendarYear planning outcome boundary', () => { ); TestBed.resetTestingModule(); - const trace: PlanningTrace = { - status: 'ready', - initialState: { - horizon: plan.horizon, - balances: [ - { - id: 'cp', - type: 'cp', - quantity: 2, - remaining: 2, - availableFrom: '2024-01-01', - expiryDate: '2024-12-31', - earned: false, - }, - ], - placements: [plan.placements[1]], - earnedActivations: [], - }, - phases: [ - { - id: 'phase:1', - name: 'at-risk', - completed: true, - status: 'completed', - decisions: [ - { - id: 'decision:1', - action: 'add', - dates: ['2024-05-03'], - candidates: [ - { - status: 'chosen', - date: '2024-05-03', - placement: plan.placements[0], - comparison: explanation.placements[0].comparison, - }, - ], - deltas: [ - { - kind: 'placement-added', - date: '2024-05-03', - balanceId: 'cp', - placement: plan.placements[0], - }, - ], - }, - ], - }, - { - id: 'phase:2', - name: 'dates', - completed: true, - status: 'completed', - decisions: [], - }, - ], - completedStages: ['at-risk'], - }; + const trace = jointTrace(); setup = calendarFor(plan, explanation, trace); + vi.mocked(setup.calculation.historicalEligibility).mockResolvedValue([ + { + date: '2024-01-02', + reason: { + code: 'unavailable', + message: 'This balance is not available until January 4.', + }, + }, + { + date: '2024-01-08', + reason: { code: 'expired', message: 'This balance expired on January 7.' }, + }, + ]); + const eligibilityRetry = deferred(); + vi.mocked(setup.calculation.historicalEligibility) + .mockRejectedValueOnce(new Error('Historical eligibility unavailable.')) + .mockImplementationOnce(() => eligibilityRetry.promise); setup.calendar.selectedDatesForPlan.mockImplementation((value) => selectedDatesForPlan(value, true), ); @@ -795,187 +1312,167 @@ describe('CalendarYear planning outcome boundary', () => { { provide: CalendarService, useValue: setup.calendar }, ], }).compileComponents(); + TestBed.inject(TranslationService).setLanguage('en'); fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); await fixture.whenStable(); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); - const lastPhase = fixture.nativeElement.querySelector( - 'button[data-phase-step="last"]', - ) as HTMLButtonElement; - lastPhase.click(); + const root = fixture.nativeElement as HTMLElement; + const traceDetails = root.querySelector('.trace-explorer')!; + activateWithKeyboard(traceDetails.querySelector('summary')!, ' '); fixture.detectChanges(); - expect(fixture.componentInstance['tracePhaseIndex']()).toBe(1); - const firstPhase = fixture.nativeElement.querySelector( - 'button[data-phase-step="first"]', - ) as HTMLButtonElement; - firstPhase.click(); - fixture.detectChanges(); - expect( - Array.from( - fixture.nativeElement.querySelectorAll( - '.candidate-list .criteria-table td', - ) as NodeListOf, - (node) => node.textContent?.trim(), - ), - ).toEqual(['3', '4']); - const beforeButton = fixture.nativeElement.querySelectorAll('.trace-toolbar button')[0]; - beforeButton.click(); - fixture.detectChanges(); - expect(fixture.componentInstance['traceState']()?.placements).toHaveLength(1); - expect(fixture.componentInstance['dateStatuses']()['2024-05-06'].change).toBe('unchanged'); - expect(fixture.componentInstance['dateStatuses']()['2024-05-03'].change).toBe('added'); - expect(fixture.componentInstance['dateStatuses']()['2024-05-04']).toMatchObject({ - kind: 'ineligible', - reason: 'Weekend', - }); - expect(fixture.componentInstance['dateStatuses']()['2024-05-08']).toMatchObject({ - kind: 'ineligible', - reason: 'Public Holiday', - }); - const weekend = fixture.nativeElement.querySelector( - 'button[data-date="2024-05-04"]', - ) as HTMLButtonElement; - expect(weekend.getAttribute('aria-label')).toContain('Ineligible'); - expect(weekend.getAttribute('aria-label')).toContain('Weekend'); - const holiday = fixture.nativeElement.querySelector( - 'button[data-date="2024-05-08"]', - ) as HTMLButtonElement; - expect(holiday.getAttribute('aria-label')).toContain('Public Holiday'); - const legend = fixture.nativeElement.querySelector('.calendar-legend').textContent; - expect(legend).toContain('Evaluated and rejected'); - expect(legend).toContain('Not examined'); - const unevaluatedDate = fixture.nativeElement.querySelector( - 'button[data-date="2024-05-07"]', - ) as HTMLButtonElement; - unevaluatedDate.click(); - fixture.detectChanges(); - expect(unevaluatedDate.classList).toContain('current'); - expect(fixture.componentInstance['liveMessage']()).toContain('Not examined'); - const afterButton = fixture.nativeElement.querySelectorAll('.trace-toolbar button')[1]; - afterButton.click(); - fixture.detectChanges(); - expect(fixture.componentInstance['traceState']()?.placements).toHaveLength(2); - const compareButton = fixture.nativeElement.querySelector( - '.candidate-list button', - ) as HTMLButtonElement; - compareButton.click(); + await fixture.whenStable(); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); - expect(fixture.componentInstance['traceReplay']()).toBe(true); - expect(fixture.componentInstance['selectedTraceDecision']()?.id).toBe('decision:1'); - expect(fixture.componentInstance['dateStatuses']()['2024-05-04']).toMatchObject({ - kind: 'ineligible', - label: 'Ineligible', - reason: 'Weekend', - hypotheticalLabel: 'Ineligible', - hypotheticalReason: 'Weekend', + expect(traceDetails.open).toBe(true); + const nextDecision = root.querySelector( + '.trace-decisions nav button[aria-label="Next decision"]', + )!; + activateWithKeyboard(nextDecision); + await vi.waitFor(() => { + expect(fixture.componentInstance['historicalEligibilityError']()).toBe( + 'Historical eligibility unavailable.', + ); }); - expect(fixture.componentInstance['dateStatuses']()['2024-05-03']).toMatchObject({ - kind: 'chosen', - label: 'Chosen', - hypotheticalLabel: 'Eligible', - }); - expect(fixture.componentInstance['dateStatuses']()['2024-05-07']).toMatchObject({ - kind: 'not-examined', - label: 'Not examined', - hypotheticalLabel: 'Ineligible', - }); - expect(fixture.componentInstance['dateStatuses']()['2024-05-08']).toMatchObject({ - kind: 'ineligible', - label: 'Ineligible', - reason: 'Public Holiday', - hypotheticalLabel: 'Ineligible', - }); - const holidayAlternative = fixture.nativeElement.querySelector( - 'button[data-date="2024-05-08"]', - ) as HTMLButtonElement; - expect(holidayAlternative.querySelector('.status-marker')?.textContent).toBe('×'); - expect(holidayAlternative.querySelector('.hypothetical-marker')?.textContent).toBe('~×'); - const notExaminedAlternative = fixture.nativeElement.querySelector( - 'button[data-date="2024-05-07"]', - ) as HTMLButtonElement; - expect(notExaminedAlternative.querySelector('.status-marker')?.textContent).toBe('?'); - expect(notExaminedAlternative.querySelector('.hypothetical-marker')?.textContent).toBe( - '~×', - ); - expect(notExaminedAlternative.getAttribute('aria-label')).toContain('Not examined'); - expect(notExaminedAlternative.getAttribute('aria-label')).toContain( - 'Date alternatives: Ineligible', - ); - const counterfactualLegend = fixture.nativeElement.querySelector('.calendar-legend'); - expect(counterfactualLegend.textContent).toContain('~✓Eligible'); - expect(counterfactualLegend.textContent).toContain('~×Ineligible'); - expect(fixture.componentInstance['dateStatuses']()['2024-05-03'].change).toBe('added'); - expect(fixture.componentInstance['dateStatuses']()['2024-05-06'].change).toBe('unchanged'); - const addedDate = fixture.nativeElement.querySelector( - 'button[data-date="2024-05-03"]', - ) as HTMLButtonElement; - expect(addedDate.querySelector('.status-marker')?.textContent).toBe('+'); - expect(addedDate.querySelector('.hypothetical-marker')?.textContent).toBe('~✓'); - expect(addedDate.classList).toContain('change-added'); - expect( - ( - fixture.nativeElement.querySelector( - 'button[data-date="2024-05-06"]', - ) as HTMLButtonElement - ).classList, - ).toContain('change-unchanged'); - const alternativeDate = fixture.nativeElement.querySelector( - 'button[data-date="2024-05-04"]', - ) as HTMLButtonElement; - alternativeDate.click(); - fixture.detectChanges(); - expect(alternativeDate.classList).toContain('current'); - expect(alternativeDate.getAttribute('aria-current')).toBe('date'); - expect(alternativeDate.getAttribute('aria-label')).toContain('Ineligible'); - expect(alternativeDate.getAttribute('aria-label')).toContain( - 'Date alternatives: Ineligible', - ); - expect(alternativeDate.getAttribute('aria-label')).toContain('Reason: Weekend'); - expect(fixture.componentInstance['liveMessage']()).toContain( - 'Date alternatives: Ineligible', - ); - const counterfactualResult = document.querySelector( - '.counterfactual-result', - ) as HTMLElement; - expect(counterfactualResult.textContent).toContain('This date can’t be used'); - expect(counterfactualResult.textContent).toContain('Original search status: Ineligible'); - const chosenDate = fixture.nativeElement.querySelector( - 'button[data-date="2024-05-03"]', - ) as HTMLButtonElement; - chosenDate.click(); - fixture.detectChanges(); - expect(chosenDate.getAttribute('aria-label')).toContain('Chosen'); - expect(chosenDate.getAttribute('aria-label')).toContain('Date alternatives: Eligible'); - expect(counterfactualResult.textContent).toContain('Available date'); - expect(counterfactualResult.textContent).toContain('Original search status: Chosen'); - expect(counterfactualResult.textContent).toContain('Before this decision'); - expect( - Array.from(counterfactualResult.querySelectorAll(':scope > .impact-table td'), (node) => - node.textContent?.trim(), - ), - ).toEqual(['3', '4']); - expect( - Array.from(counterfactualResult.querySelectorAll('.criteria-table td'), (node) => - node.textContent?.trim(), - ), - ).toEqual(['3', '4']); - // Closing the trace restores the actual plan and clears hypothetical markers. - const traceDetails = fixture.nativeElement.querySelector( - '.trace-explorer', - ) as HTMLDetailsElement; - traceDetails.open = true; - traceDetails.dispatchEvent(new Event('toggle')); - beforeButton.click(); fixture.detectChanges(); expect(fixture.componentInstance['traceReplay']()).toBe(true); - traceDetails.open = false; - traceDetails.dispatchEvent(new Event('toggle')); + expect(root.querySelector('[role="alert"]')?.textContent).toContain( + 'Historical eligibility unavailable.', + ); + expect(root.querySelector('.sr-only[role="status"]')?.textContent).toContain( + 'Original date eligibility could not be checked.', + ); + const retryEligibility = Array.from(root.querySelectorAll('button')).find((button) => + button.textContent?.includes('Retry date eligibility check'), + )!; + activateWithKeyboard(retryEligibility); fixture.detectChanges(); - expect(fixture.componentInstance['traceReplay']()).toBe(false); - expect(fixture.componentInstance['counterfactual']()).toBeNull(); - expect(fixture.componentInstance['dateStatuses']()).toEqual({}); + expect(root.querySelector('[role="status"]')?.textContent).toContain( + 'Checking which dates this leave could use', + ); + eligibilityRetry.resolve([ + { + date: '2024-01-02', + reason: { + code: 'unavailable', + message: 'This balance is not available until January 4.', + }, + }, + { + date: '2024-01-08', + reason: { code: 'expired', message: 'This balance expired on January 7.' }, + }, + ]); + await vi.waitFor(() => { + expect([...fixture.componentInstance['historicalIneligibility']().keys()]).toEqual([ + '2024-01-02', + '2024-01-08', + ]); + }); + fixture.detectChanges(); + expect(setup.calculation.historicalEligibility).toHaveBeenCalledWith({ + decisionId: 'decision:joint', + placement: plan.placements[0], + }); + expect(fixture.componentInstance['traceReplay']()).toBe(true); + expect(fixture.componentInstance['historicalEligibilityError']()).toBeNull(); + expect([...fixture.componentInstance['historicalIneligibility']().keys()]).toEqual([ + '2024-01-02', + '2024-01-08', + ]); + const historicalAvailability = fixture.componentInstance['dateStatuses'](); + expect(historicalAvailability['2024-01-02']).toMatchObject({ + kind: 'ineligible', + reason: 'This leave balance cannot be used on this date.', + }); + expect(historicalAvailability['2024-01-08']).toMatchObject({ + kind: 'ineligible', + reason: 'This date is after the leave balance expires.', + }); + let earnedBalance = Array.from(root.querySelectorAll('.trace-state-balance')).find((item) => + item.textContent?.includes('earned:spring:2024'), + )!; + expect(earnedBalance.textContent).toContain('Available from 4 May 2024'); + expect(earnedBalance.textContent).toContain('Expires on 4 May 2025'); + const jointAfterButton = + root.querySelectorAll('.trace-toolbar button')[1]!; + activateWithKeyboard(jointAfterButton); + fixture.detectChanges(); + earnedBalance = Array.from(root.querySelectorAll('.trace-state-balance')).find((item) => + item.textContent?.includes('earned:spring:2024'), + )!; + expect(earnedBalance.textContent).toContain('Available from 7 May 2024'); + expect(earnedBalance.textContent).toContain('Expires on 7 May 2025'); + expect( + root + .querySelector('.trace-decisions output[aria-current="step"]') + ?.getAttribute('aria-label'), + ).toContain('2024-05-03, 2024-05-06'); + const candidates = Array.from( + root.querySelectorAll('.candidate-list > details'), + ); + expect(candidates).toHaveLength(2); + expect(candidates[0].querySelector('summary')?.textContent).toContain( + '2024-05-10, 2024-05-13', + ); + expect(candidates[1].querySelector('summary')?.textContent).toContain( + '2024-05-03, 2024-05-06', + ); + expect(candidates.every((candidate) => !candidate.open)).toBe(true); + const technicalComparison = candidates[0].querySelector('.technical-comparison')!; + expect(technicalComparison.closest('details')).toBe(candidates[0]); + expect(candidates[0].open).toBe(false); + activateWithKeyboard(candidates[0].querySelector('summary')!, 'Enter'); + fixture.detectChanges(); + expect(candidates[0].open).toBe(true); + expect(candidates[0].textContent).toContain( + 'This branch cannot satisfy both required dates.', + ); + expect(technicalComparison.textContent).toContain('Actual stage comparison'); + const traceImpact = candidates[0].querySelector('.criteria-detail')!; + expect(traceImpact.textContent).toContain('Full-plan effect of this branch'); + expect(traceImpact.querySelectorAll('.decisive-row')).toHaveLength(0); + expect(traceImpact.textContent).not.toContain('First difference'); + const decisiveComparator = candidates[0].querySelector( + '.technical-comparison tbody tr.decisive-row', + )!; + expect(decisiveComparator.textContent).toContain('preference:spring'); + expect(decisiveComparator.textContent).toContain('1'); + expect(decisiveComparator.textContent).toContain('0'); + expect( + candidates[0].textContent!.indexOf('This branch cannot satisfy both required dates.'), + ).toBeLessThan(candidates[0].textContent!.indexOf('preference:spring')); + expect(document.activeElement).toBe(candidates[0].querySelector('summary')); + + const beforeButton = root.querySelector('.trace-toolbar button') as HTMLButtonElement; + const afterButton = root.querySelectorAll('.trace-toolbar button')[1] as HTMLButtonElement; + activateWithKeyboard(beforeButton, ' '); + fixture.detectChanges(); + expect(beforeButton.getAttribute('aria-pressed')).toBe('true'); + expect(root.querySelector('.trace-state-summary')?.textContent).toContain( + '1 earned activations', + ); + expect(root.querySelector('.trace-state-summary')?.textContent).toContain( + 'cp: 1 remaining', + ); + activateWithKeyboard(afterButton); + fixture.detectChanges(); + expect(afterButton.getAttribute('aria-pressed')).toBe('true'); + expect(root.querySelector('.trace-state-summary')?.textContent).toContain('2 placements'); + expect(root.querySelector('.trace-state-summary')?.textContent).toContain( + '1 earned activations', + ); + expect(root.querySelector('.trace-state-summary')?.textContent).toContain( + 'cp: 0 remaining', + ); + const weekend = root.querySelector('button[data-date="2024-05-04"]')!; + expect(weekend.getAttribute('aria-label')).toContain('Ineligible'); + expect(weekend.querySelector('.status-marker')?.textContent).toBe('×'); + const legend = root.querySelector('.calendar-legend')?.textContent ?? ''; + expect(legend).toContain('Evaluated and rejected'); + expect(legend).toContain('Not examined'); + expect(root.querySelector('.counterfactual-result')).toBeNull(); }); it('turns planner rejection into an incomplete error and clears loading', async () => { diff --git a/src/app/components/calendar-year/calendar-year.html b/src/app/components/calendar-year/calendar-year.html index e3e6b7f..88c5eb3 100644 --- a/src/app/components/calendar-year/calendar-year.html +++ b/src/app/components/calendar-year/calendar-year.html @@ -1,5 +1,7 @@
-

{{ liveMessage() }}

+

+ {{ liveMessage() }} +

@if (isLoading()) {
@@ -12,7 +14,7 @@ >
{{ loadingProgress() }}%
-

{{ tr().loading }}

+

{{ tr().loading }}

} @@ -170,7 +172,7 @@ - + {{ tr().planning.placement }} {{ selectedPlacementIndex() + 1 }} {{ tr().planning.of }} {{ planExplanation()!.placements.length }} @@ -408,7 +441,7 @@ {{ tracePhaseIndex() + 1 }} {{ tr().planning.of }} {{ trace.phases.length }} {{ tr().planning.decision }} {{ traceDecisionIndex() + 1 }} {{ tr().planning.of }} {{ phase.decisions.length }} +

+ }

{{ tr().planning.dates }}: {{ @@ -562,13 +620,17 @@ > {{ tr().planning.page }} @@ -578,14 +640,16 @@ > } @@ -624,6 +688,100 @@ @if (candidate.reason) {

{{ candidate.reason.message }}

} + @if (candidate.technicalComparison?.length) { +
+
+ {{ + tr().planning.details + .traceStageTitle + }} +
+

+ {{ + tr().planning.details + .traceStageHelp + }} +

+ + + + + + + + + + @for ( + comparator of candidate.technicalComparison; + track $index + ) { + + + + + + } + +
+ {{ + tr().planning + .explanation + .criterion + }} + + {{ + tr().planning.before + }} + + {{ + tr().planning.after + }} +
+ {{ + comparator.name + }} + {{ + comparator.direction === + 'maximize' + ? tr() + .planning + .details + .higher + : tr() + .planning + .details + .lower + }} + @if ( + comparator.name === + candidate.firstDecisiveComparator + ) { + {{ + tr() + .planning + .details + .firstDifference + }} + } + + {{ + comparator.before + }} + + {{ + comparator.after + }} +
+
+ } @if (candidate.comparison; as comparison) {
@@ -687,6 +849,21 @@ {{ tr().planning.placements }}, {{ state.earnedActivations.length }} {{ tr().planning.activations }} + @for (balance of state.balances; track balance.id) { + {{ balance.id }}: {{ balance.remaining }} + {{ tr().planning.balanceRemaining }} + {{ tr().planning.details.available }} + {{ displayDate(balance.availableFrom) }} + · + {{ tr().planning.details.expires }} + {{ + displayDate(balance.expiryDate) + }} + }

} } @@ -697,7 +874,7 @@ } } @else if (traceResolved()) { -

{{ tr().planning.traceUnavailable }}

+

{{ tr().planning.traceUnavailable }}

} } @@ -711,6 +888,16 @@ } + @if (counterfactualLoading()) { + + } + @if (counterfactualError()) { + + }

{{ tr().planning.calendarHelp }}

@@ -828,10 +1015,23 @@ let-beforeLabel="beforeLabel" let-afterLabel="afterLabel" let-audit="audit" + let-traceEffect="traceEffect" >
-

{{ tr().planning.details.criteria }}

-

{{ tr().planning.details.criteriaHelp }}

+

+ {{ + traceEffect + ? tr().planning.details.traceImpactTitle + : tr().planning.details.criteria + }} +

+

+ {{ + traceEffect + ? tr().planning.details.traceImpactHelp + : tr().planning.details.criteriaHelp + }} +

@@ -849,7 +1049,7 @@ @if (objective.numeric) {
@@ -862,7 +1062,10 @@ ? tr().planning.details.higher : tr().planning.details.lower }} - @if (objective.id === comparison.firstDecisiveObjective) { + @if ( + !traceEffect && + objective.id === comparison.firstDecisiveObjective + ) { {{ tr().planning.details.firstDifference }} @@ -886,7 +1089,10 @@ @if (!objective.numeric) {
  • {{ objective.label }} - @if (objective.id === comparison.firstDecisiveObjective) { + @if ( + !traceEffect && + objective.id === comparison.firstDecisiveObjective + ) { {{ tr().planning.details.firstDifference }} @@ -940,7 +1146,9 @@

    {{ tr().planning.counterfactualLoading }}

    } @else if (counterfactualError()) { - + } @else if (counterfactual(); as analysis) {
    @@ -1106,6 +1314,12 @@ ) }}

    + @if (dateStatuses()[result.date]?.reason; as historicalReason) { +

    + {{ tr().planning.reason }}: + {{ historicalReason }} +

    + } @if (dateComparison(); as comparison) { } @else { @@ -1136,11 +1351,17 @@
    {{ tr().planning.comparison.preview }} -
    diff --git a/src/app/components/calendar-year/calendar-year.scss b/src/app/components/calendar-year/calendar-year.scss index 12f61ab..f743b0c 100644 --- a/src/app/components/calendar-year/calendar-year.scss +++ b/src/app/components/calendar-year/calendar-year.scss @@ -67,6 +67,10 @@ button { opacity: 0.45; cursor: not-allowed; } + &[aria-disabled='true'] { + opacity: 0.45; + cursor: not-allowed; + } } summary { @@ -353,12 +357,27 @@ output { margin: 0; } .inline-error, +.counterfactual-inline-error, .conflict-explanation { padding: 0.75rem; border-inline-start: 4px solid var(--ui-error-text); background: var(--ui-error-bg); color: var(--ui-error-text); } +.counterfactual-inline-error { + display: grid; + justify-items: start; + gap: 0.75rem; +} +.counterfactual-progress { + color: var(--ui-text-secondary); + font-size: 0.875rem; +} +.decisive-summary { + padding: 0.75rem; + border-inline-start: 3px solid var(--mat-sys-primary); + background: var(--ui-panel-bg); +} .incomplete-explanation, .incomplete-incumbent { padding: 1rem; @@ -461,6 +480,8 @@ output { } small { color: var(--ui-text-secondary); + flex-shrink: 0; + white-space: nowrap; } } .trace-phase-stepper { diff --git a/src/app/components/calendar-year/calendar-year.ts b/src/app/components/calendar-year/calendar-year.ts index 3296bd3..0c82ba2 100644 --- a/src/app/components/calendar-year/calendar-year.ts +++ b/src/app/components/calendar-year/calendar-year.ts @@ -33,6 +33,7 @@ import { RuleService } from '../../services/rule-service'; import { TranslationService } from '../../services/translation-service'; import { UserInputService } from '../../services/user-input-service'; import { + applyReplayDeltas, CounterfactualAnalysis, CounterfactualSelection, LeavePlacement, @@ -52,6 +53,7 @@ import { type PlacementExplanation = PlanExplanation['placements'][number]; type TraceMoment = 'before' | 'after'; +type TraceTransition = Readonly<{ before: PlanningTraceState; after: PlanningTraceState }>; @Component({ selector: 'app-calendar-year', @@ -110,12 +112,24 @@ export class CalendarYear { protected readonly traceReplay = signal(false); protected readonly selectedTraceDate = signal(null); protected readonly liveMessage = signal(''); + private readonly historicalIneligibility = signal>( + new Map(), + ); + protected readonly historicalEligibilityLoading = signal(false); + protected readonly historicalEligibilityError = signal(null); protected readonly planningHorizon = signal<{ start: string; end: string } | null>(null); protected readonly selectedDates = signal(new SelectedDates()); private calculationVersion = 0; private counterfactualVersion = 0; + private historicalEligibilityVersion = 0; private activeCalculation: PlanningCalculationContext | null = null; + private readonly traceDecisionIndices = new WeakMap< + PlanningTrace, + ReadonlyMap + >(); + private displayDateLocale = ''; + private displayDateFormatter?: Intl.DateTimeFormat; get tr() { return this.translationService.tr; @@ -146,6 +160,11 @@ export class CalendarYear { protected readonly selectedTraceDecision = computed( () => this.selectedTracePhase()?.decisions[this.traceDecisionIndex()], ); + protected readonly selectedTraceTransition = computed(() => + this.traceReplay() + ? this.replayTraceTransition(this.planningTrace(), this.selectedTraceDecision()) + : undefined, + ); protected readonly visibleTraceCandidates = computed(() => { const start = this.traceCandidatePage() * 50; @@ -227,12 +246,7 @@ export class CalendarYear { ); protected readonly traceState = computed(() => { - if (!this.traceReplay()) return undefined; - return this.replayTraceState( - this.planningTrace(), - this.selectedTraceDecision()?.id, - this.traceMoment(), - ); + return this.selectedTraceTransition()?.[this.traceMoment()]; }); protected readonly calendarSelectedDates = computed(() => { @@ -250,7 +264,7 @@ export class CalendarYear { return this.selectedCounterfactualDate() ?? this.counterfactual()!.placement.date; if (this.traceReplay()) return this.selectedTraceDate() ?? this.selectedTraceDecision()?.dates[0] ?? null; - return this.planDetailsOpen() ? (this.selectedExplanation()?.placement.date ?? null) : null; + return this.selectedExplanation()?.placement.date ?? null; }); protected readonly dateStatuses = computed>>(() => { @@ -307,6 +321,14 @@ export class CalendarYear { } } } + for (const [date, reason] of this.historicalIneligibility()) { + if (statuses[date]?.kind !== 'not-examined') continue; + statuses[date] = { + kind: 'ineligible', + label: this.traceStatusLabel('ineligible'), + reason: this.reasonLabel(reason), + }; + } const decision = this.selectedTraceDecision(); if (decision) { const changedDates = new Set(); @@ -325,7 +347,7 @@ export class CalendarYear { changeLabel: this.traceChangeLabel(change), }; }; - for (const delta of decision.deltas) { + for (const delta of decision.branch?.deltas ?? decision.deltas) { if (delta.kind === 'placement-added') setChange(delta.date, 'added'); else if (delta.kind === 'placement-removed') setChange(delta.date, 'removed'); else if (delta.kind === 'placement-moved') { @@ -333,11 +355,10 @@ export class CalendarYear { setChange(delta.toDate, 'moved-to'); } else if (delta.kind === 'placement-updated') setChange(delta.date, 'shared'); } - const before = this.replayTraceState(trace, decision.id, 'before'); - const after = this.replayTraceState(trace, decision.id, 'after'); + const transition = this.selectedTraceTransition(); for (const placement of [ - ...(before?.placements ?? []), - ...(after?.placements ?? []), + ...(transition?.before.placements ?? []), + ...(transition?.after.placements ?? []), ]) { if (!changedDates.has(placement.date)) setChange(placement.date, 'unchanged'); } @@ -351,6 +372,9 @@ export class CalendarYear { ...status, kind: historicalStatus, label: this.traceStatusLabel(historicalStatus), + reason: result.historicalReason + ? this.reasonLabel(result.historicalReason) + : status?.reason, hypotheticalLabel: result.status === 'eligible' ? this.tr().planning.eligible @@ -367,7 +391,11 @@ export class CalendarYear { statuses[result.date] = { kind: historicalStatus, label: this.traceStatusLabel(historicalStatus), - reason: historicalStatus === 'ineligible' ? nonWorkingReason : undefined, + reason: result.historicalReason + ? this.reasonLabel(result.historicalReason) + : historicalStatus === 'ineligible' + ? nonWorkingReason + : undefined, hypotheticalLabel: result.status === 'eligible' ? this.tr().planning.eligible @@ -381,11 +409,18 @@ export class CalendarYear { protected readonly months = computed(() => { const horizon = this.planningHorizon(); if (!horizon) return []; - const start = new Date(`${horizon.start}T00:00:00`); - const last = new Date(`${horizon.end}T00:00:00`); + let first = new Date(`${horizon.start}T00:00:00`); + let last = new Date(`${horizon.end}T00:00:00`); last.setDate(last.getDate() - 1); + for (const period of this.displayedPlan()?.timeOffPeriods ?? []) { + for (const date of period.nonWorkingDates) { + const contextDate = new Date(`${date}T00:00:00`); + if (date < horizon.start && contextDate < first) first = contextDate; + else if (date >= horizon.end && contextDate > last) last = contextDate; + } + } const result: { monthIndex: number; year: number }[] = []; - const cursor = new Date(start.getFullYear(), start.getMonth(), 1); + const cursor = new Date(first.getFullYear(), first.getMonth(), 1); while (cursor <= last) { result.push({ monthIndex: cursor.getMonth(), year: cursor.getFullYear() }); cursor.setMonth(cursor.getMonth() + 1); @@ -406,6 +441,7 @@ export class CalendarYear { const version = ++this.calculationVersion; this.isLoading.set(true); this.loadingProgress.set(0); + this.liveMessage.set(this.tr().loading); queueMicrotask(() => { let calculation: PlanningCalculationContext; try { @@ -494,26 +530,57 @@ export class CalendarYear { this.traceCandidatePage.set(0); this.traceMoment.set('before'); this.traceReplay.set(false); + this.clearHistoricalEligibility(); this.selectedTraceDate.set(null); this.selectedDates.set(this.calendarService.getSelectedDatesForYear()); this.userInput.remainingVacationDaysSignal.set([]); } private loadArtifacts(calculation: PlanningCalculationContext, version: number): void { - void calculation.explanation.then((explanation) => { - if (version !== this.calculationVersion) return; - this.planningExplanation.set(explanation ?? null); - if (explanation?.kind === 'PlanExplanation' && this.planDetailsOpen()) - this.selectPlacement(0); - }); - void calculation.explanationError.then((reason) => { - if (version === this.calculationVersion) this.explanationError.set(reason ?? null); - }); + void calculation.explanation + .then((explanation) => { + if (version !== this.calculationVersion) return; + this.planningExplanation.set(explanation ?? null); + if (explanation?.kind === 'PlanExplanation') { + this.planDetailsOpen.set(true); + this.selectPlacement(0, false, false); + this.liveMessage.set( + `${this.planningOutcome() ? this.outcomeLabel(this.planningOutcome()!) : ''} ${this.placementAnnouncement()}`.trim(), + ); + } + }) + .catch((error: unknown) => { + if (version !== this.calculationVersion) return; + const reason = { + code: 'explanation-error', + message: error instanceof Error ? error.message : String(error), + }; + this.explanationError.set(reason); + this.liveMessage.set(`${this.tr().planning.explanationError} ${reason.message}`); + }); + void calculation.explanationError + .then((reason) => { + if (version === this.calculationVersion) this.explanationError.set(reason ?? null); + }) + .catch((error: unknown) => { + if (version !== this.calculationVersion) return; + const reason = { + code: 'explanation-error', + message: error instanceof Error ? error.message : String(error), + }; + this.explanationError.set(reason); + this.liveMessage.set(`${this.tr().planning.explanationError} ${reason.message}`); + }); void calculation.trace .then((trace) => { if (version !== this.calculationVersion) return; this.planningTrace.set(trace ?? null); this.traceResolved.set(true); + this.liveMessage.set( + trace?.status === 'failed' + ? this.tr().planning.traceUnavailable + : this.tr().planning.traceReady, + ); }) .catch((error: unknown) => { if (version !== this.calculationVersion) return; @@ -533,6 +600,7 @@ export class CalendarYear { }, }); this.traceResolved.set(true); + this.liveMessage.set(this.tr().planning.traceUnavailable); }) .finally(() => { if (version === this.calculationVersion) this.traceLoading.set(false); @@ -553,7 +621,7 @@ export class CalendarYear { this.userInput.remainingVacationDaysSignal.set([]); } - protected selectPlacement(index: number): void { + protected selectPlacement(index: number, analyze = true, announce = true): void { const explanation = this.planExplanation(); if (!explanation?.placements.length) return; const selected = Math.max(0, Math.min(index, explanation.placements.length - 1)); @@ -561,14 +629,27 @@ export class CalendarYear { this.traceReplay.set(false); this.clearCounterfactual(); this.selectedCounterfactualDate.set(explanation.placements[selected].placement.date); - this.liveMessage.set( - `${this.tr().planning.placement} ${selected + 1} ${this.tr().planning.of} ${explanation.placements.length}`, - ); + if (announce) this.liveMessage.set(this.placementAnnouncement()); + if (analyze) void this.analyzeSelection(explanation.placements[selected].placement); } protected compareDates(): void { const placement = this.selectedExplanation()?.placement; - if (placement) void this.analyzeSelection(placement); + if (!placement) return; + this.openComparisonDialog(); + if (this.counterfactual()) { + if (this.selectedCounterfactualDate() === placement.date) { + const alternative = this.nearbyComparisonDates()[0]; + if (alternative) this.selectCalendarDate(alternative.date); + } + } else if (!this.counterfactualLoading()) { + void this.analyzeSelection(placement, true); + } + } + + protected retryCounterfactual(): void { + if (this.counterfactualSelection) + void this.analyzeSelection(this.counterfactualSelection, !!this.comparisonDialog); } protected togglePlanDetails(event: Event): void { @@ -592,15 +673,15 @@ export class CalendarYear { protected stepPlacement(step: 'first' | 'previous' | 'next' | 'last'): void { const last = (this.planExplanation()?.placements.length ?? 1) - 1; const current = this.selectedPlacementIndex(); - this.selectPlacement( + const target = step === 'first' ? 0 : step === 'last' ? last : step === 'previous' ? current - 1 - : current + 1, - ); + : current + 1; + if (target !== current) this.selectPlacement(target); } protected selectCalendarDate(date: string): void { @@ -616,10 +697,6 @@ export class CalendarYear { this.planDetailsOpen.set(true); this.traceOpen.set(false); this.selectPlacement(index); - this.planDetails()?.nativeElement.scrollIntoView?.({ block: 'start' }); - this.planDetails() - ?.nativeElement.querySelector('summary') - ?.focus({ preventScroll: true }); return; } } @@ -661,25 +738,24 @@ export class CalendarYear { this.traceCandidatePage.set(0); this.traceMoment.set('before'); this.traceReplay.set(false); + this.clearHistoricalEligibility(); this.selectedTraceDate.set(null); this.clearCounterfactual(); - this.liveMessage.set( - `${this.tr().planning.phases} ${index + 1} ${this.tr().planning.of} ${this.planningTrace()?.phases.length ?? 0}`, - ); + this.liveMessage.set(this.tracePhaseAnnouncement()); } protected stepTracePhase(step: 'first' | 'previous' | 'next' | 'last'): void { const last = (this.planningTrace()?.phases.length ?? 1) - 1; const current = this.tracePhaseIndex(); - this.selectTracePhase( + const target = step === 'first' ? 0 : step === 'last' ? last : step === 'previous' ? current - 1 - : current + 1, - ); + : current + 1; + if (target !== current) this.selectTracePhase(target); } protected selectTraceDecision(index: number): void { @@ -691,28 +767,94 @@ export class CalendarYear { this.traceReplay.set(true); this.selectedTraceDate.set(this.selectedTraceDecision()?.dates[0] ?? null); this.clearCounterfactual(); - this.liveMessage.set( - `${this.tr().planning.decision} ${index + 1} ${this.tr().planning.of} ${this.selectedTracePhase()?.decisions.length ?? 0}`, - ); + this.liveMessage.set(this.traceDecisionAnnouncement()); + void this.loadHistoricalEligibility(); + } + + private selectedDecisionPlacement(decision: PlanningTraceDecision): LeavePlacement | undefined { + const placements = [ + ...decision.candidates.flatMap((candidate) => + candidate.placement ? [candidate.placement] : [], + ), + ...(decision.branch?.deltas ?? decision.deltas).flatMap((delta) => + delta.placement ? [delta.placement] : [], + ), + ]; + const selectedDate = this.selectedTraceDate() ?? decision.dates[0]; + return placements.find((placement) => placement.date === selectedDate) ?? placements[0]; + } + + private clearHistoricalEligibility(): void { + this.historicalEligibilityVersion++; + this.historicalIneligibility.set(new Map()); + this.historicalEligibilityLoading.set(false); + this.historicalEligibilityError.set(null); + } + + private async loadHistoricalEligibility(): Promise { + const version = ++this.historicalEligibilityVersion; + this.historicalIneligibility.set(new Map()); + this.historicalEligibilityLoading.set(false); + this.historicalEligibilityError.set(null); + const calculation = this.activeCalculation; + const decision = this.selectedTraceDecision(); + const placement = decision && this.selectedDecisionPlacement(decision); + if (!this.traceReplay() || !calculation || !decision || !placement) return; + this.historicalEligibilityLoading.set(true); + this.liveMessage.set(this.tr().planning.historicalEligibilityLoading); + try { + const reasons = await calculation.historicalEligibility({ + decisionId: decision.id, + placement, + }); + if ( + version !== this.historicalEligibilityVersion || + calculation !== this.activeCalculation + ) + return; + this.historicalIneligibility.set( + new Map(reasons.map(({ date, reason }) => [date, reason])), + ); + this.liveMessage.set(this.tr().planning.historicalEligibilityReady); + } catch (error) { + if (version !== this.historicalEligibilityVersion) return; + this.historicalIneligibility.set(new Map()); + const message = error instanceof Error ? error.message : String(error); + this.historicalEligibilityError.set(message); + this.liveMessage.set(`${this.tr().planning.historicalEligibilityError} ${message}`); + } finally { + if (version === this.historicalEligibilityVersion) + this.historicalEligibilityLoading.set(false); + } + } + + protected retryHistoricalEligibility(): void { + void this.loadHistoricalEligibility(); } protected stepTraceDecision(step: 'first' | 'previous' | 'next' | 'last'): void { const last = (this.selectedTracePhase()?.decisions.length ?? 1) - 1; const current = this.traceDecisionIndex(); - this.selectTraceDecision( + const target = step === 'first' ? 0 : step === 'last' ? last : step === 'previous' ? current - 1 - : current + 1, - ); + : current + 1; + if (target !== current) this.selectTraceDecision(target); } protected stepTraceCandidatePage(direction: -1 | 1): void { - this.traceCandidatePage.update((page) => - Math.max(0, Math.min(page + direction, this.traceCandidatePages() - 1)), + const page = Math.max( + 0, + Math.min(this.traceCandidatePage() + direction, this.traceCandidatePages() - 1), + ); + if (page === this.traceCandidatePage()) return; + this.traceCandidatePage.set(page); + this.liveMessage.set( + `${this.tr().planning.page} ${this.traceCandidatePage() + 1} ${this.tr().planning.of} ${this.traceCandidatePages()}`, ); } @@ -720,13 +862,13 @@ export class CalendarYear { this.clearCounterfactual(); this.traceMoment.set(moment); this.traceReplay.set(true); - this.liveMessage.set( - `${this.tr().planning[moment]} ${this.tr().planning.decision} ${this.traceDecisionIndex() + 1} ${this.tr().planning.of} ${this.selectedTracePhase()?.decisions.length ?? 0}`, - ); + this.liveMessage.set(`${this.tr().planning[moment]} ${this.traceDecisionAnnouncement()}`); + void this.loadHistoricalEligibility(); } protected showFinalPlan(): void { this.traceReplay.set(false); + this.clearHistoricalEligibility(); this.clearCounterfactual(); this.liveMessage.set(this.tr().planning.finalPlan); } @@ -737,7 +879,10 @@ export class CalendarYear { this.traceMoment.set('before'); this.traceReplay.set(true); this.selectedTraceDate.set(candidate.placement.date); - void this.analyzeSelection({ decisionId: decision.id, placement: candidate.placement }); + void this.analyzeSelection( + { decisionId: decision.id, placement: candidate.placement }, + true, + ); } private nonWorkingDateReason( @@ -750,59 +895,81 @@ export class CalendarYear { return undefined; } - private async analyzeSelection(selection: CounterfactualSelection): Promise { + private counterfactualSelection: CounterfactualSelection | null = null; + + private async analyzeSelection( + selection: CounterfactualSelection, + openDialog = false, + ): Promise { const calculation = this.activeCalculation; if (!calculation) return; + this.counterfactualSelection = selection; const version = ++this.counterfactualVersion; this.counterfactual.set(null); this.counterfactualError.set(null); this.counterfactualLoading.set(true); this.invalidComparisonDate.set(false); - if (!this.comparisonDialog) { - const dialog = this.dialog.open(this.comparisonTemplate(), { - width: '48rem', - maxWidth: 'calc(100vw - 2rem)', - maxHeight: 'calc(100dvh - 2rem)', - autoFocus: 'first-heading', - ariaLabelledBy: 'counterfactual-title', - ariaDescribedBy: 'comparison-intro', - }); - this.comparisonDialog = dialog; - dialog.beforeClosed().subscribe(() => { - if (this.comparisonDialog === dialog) { - this.comparisonDialog = null; - this.clearCounterfactual(); - } - }); - } + if (openDialog) this.openComparisonDialog(); + const placement = + typeof selection === 'string' + ? selection + : 'placement' in selection + ? selection.placement + : selection; + this.liveMessage.set( + `${this.tr().planning.counterfactualLoading} ${typeof placement === 'string' ? this.displayDate(placement) : this.displayDate(placement.date)}`, + ); try { const result = await calculation.analyzeCounterfactual(selection); if (version !== this.counterfactualVersion || calculation !== this.activeCalculation) return; if (result.kind === 'Counterfactual') { this.counterfactual.set(result); - this.selectCalendarDate( - this.traceReplay() - ? result.placement.date - : (this.nearbyComparisonDates()[0]?.date ?? result.placement.date), - ); + if (openDialog || this.comparisonDialog) + this.selectCalendarDate( + this.traceReplay() + ? result.placement.date + : (this.nearbyComparisonDates()[0]?.date ?? result.placement.date), + ); this.liveMessage.set(this.tr().planning.counterfactualReady); } } catch (error) { if (version !== this.counterfactualVersion || calculation !== this.activeCalculation) return; - this.counterfactualError.set(error instanceof Error ? error.message : String(error)); + const message = error instanceof Error ? error.message : String(error); + this.counterfactualError.set(message); + this.liveMessage.set(`${this.tr().planning.counterfactualError} ${message}`); } finally { if (version === this.counterfactualVersion) this.counterfactualLoading.set(false); } } + private openComparisonDialog(): void { + if (this.comparisonDialog) return; + const dialog = this.dialog.open(this.comparisonTemplate(), { + width: '48rem', + maxWidth: 'calc(100vw - 2rem)', + maxHeight: 'calc(100dvh - 2rem)', + autoFocus: 'first-heading', + ariaLabelledBy: 'counterfactual-title', + ariaDescribedBy: 'comparison-intro', + }); + this.comparisonDialog = dialog; + dialog.beforeClosed().subscribe(() => { + if (this.comparisonDialog === dialog) { + this.comparisonDialog = null; + this.clearCounterfactual(); + } + }); + } + protected clearCounterfactual(): void { const dialog = this.comparisonDialog; this.comparisonDialog = null; dialog?.close(); this.counterfactualVersion++; this.counterfactual.set(null); + this.counterfactualSelection = null; this.selectedCounterfactualDate.set(null); this.counterfactualLoading.set(false); this.counterfactualError.set(null); @@ -810,70 +977,130 @@ export class CalendarYear { this.comparisonDateInput.set(null); } - private replayTraceState( + private replayTraceTransition( trace: PlanningTrace | null, - decisionId: string | undefined, - moment: TraceMoment, - ): PlanningTraceState | undefined { - if (!trace || !decisionId) return undefined; - const balances = trace.initialState.balances.map((balance) => ({ ...balance })); - const placements = new Map( - trace.initialState.placements.map((placement) => [placement.date, { ...placement }]), - ); - const earnedActivations = trace.initialState.earnedActivations.map((activation) => ({ - ...activation, - dates: [...activation.dates], - })); + decision: PlanningTraceDecision | undefined, + ): TraceTransition | undefined { + if (!trace || !decision) return undefined; + const state = { + balances: trace.initialState.balances.map((balance) => ({ ...balance })), + placements: new Map( + trace.initialState.placements.map((placement) => [ + placement.date, + { ...placement, ruleIds: [...placement.ruleIds] }, + ]), + ), + earnedActivations: trace.initialState.earnedActivations.map((activation) => ({ + ...activation, + dates: [...activation.dates], + })), + }; const snapshot = (): PlanningTraceState => ({ horizon: { ...trace.initialState.horizon }, - balances, - placements: [...placements.values()], - earnedActivations, + balances: state.balances.map((balance) => ({ ...balance })), + placements: [...state.placements.values()].map((placement) => ({ + ...placement, + ruleIds: [...placement.ruleIds], + })), + earnedActivations: state.earnedActivations.map((activation) => ({ + ...activation, + dates: [...activation.dates], + })), }); - for (const decision of trace.phases.flatMap((phase) => phase.decisions)) { - if (decision.id === decisionId && moment === 'before') return snapshot(); - this.applyTraceDecision(decision, placements, balances, earnedActivations); - if (decision.id === decisionId) return snapshot(); + if (decision.branch) { + const decisionIndex = this.getTraceDecisionIndex(trace); + const target = decisionIndex.get(decision.id); + if (!target) return undefined; + const branchAncestors: PlanningTraceDecision[] = []; + const visited = new Set([decision.id]); + let current = decision; + let committedParentOrdinal: number | undefined; + while (current.branch) { + branchAncestors.unshift(current); + const parentId = current.branch.parentDecisionId; + if (!parentId) break; + if (visited.has(parentId)) return undefined; + visited.add(parentId); + const parent = decisionIndex.get(parentId); + if (!parent) return undefined; + if (!parent.decision.branch) { + committedParentOrdinal = parent.ordinal; + break; + } + current = parent.decision; + } + const branchRoot = branchAncestors[0]; + const branchRootIndex = branchRoot && decisionIndex.get(branchRoot.id)?.ordinal; + if (branchRootIndex === undefined || target.ordinal < branchRootIndex) return undefined; + const replayThrough = committedParentOrdinal ?? branchRootIndex - 1; + if (replayThrough >= branchRootIndex) return undefined; + let ordinal = 0; + for (const phase of trace.phases) { + for (const prior of phase.decisions) { + if (ordinal++ > replayThrough) break; + applyReplayDeltas(state, prior.deltas); + } + if (ordinal > replayThrough) break; + } + for (const ancestor of branchAncestors.slice(0, -1)) + applyReplayDeltas(state, ancestor.branch!.deltas); + const before = snapshot(); + applyReplayDeltas(state, decision.branch.deltas); + return { before, after: snapshot() }; + } + for (const phase of trace.phases) { + for (const prior of phase.decisions) { + if (prior.id === decision.id) { + const before = snapshot(); + applyReplayDeltas(state, prior.deltas); + return { before, after: snapshot() }; + } + applyReplayDeltas(state, prior.deltas); + } } return undefined; } - private applyTraceDecision( - decision: PlanningTraceDecision, - placements: Map, - balances: PlanningTraceState['balances'][number][], - earnedActivations: Array<{ - id: string; - ruleId: string; - occurrence: string; - dates: string[]; - }>, - ): void { - for (const delta of decision.deltas) { - if (delta.previousPlacement) placements.delete(delta.previousPlacement.date); - if (delta.placement) - placements.set(delta.placement.date, { - ...delta.placement, - ruleIds: [...delta.placement.ruleIds], - }); - const balanceIndex = balances.findIndex( - (candidate) => candidate.id === delta.balanceId, - ); - if (balanceIndex >= 0 && delta.remainingAfter !== undefined) - balances[balanceIndex] = { - ...balances[balanceIndex], - remaining: delta.remainingAfter, - }; - else if (balanceIndex < 0 && delta.balance) balances.push({ ...delta.balance }); - if ( - delta.activation && - !earnedActivations.some((activation) => activation.id === delta.activation!.id) - ) - earnedActivations.push({ - ...delta.activation, - dates: [...delta.activation.dates], - }); + private getTraceDecisionIndex( + trace: PlanningTrace, + ): ReadonlyMap { + let index = this.traceDecisionIndices.get(trace); + if (!index) { + const decisions = new Map< + string, + { decision: PlanningTraceDecision; ordinal: number } + >(); + let ordinal = 0; + for (const phase of trace.phases) + for (const decision of phase.decisions) + decisions.set(decision.id, { decision, ordinal: ordinal++ }); + index = decisions; + this.traceDecisionIndices.set(trace, index); } + return index; + } + + protected placementAnnouncement(): string { + const selected = this.selectedExplanation(); + const count = this.planExplanation()?.placements.length ?? 0; + if (!selected || !count) return ''; + return `${this.tr().planning.placement} ${this.selectedPlacementIndex() + 1} ${this.tr().planning.of} ${count}: ${this.displayDate(selected.placement.date)}`; + } + + protected hasAtRiskContribution(explanation: PlacementExplanation): boolean { + return explanation.contributions.some((contribution) => contribution.kind === 'at-risk'); + } + + protected tracePhaseAnnouncement(): string { + const phases = this.planningTrace()?.phases ?? []; + const phase = phases[this.tracePhaseIndex()]; + return `${this.tr().planning.phases} ${this.tracePhaseIndex() + 1} ${this.tr().planning.of} ${phases.length}${phase ? `: ${this.phaseLabel(phase.name)}` : ''}`; + } + + protected traceDecisionAnnouncement(): string { + const decisions = this.selectedTracePhase()?.decisions ?? []; + const decision = decisions[this.traceDecisionIndex()]; + return `${this.tr().planning.decision} ${this.traceDecisionIndex() + 1} ${this.tr().planning.of} ${decisions.length}${decision?.dates.length ? `: ${decision.dates.join(', ')}` : ''}`; } protected hasPlannerError(): boolean { @@ -974,10 +1201,15 @@ export class CalendarYear { } protected displayDate(value: string): string { - return new Intl.DateTimeFormat(this.translationService.locale(), { - dateStyle: 'medium', - timeZone: 'UTC', - }).format(new Date(value)); + const locale = this.translationService.locale(); + if (!this.displayDateFormatter || locale !== this.displayDateLocale) { + this.displayDateLocale = locale; + this.displayDateFormatter = new Intl.DateTimeFormat(locale, { + dateStyle: 'medium', + timeZone: 'UTC', + }); + } + return this.displayDateFormatter.format(new Date(value)); } protected planningDate(value: string | Date | undefined): string { @@ -989,6 +1221,10 @@ export class CalendarYear { return this.tr().planning.traceStatuses[status]; } + protected reasonLabel(reason: PlanningReason): string { + return this.tr().planning.explanation.unavailable[reason.code] ?? reason.message; + } + protected tracePhaseStatusLabel(status: 'completed' | 'interrupted' | 'not-run'): string { return this.tr().planning.tracePhaseStatuses[status]; } diff --git a/src/app/services/leave-planner-explanation.spec.ts b/src/app/services/leave-planner-explanation.spec.ts new file mode 100644 index 0000000..0bba11c --- /dev/null +++ b/src/app/services/leave-planner-explanation.spec.ts @@ -0,0 +1,1040 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPlanningCalculation } from './leave-planner.service'; + +const balance = ( + id: string, + type: 'cp' | 'rtt', + quantity: number, + expiryDate: string, + availableFrom = '2024-01-01', +) => ({ id, type, quantity, expiryDate, availableFrom }); + +beforeEach(() => vi.stubGlobal('window', undefined)); +afterEach(() => vi.unstubAllGlobals()); + +describe('planner explanation and trace seam', () => { + it('records only actual evaluations and publishes non-working dates as metadata', async () => { + const calculation = createPlanningCalculation({ + horizon: { start: '2024-01-01', end: '2024-01-07' }, + balances: [], + rules: [], + }); + const outcome = await calculation.outcome; + const trace = await calculation.trace; + + expect(outcome.kind).toBe('Plan'); + expect(trace?.status).toBe('ready'); + expect(trace?.nonWorkingDates?.map(({ date }) => date)).toEqual([ + '2024-01-01', + '2024-01-06', + ]); + expect( + trace?.phases + .flatMap((phase) => phase.decisions) + .flatMap((decision) => decision.candidates), + ).toEqual([]); + expect(trace?.phases.every((phase) => phase.status === 'completed')).toBe(true); + }); + + it('keeps joint obligation branches atomic and associates the selected shared placement', async () => { + const calculation = createPlanningCalculation({ + horizon: { start: '2024-01-01', end: '2024-01-08' }, + balances: [balance('cp', 'cp', 2, '2024-12-31')], + rules: [ + { + id: 'joint', + type: 'time_window', + force: 'obligation', + startDate: '2024-01-02', + endDate: '2024-01-05', + placeDays: 2, + placeType: 'cp', + }, + { + id: 'shared', + type: 'time_window', + force: 'obligation', + startDate: '2024-01-03', + endDate: '2024-01-03', + placeDays: 1, + placeType: 'cp', + }, + ], + }); + const outcome = await calculation.outcome; + const trace = await calculation.trace; + expect(outcome.kind).toBe('Plan'); + if (outcome.kind !== 'Plan' || !trace) return; + + const obligations = trace.phases.find((phase) => phase.name === 'obligations'); + const branch = obligations?.decisions + .flatMap((decision) => decision.candidates) + .find((candidate) => candidate.status === 'chosen' && candidate.dates?.length === 2); + expect(branch?.dates).toHaveLength(2); + expect( + branch?.dates?.every((date) => outcome.placements.some((item) => item.date === date)), + ).toBe(true); + const sharedPlacement = outcome.placements.find( + (placement) => + placement.ruleIds.includes('joint') && placement.ruleIds.includes('shared'), + ); + expect(sharedPlacement).toBeDefined(); + const acceptedMutation = obligations?.decisions.find((decision) => + decision.deltas.some( + (delta) => + (delta.kind === 'placement-added' || delta.kind === 'placement-updated') && + delta.placement !== undefined && + delta.placement.date === sharedPlacement?.date && + delta.placement.ruleIds.includes('joint') && + delta.placement.ruleIds.includes('shared'), + ), + ); + expect(acceptedMutation).toBeDefined(); + expect(acceptedMutation?.branch?.deltas).toContainEqual( + expect.objectContaining({ + kind: 'placement-updated', + placement: expect.objectContaining({ + date: sharedPlacement?.date, + ruleIds: ['joint', 'shared'], + }), + }), + ); + const sameDatesOnRejectedParent = obligations?.decisions.filter( + (decision) => + decision.id !== acceptedMutation?.id && + decision.candidates.some( + (candidate) => + candidate.status === 'evaluated-rejected' && + candidate.dates?.includes(sharedPlacement?.date ?? ''), + ), + ); + expect( + sameDatesOnRejectedParent?.some((decision) => decision.branch?.parentDecisionId), + ).toBe(true); + }); + + it('records at-risk assignments as sequential decisions with exact prior-state deltas', async () => { + const calculation = createPlanningCalculation({ + horizon: { start: '2024-01-01', end: '2024-01-08' }, + balances: [balance('cp', 'cp', 2, '2024-01-07')], + rules: [], + }); + const outcome = await calculation.outcome; + const trace = await calculation.trace; + expect(outcome.kind).toBe('Plan'); + if (outcome.kind !== 'Plan' || !trace) return; + + const atRisk = trace.phases.find((phase) => phase.name === 'at-risk'); + const jan2 = atRisk?.decisions.find((decision) => + decision.deltas.some( + (delta) => delta.kind === 'placement-added' && delta.date === '2024-01-02', + ), + ); + const jan3 = atRisk?.decisions.find((decision) => + decision.deltas.some( + (delta) => delta.kind === 'placement-added' && delta.date === '2024-01-03', + ), + ); + expect(jan2?.candidates.some((candidate) => candidate.status === 'chosen')).toBe(true); + expect(jan2?.candidates[0]).toMatchObject({ + technicalComparison: [ + { + name: 'preference-date-conflict', + direction: 'minimize', + before: 0, + after: 0, + }, + { + name: 'placement-date', + direction: 'minimize', + before: '2024-01-02', + after: '2024-01-03', + }, + ], + firstDecisiveComparator: 'placement-date', + }); + expect(jan2?.deltas).toContainEqual( + expect.objectContaining({ + kind: 'balance-changed', + remainingBefore: 2, + remainingAfter: 1, + }), + ); + expect(jan3?.candidates.some((candidate) => candidate.status === 'chosen')).toBe(true); + expect(jan3?.candidates[0]).toMatchObject({ + technicalComparison: [ + { + name: 'preference-date-conflict', + direction: 'minimize', + before: 0, + after: 0, + }, + { + name: 'placement-date', + direction: 'minimize', + before: '2024-01-03', + after: '2024-01-04', + }, + ], + firstDecisiveComparator: 'placement-date', + }); + expect(jan3?.deltas).toContainEqual( + expect.objectContaining({ + kind: 'balance-changed', + remainingBefore: 1, + remainingAfter: 0, + }), + ); + for (const [date, decision] of [ + ['2024-01-02', jan2], + ['2024-01-03', jan3], + ] as const) { + expect( + decision?.candidates.filter( + (candidate) => candidate.status === 'chosen' && candidate.date === date, + ), + ).toHaveLength(1); + expect( + decision?.deltas.filter( + (delta) => delta.kind === 'placement-added' && delta.date === date, + ), + ).toHaveLength(1); + } + const selected = jan3?.deltas.find( + (delta) => delta.kind === 'placement-added' && delta.date === '2024-01-03', + )?.placement; + expect(selected).toBeDefined(); + if (!jan3 || !selected) return; + const analysis = await calculation.analyzeCounterfactual({ + decisionId: jan3.id, + placement: selected, + }); + expect(analysis.kind).toBe('Counterfactual'); + if (analysis.kind === 'Counterfactual') + expect(analysis.results.find((item) => item.date === '2024-01-02')).toMatchObject({ + status: 'ineligible', + reason: { code: 'occupied' }, + }); + }); + + it('puts actual obligation validity first in stable plan explanations', async () => { + const calculation = createPlanningCalculation({ + horizon: { start: '2024-01-01', end: '2024-01-08' }, + balances: [balance('cp', 'cp', 1, '2025-01-01')], + rules: [ + { + id: 'mandatory', + type: 'time_window', + force: 'obligation', + startDate: '2024-01-03', + endDate: '2024-01-03', + placeDays: 1, + placeType: 'cp', + }, + ], + }); + const outcome = await calculation.outcome; + const explanation = await calculation.explanation; + expect(outcome.kind).toBe('Plan'); + expect(explanation?.kind).toBe('PlanExplanation'); + if (explanation?.kind !== 'PlanExplanation') return; + + const placement = explanation.placements[0]; + expect(placement.objectiveVector.objectives[0]).toMatchObject({ + id: 'obligation-validity', + direction: 'minimize', + value: 0, + }); + expect(placement.comparison.before.objectives[0]).toMatchObject({ + id: 'obligation-validity', + value: 1, + }); + expect(placement.comparison.after.objectives[0]).toMatchObject({ + id: 'obligation-validity', + value: 0, + }); + expect(placement.firstDecisiveObjective).toBe('obligation-validity'); + }); + + it('records the balance order used for a shared ANY preference placement', async () => { + const calculation = createPlanningCalculation({ + horizon: { start: '2024-01-01', end: '2024-01-08' }, + balances: [ + balance('cp', 'cp', 1, '2025-01-04'), + balance('rtt-first', 'rtt', 1, '2025-01-05'), + balance('rtt-second', 'rtt', 1, '2025-01-06'), + ], + rules: [ + { + id: 'any-preference', + type: 'time_window', + force: 'preference', + startDate: '2024-01-03', + endDate: '2024-01-03', + placeDays: 1, + placeType: 'any', + }, + { + id: 'rtt-preference', + type: 'time_window', + force: 'preference', + startDate: '2024-01-03', + endDate: '2024-01-03', + placeDays: 1, + placeType: 'rtt', + }, + ], + }); + const outcome = await calculation.outcome; + const trace = await calculation.trace; + expect(outcome.kind).toBe('Plan'); + if (outcome.kind !== 'Plan' || !trace) return; + + const sharedPlacement = outcome.placements.find((placement) => + placement.ruleIds.includes('any-preference'), + ); + expect(sharedPlacement).toMatchObject({ + date: '2024-01-03', + balanceId: 'rtt-first', + ruleIds: ['any-preference', 'rtt-preference'], + }); + const candidate = trace.phases + .find((phase) => phase.name === 'preference:any-preference') + ?.decisions.flatMap((decision) => decision.candidates) + .find((item) => item.status === 'chosen' && item.date === '2024-01-03'); + expect(candidate).toMatchObject({ + technicalComparison: [ + { + name: 'shared-placement-type', + direction: 'maximize', + before: 0, + after: 0, + }, + { + name: 'protected-preference-type', + direction: 'maximize', + before: 1, + after: 1, + }, + { + name: 'balance-expiry', + direction: 'minimize', + before: '2025-01-05', + after: '2025-01-06', + }, + ], + firstDecisiveComparator: 'balance-expiry', + placement: expect.objectContaining({ balanceId: 'rtt-first' }), + }); + }); + + it('records actual IfThen leaf comparisons and selected branch identity', async () => { + const calculation = createPlanningCalculation({ + horizon: { start: '2024-01-01', end: '2025-01-01' }, + balances: [balance('cp', 'cp', 2, '2024-12-31')], + rules: [ + { + id: 'if-then-preference', + type: 'if_then', + force: 'preference', + dayOfWeek: 3, + placeDays: 1, + placeType: 'cp', + placement: 'auto', + rewardDays: 0, + }, + ], + }); + const outcome = await calculation.outcome; + const trace = await calculation.trace; + expect(outcome.kind).toBe('Plan'); + if (outcome.kind !== 'Plan' || !trace) return; + + const candidates = trace.phases + .flatMap((phase) => phase.decisions) + .flatMap((decision) => + decision.candidates + .filter((candidate) => + candidate.technicalComparison?.some((item) => + item.name.startsWith('complete-occurrences:if-then-preference'), + ), + ) + .map((candidate) => ({ candidate, decision })), + ); + const chosen = candidates.find(({ candidate }) => candidate.status === 'chosen'); + const preferenceLeaves = + trace.phases + .find((phase) => phase.name === 'preference:if-then-preference') + ?.decisions.flatMap((decision) => decision.candidates) + .filter((candidate) => + candidate.technicalComparison?.some((item) => + item.name.startsWith('complete-occurrences:if-then-preference'), + ), + ) ?? []; + expect(preferenceLeaves.length).toBeGreaterThan(1); + expect(preferenceLeaves.some((candidate) => candidate.status === 'chosen')).toBe(true); + expect( + preferenceLeaves.some((candidate) => candidate.status === 'evaluated-rejected'), + ).toBe(true); + expect(candidates.some(({ candidate }) => candidate.status === 'evaluated-rejected')).toBe( + true, + ); + expect(chosen?.candidate.technicalComparison).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'complete-occurrences:if-then-preference', + direction: 'maximize', + }), + expect.objectContaining({ + name: 'future-pattern-conflict-score:if-then-preference', + direction: 'minimize', + }), + ]), + ); + expect(chosen?.decision.branch?.deltas.length).toBeGreaterThan(0); + expect( + chosen?.candidate.dates?.every((date) => + outcome.placements.some((p) => p.date === date), + ), + ).toBe(true); + }); + + it('records earned obligation branches with the activated entitlement and its consumer', async () => { + const calculation = createPlanningCalculation({ + horizon: { start: '2024-04-25', end: '2024-05-08' }, + balances: [ + balance('cp', 'cp', 1, '2024-12-31'), + balance('rtt', 'rtt', 0, '2026-01-01'), + ], + rules: [ + { + id: 'producer', + type: 'if_then', + force: 'obligation', + dayOfWeek: 3, + placeDays: 1, + placeType: 'cp', + placement: 'after', + rewardDays: 1, + rewardType: 'rtt', + }, + { + id: 'consumer', + type: 'time_window', + force: 'obligation', + startDate: '2024-05-07', + endDate: '2024-05-07', + placeDays: 1, + placeType: 'rtt', + }, + ], + }); + const outcome = await calculation.outcome; + const trace = await calculation.trace; + expect(outcome.kind).toBe('Plan'); + if (outcome.kind !== 'Plan' || !trace) return; + + const earned = outcome.placements.find((placement) => + placement.balanceId.startsWith('earned:'), + ); + expect(earned?.ruleIds).toContain('consumer'); + const decisions = trace.phases.flatMap((phase) => phase.decisions); + const activation = decisions + .flatMap((decision) => decision.deltas) + .find( + (delta) => + delta.kind === 'earned-activated' && delta.balanceId === earned?.balanceId, + ); + expect(activation?.activation).toMatchObject({ ruleId: 'producer', dates: ['2024-05-02'] }); + const branch = decisions + .flatMap((decision) => decision.candidates) + .find( + (candidate) => + candidate.status === 'chosen' && candidate.dates?.includes(earned?.date ?? ''), + ); + expect(branch).toBeDefined(); + }); + + it('rejects a producer move that delays its earned entitlement past the consumer', async () => { + const calculation = createPlanningCalculation({ + horizon: { start: '2024-04-25', end: '2024-05-08' }, + balances: [ + balance('cp', 'cp', 1, '2025-12-31'), + balance('rtt', 'rtt', 0, '2026-01-01'), + ], + rules: [ + { + id: 'producer', + type: 'if_then', + force: 'obligation', + dayOfWeek: 3, + placeDays: 1, + placeType: 'cp', + placement: 'auto', + rewardDays: 1, + rewardType: 'rtt', + }, + { + id: 'consumer', + type: 'time_window', + force: 'obligation', + startDate: '2024-05-03', + endDate: '2024-05-03', + placeDays: 1, + placeType: 'rtt', + }, + ], + }); + const outcome = await calculation.outcome; + expect(outcome.kind).toBe('Plan'); + if (outcome.kind !== 'Plan') return; + const producer = outcome.placements.find((placement) => + placement.ruleIds.includes('producer'), + ); + expect(producer?.date).toBe('2024-04-30'); + if (!producer) return; + + const analysis = await calculation.analyzeCounterfactual(producer); + expect(analysis.kind).toBe('Counterfactual'); + if (analysis.kind !== 'Counterfactual') return; + expect(analysis.results.find((result) => result.date === '2024-05-02')).toMatchObject({ + status: 'ineligible', + reason: { code: 'unavailable' }, + }); + expect( + analysis.results.find((result) => result.date === '2024-05-02')?.comparison, + ).toBeUndefined(); + }); + + it('rejects an at-risk move that would delay already-earned leave past its consumer', async () => { + const calculation = createPlanningCalculation({ + horizon: { start: '2024-04-25', end: '2024-05-08' }, + balances: [ + balance('cp1', 'cp', 1, '2025-12-31'), + balance('cp2', 'cp', 2, '2024-05-07'), + balance('rtt', 'rtt', 0, '2026-01-01'), + ], + rules: [ + { + id: 'anchor', + type: 'time_window', + force: 'obligation', + startDate: '2024-04-25', + endDate: '2024-04-25', + placeDays: 1, + placeType: 'cp', + }, + { + id: 'producer', + type: 'if_then', + force: 'obligation', + dayOfWeek: 3, + placeDays: 1, + placeType: 'cp', + placement: 'after', + rewardDays: 1, + rewardType: 'rtt', + }, + { + id: 'consumer', + type: 'time_window', + force: 'obligation', + startDate: '2024-05-06', + endDate: '2024-05-06', + placeDays: 1, + placeType: 'rtt', + }, + ], + }); + const outcome = await calculation.outcome; + expect(outcome.kind).toBe('Plan'); + if (outcome.kind !== 'Plan') return; + const atRisk = outcome.placements.find( + (placement) => placement.balanceId === 'cp2' && !placement.ruleIds.length, + ); + expect(atRisk?.date).toBeDefined(); + if (!atRisk) return; + + const analysis = await calculation.analyzeCounterfactual(atRisk); + expect(analysis.kind).toBe('Counterfactual'); + if (analysis.kind !== 'Counterfactual') return; + expect(analysis.results.find((result) => result.date === '2024-05-03')).toMatchObject({ + status: 'ineligible', + reason: { code: 'unavailable' }, + }); + expect( + analysis.results.find((result) => result.date === '2024-05-03')?.comparison, + ).toBeUndefined(); + }); + + it('records earned balance availability metadata changes when a shared period extends', async () => { + const calculation = createPlanningCalculation({ + horizon: { start: '2024-04-25', end: '2024-05-15' }, + balances: [ + balance('cp', 'cp', 3, '2024-12-31'), + balance('rtt', 'rtt', 0, '2026-01-01'), + ], + rules: [ + { + id: 'producer', + type: 'if_then', + force: 'obligation', + dayOfWeek: 3, + placeDays: 1, + placeType: 'cp', + placement: 'after', + rewardDays: 1, + rewardType: 'rtt', + }, + { + id: 'cp-preference', + type: 'time_window', + force: 'preference', + startDate: '2024-05-03', + endDate: '2024-05-03', + placeDays: 1, + placeType: 'cp', + }, + { + id: 'rtt-preference', + type: 'time_window', + force: 'preference', + startDate: '2024-05-07', + endDate: '2024-05-07', + placeDays: 1, + placeType: 'rtt', + }, + ], + }); + const outcome = await calculation.outcome; + const trace = await calculation.trace; + expect(outcome.kind).toBe('Plan'); + if (outcome.kind !== 'Plan' || !trace) return; + + const earnedBalanceId = outcome.placements.find((placement) => + placement.ruleIds.includes('rtt-preference'), + )?.balanceId; + expect(earnedBalanceId).toBeDefined(); + const changes = trace.phases + .flatMap((phase) => phase.decisions) + .flatMap((decision) => decision.deltas) + .filter( + (delta) => delta.kind === 'balance-changed' && delta.balanceId === earnedBalanceId, + ); + expect(changes).toContainEqual( + expect.objectContaining({ + remainingBefore: 1, + remainingAfter: 1, + balance: expect.objectContaining({ + availableFrom: '2024-05-06', + expiryDate: '2025-05-06', + }), + }), + ); + }); + + it('compares every horizon date while preserving the selected placement provenance', async () => { + const calculation = createPlanningCalculation({ + horizon: { start: '2024-12-27', end: '2025-01-07' }, + balances: [balance('cp', 'cp', 2, '2025-01-03', '2024-12-30')], + rules: [ + { + id: 'exact-date', + type: 'time_window', + force: 'preference', + startDate: '2024-12-30', + endDate: '2024-12-30', + placeDays: 1, + placeType: 'cp', + }, + ], + }); + const outcome = await calculation.outcome; + expect(outcome.kind).toBe('Plan'); + if (outcome.kind !== 'Plan') return; + const selected = outcome.placements.find((placement) => + placement.ruleIds.includes('exact-date'), + ); + expect(selected).toBeDefined(); + if (!selected) return; + const before = structuredClone(outcome); + const analysis = await calculation.analyzeCounterfactual(selected); + + expect(analysis.kind).toBe('Counterfactual'); + if (analysis.kind !== 'Counterfactual') return; + expect(analysis.results.map(({ date }) => date)).toEqual([ + '2024-12-27', + '2024-12-28', + '2024-12-29', + '2024-12-30', + '2024-12-31', + '2025-01-01', + '2025-01-02', + '2025-01-03', + '2025-01-04', + '2025-01-05', + '2025-01-06', + ]); + expect(analysis.placement).toEqual(selected); + expect(analysis.results.find((item) => item.date === '2024-12-27')?.reason?.code).toBe( + 'unavailable', + ); + expect(analysis.results.find((item) => item.date === '2025-01-01')?.reason?.code).toBe( + 'non-working', + ); + expect(analysis.results.find((item) => item.date === '2025-01-06')?.reason?.code).toBe( + 'expired', + ); + for (const item of analysis.results.filter((result) => result.status === 'ineligible')) + expect(item.comparison).toBeUndefined(); + expect(outcome).toEqual(before); + }); + + it('keeps typed, joint-date, and earned-order conflicts separate from invalid input', async () => { + const scenarios = [ + { + horizon: { start: '2024-04-01', end: '2024-04-06' }, + balances: [balance('rtt', 'rtt', 1, '2025-01-01')], + rules: [ + { + id: 'cp-only', + type: 'time_window' as const, + force: 'obligation' as const, + startDate: '2024-04-02', + endDate: '2024-04-02', + placeDays: 1, + placeType: 'cp', + }, + ], + }, + { + horizon: { start: '2024-04-01', end: '2024-04-06' }, + balances: [ + balance('cp', 'cp', 1, '2025-01-01'), + balance('rtt', 'rtt', 1, '2025-01-01'), + ], + rules: ['cp', 'rtt'].map((type) => ({ + id: `${type}-same-day`, + type: 'time_window' as const, + force: 'obligation' as const, + startDate: '2024-04-02', + endDate: '2024-04-02', + placeDays: 1, + placeType: type, + })), + }, + { + horizon: { start: '2024-04-25', end: '2024-05-15' }, + balances: [ + balance('cp', 'cp', 2, '2025-01-01'), + balance('rtt', 'rtt', 3, '2026-01-01'), + ], + rules: [ + { + id: 'producer', + type: 'if_then' as const, + force: 'obligation' as const, + dayOfWeek: 3, + placeDays: 1, + placeType: 'cp', + placement: 'before', + rewardDays: 1, + rewardType: 'rtt', + }, + { + id: 'consumer', + type: 'if_then' as const, + force: 'obligation' as const, + dayOfWeek: 4, + placeDays: 4, + placeType: 'rtt', + placement: 'before', + rewardDays: 0, + }, + ], + }, + ]; + const conflictCodes = [ + 'typed-capacity', + 'joint-date-conflict', + 'earned-entitlement-bottleneck', + ]; + for (const [index, scenario] of scenarios.entries()) { + const calculation = createPlanningCalculation(scenario); + const outcome = await calculation.outcome; + expect(outcome.kind).toBe('PlanningConflict'); + if (outcome.kind !== 'PlanningConflict') continue; + expect(outcome.reasons[0].code).toBe(conflictCodes[index]); + expect((await calculation.explanation)?.kind).toBe('ConflictExplanation'); + const trace = await calculation.trace; + expect(trace?.phases.some((phase) => phase.name === 'obligations')).toBe(true); + expect(trace?.phases.some((phase) => phase.name === 'holiday-week-value')).toBe(false); + if (index === 1) + expect( + trace?.phases + .find((phase) => phase.name === 'obligations') + ?.decisions.flatMap((decision) => decision.candidates).length, + ).toBeGreaterThan(0); + if (index === 2) + expect( + trace?.phases + .find((phase) => phase.name === 'obligations') + ?.decisions.some((decision) => + decision.candidates.some((candidate) => candidate.dates?.length), + ), + ).toBe(true); + } + + const invalid = createPlanningCalculation({ + horizon: { start: '2025-01-01', end: '2024-01-01' }, + balances: [], + rules: [], + }); + expect((await invalid.outcome).kind).toBe('InvalidInput'); + expect(await invalid.explanation).toBeUndefined(); + expect(await invalid.trace).toBeUndefined(); + }); + + it('does not classify a joint-date conflict as earned when only a preference generates leave', async () => { + const calculation = createPlanningCalculation({ + horizon: { start: '2024-04-01', end: '2024-04-06' }, + balances: [ + balance('cp', 'cp', 1, '2025-01-01'), + balance('rtt', 'rtt', 1, '2025-01-01'), + ], + rules: [ + ...(['cp', 'rtt'] as const).map((type) => ({ + id: `${type}-required`, + type: 'time_window' as const, + force: 'obligation' as const, + startDate: '2024-04-02', + endDate: '2024-04-02', + placeDays: 1, + placeType: type, + })), + { + id: 'unrelated-reward-preference', + type: 'if_then', + force: 'preference', + dayOfWeek: 3, + placeDays: 1, + placeType: 'cp', + placement: 'auto', + rewardDays: 1, + rewardType: 'rtt', + }, + ], + }); + const outcome = await calculation.outcome; + expect(outcome.kind).toBe('PlanningConflict'); + if (outcome.kind === 'PlanningConflict') + expect(outcome.reasons[0].code).toBe('joint-date-conflict'); + }); + + it('recovers an incumbent explanation from the exact delivered checkpoint state', async () => { + const core = await import('./leave-planner.core'); + let checkpointPlan: import('./leave-planner.core').PlanOutcome | undefined; + let checkpointState: import('./leave-planner.core').PlanningTraceState | undefined; + const request = { + horizon: { start: '2024-01-01', end: '2024-01-10' }, + balances: [balance('cp', 'cp', 2, '2024-01-31')], + rules: [], + progress: (stage: string) => { + if (stage === 'holiday-week-value') throw new Error('interrupted after checkpoint'); + }, + }; + const outcome = core.calculatePlanningInternal(request, (plan, state) => { + checkpointPlan = plan; + checkpointState = state; + }); + expect(outcome.kind).toBe('PlanningIncomplete'); + expect(checkpointPlan).toBeDefined(); + expect(checkpointState).toBeDefined(); + if (!checkpointPlan || !checkpointState) return; + + const recovered = core.explainIncompleteCheckpoint( + request, + checkpointPlan, + checkpointState, + { code: 'worker-error', message: 'The worker stopped after its checkpoint.' }, + ); + expect(recovered.explanation?.kind).toBe('PlanExplanation'); + if (recovered.explanation?.kind !== 'PlanExplanation') return; + expect(recovered.explanation.incomplete?.reason.code).toBe('worker-error'); + expect(recovered.explanation.placements).toHaveLength(checkpointPlan.placements.length); + + let completedState: import('./leave-planner.core').PlanningTraceState | undefined; + const completed = core.calculatePlanningInternal( + { ...request, progress: undefined }, + (plan, state) => { + if (plan.completedStages.at(-1) === 'dates') completedState = state; + }, + ); + expect(completed.kind).toBe('Plan'); + if (completed.kind !== 'Plan' || !completedState) return; + const completedRecovery = core.explainIncompleteCheckpoint( + { ...request, progress: undefined }, + completed, + completedState, + { code: 'worker-error', message: 'The result arrived before the worker stopped.' }, + ); + expect(completedRecovery.explanation?.kind).toBe('PlanExplanation'); + if (completedRecovery.explanation?.kind === 'PlanExplanation') + expect(completedRecovery.explanation.incomplete).toBeUndefined(); + }); + + it('does not charge a deliberately slow incremental trace consumer to planning work', async () => { + const core = await import('./leave-planner.core'); + let clock = 0; + vi.stubGlobal('performance', { now: () => ++clock }); + const request = { + horizon: { start: '2024-01-01', end: '2024-01-15' }, + balances: [balance('cp', 'cp', 3, '2024-01-31')], + rules: [], + deadlineMs: 1_000, + }; + const plain = core.calculatePlanningInternal(request); + clock = 0; + let callbackCount = 0; + let artifacts: import('./leave-planner.core').PlanningArtifacts | undefined; + const operations: import('./leave-planner.core').PlanningTraceOperation[] = []; + const traced = core.calculatePlanningInternal( + { + ...request, + traceProgress: ( + _totalMs: number, + update: import('./leave-planner.core').PlanningTraceUpdate, + ) => { + callbackCount++; + clock += 10_000; + operations.push(...update.operations); + }, + }, + undefined, + (value) => (artifacts = value), + ); + expect(traced).toEqual(plain); + expect(callbackCount).toBeGreaterThan(0); + expect(artifacts?.trace?.status).toBe('ready'); + expect(artifacts?.timings?.planningWorkMs).toBeLessThan(request.deadlineMs); + expect(artifacts?.timings?.traceTransferMs).toBeGreaterThanOrEqual(callbackCount * 10_000); + const decisions = artifacts?.trace?.phases.flatMap((phase) => phase.decisions) ?? []; + const candidateUpdates = operations.filter((operation) => operation.kind === 'candidate'); + expect(candidateUpdates.some((operation) => operation.mode === 'replace')).toBe(true); + const streamedCandidates = new Map(); + for (const operation of candidateUpdates) + if (operation.kind === 'candidate') + streamedCandidates.set( + `${operation.phaseId}/${operation.decisionId}/${operation.index}`, + operation.candidate, + ); + expect(streamedCandidates.size).toBe( + decisions.flatMap((decision) => decision.candidates).length, + ); + expect(operations.filter((operation) => operation.kind === 'delta')).toHaveLength( + decisions.flatMap((decision) => decision.deltas).length, + ); + }); + + it('coalesces pending candidate snapshots without losing later flushed replacements', async () => { + const core = await import('./leave-planner.core'); + vi.stubGlobal('performance', { now: () => 0 }); + const request = { + horizon: { start: '2024-01-01', end: '2024-01-08' }, + balances: [balance('cp', 'cp', 2, '2024-01-07')], + rules: [], + }; + let plainArtifacts: import('./leave-planner.core').PlanningArtifacts | undefined; + const plain = core.calculatePlanningInternal(request, undefined, (value) => { + plainArtifacts = value; + }); + const operations: import('./leave-planner.core').PlanningTraceOperation[] = []; + let streamedArtifacts: import('./leave-planner.core').PlanningArtifacts | undefined; + const streamed = core.calculatePlanningInternal( + { + ...request, + traceProgress: ( + _totalMs: number, + update: import('./leave-planner.core').PlanningTraceUpdate, + ) => operations.push(...update.operations), + }, + undefined, + (value) => (streamedArtifacts = value), + ); + + expect(streamed).toEqual(plain); + expect(streamedArtifacts?.trace).toEqual(plainArtifacts?.trace); + const decisions = + streamedArtifacts?.trace?.phases.flatMap((phase) => phase.decisions) ?? []; + const candidates = decisions.flatMap((decision) => + decision.candidates.map((candidate, index) => ({ + key: `${decision.id}/${index}`, + candidate, + })), + ); + const candidateOperations = operations.filter( + (operation): operation is Extract => + operation.kind === 'candidate', + ); + expect(candidateOperations).toHaveLength(candidates.length); + const finalStreamedCandidates = new Map( + candidateOperations.map((operation) => [ + `${operation.decisionId}/${operation.index}`, + operation, + ]), + ); + for (const { key, candidate } of candidates) { + expect(finalStreamedCandidates.get(key)?.candidate).toEqual(candidate); + if (candidate.status === 'chosen') + expect(finalStreamedCandidates.get(key)?.mode).toBe('append'); + } + }); + + it('reports matching completed, interrupted, and not-run phases at both interruption boundaries', async () => { + const early = createPlanningCalculation({ + horizon: { start: '2024-01-01', end: '2024-01-10' }, + balances: [balance('cp', 'cp', 1, '2024-01-31')], + rules: [], + deadlineMs: 0, + }); + const earlyOutcome = await early.outcome; + const earlyTrace = await early.trace; + expect(earlyOutcome.kind).toBe('PlanningIncomplete'); + if (earlyOutcome.kind === 'PlanningIncomplete') + expect(earlyOutcome.completedStages).toEqual(['validation']); + expect(earlyTrace?.completedStages).toEqual(['validation']); + expect(earlyTrace?.phases.find((phase) => phase.name === 'validation')?.status).toBe( + 'completed', + ); + expect(earlyTrace?.phases.find((phase) => phase.name === 'obligations')?.status).toBe( + 'interrupted', + ); + expect(earlyTrace?.phases.find((phase) => phase.name === 'at-risk')?.status).toBe( + 'not-run', + ); + + const quality = createPlanningCalculation({ + horizon: { start: '2024-01-01', end: '2024-02-01' }, + balances: [balance('cp', 'cp', 1, '2024-01-31')], + rules: [], + progress: (stage) => { + if (stage === 'holiday-week-value') { + throw new Error('deliberate interruption in quality phase'); + } + }, + }); + const qualityOutcome = await quality.outcome; + const qualityTrace = await quality.trace; + expect(qualityOutcome.kind).toBe('PlanningIncomplete'); + if (qualityOutcome.kind !== 'PlanningIncomplete') return; + expect(qualityOutcome.reasons[0].code).toBe('planner-error'); + expect(qualityOutcome.completedStages).toEqual(['validation', 'obligations', 'at-risk']); + expect(qualityTrace?.completedStages).toEqual(qualityOutcome.completedStages); + expect( + qualityTrace?.phases.find((phase) => phase.name === 'holiday-week-value')?.status, + ).toBe('interrupted'); + expect( + qualityTrace?.phases.find((phase) => phase.name === 'non-working-day-gain')?.status, + ).toBe('not-run'); + }); +}); diff --git a/src/app/services/leave-planner-quality-trace.spec.ts b/src/app/services/leave-planner-quality-trace.spec.ts new file mode 100644 index 0000000..820408a --- /dev/null +++ b/src/app/services/leave-planner-quality-trace.spec.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPlanningCalculation } from './leave-planner.service'; + +beforeEach(() => vi.stubGlobal('window', undefined)); +afterEach(() => vi.unstubAllGlobals()); + +describe('quality calculation trace', () => { + it('separates source effects from the actual incumbent comparison', async () => { + const calculation = createPlanningCalculation({ + horizon: { start: '2024-01-01', end: '2024-02-01' }, + balances: [ + { + id: 'cp', + type: 'cp', + quantity: 2, + availableFrom: '2024-01-01', + expiryDate: '2024-01-31', + }, + ], + rules: [], + }); + const outcome = await calculation.outcome; + const trace = await calculation.trace; + expect(outcome.kind).toBe('Plan'); + expect(trace?.status).toBe('ready'); + if (outcome.kind !== 'Plan' || !trace) return; + + const phase = trace.phases.find((item) => item.name === 'non-working-day-gain'); + const jan3Decision = phase?.decisions.find((decision) => + decision.candidates.some((candidate) => candidate.sourceDate === '2024-01-03'), + ); + const jan2Decision = phase?.decisions.find((decision) => + decision.candidates.some((candidate) => candidate.sourceDate === '2024-01-02'), + ); + const jan5 = jan3Decision?.candidates.find( + (candidate) => candidate.sourceDate === '2024-01-03' && candidate.date === '2024-01-05', + ); + const jan8 = jan3Decision?.candidates.find( + (candidate) => candidate.sourceDate === '2024-01-03' && candidate.date === '2024-01-08', + ); + + expect(jan2Decision).toBeDefined(); + expect(jan3Decision).toBeDefined(); + expect(jan3Decision).not.toBe(jan2Decision); + expect(jan3Decision?.dates).toEqual(['2024-01-03', '2024-01-05']); + expect(jan5?.status).toBe('chosen'); + expect(jan8?.status).toBe('evaluated-rejected'); + expect(jan3Decision?.deltas).toContainEqual( + expect.objectContaining({ + kind: 'placement-moved', + fromDate: '2024-01-03', + toDate: '2024-01-05', + }), + ); + + const sourceValue = ( + candidate: typeof jan5, + state: 'before' | 'after', + objective: string, + ) => candidate?.comparison?.[state].objectives.find((item) => item.id === objective)?.value; + expect(sourceValue(jan5, 'before', 'non-working-day-gain')).toBe(3); + expect(sourceValue(jan5, 'after', 'non-working-day-gain')).toBe(5); + expect(sourceValue(jan5, 'before', 'longest-work-sequence')).toBe(20); + expect(sourceValue(jan5, 'after', 'longest-work-sequence')).toBe(18); + expect(sourceValue(jan8, 'before', 'non-working-day-gain')).toBe(3); + expect(sourceValue(jan8, 'after', 'non-working-day-gain')).toBe(5); + expect(sourceValue(jan8, 'before', 'longest-work-sequence')).toBe(20); + expect(sourceValue(jan8, 'after', 'longest-work-sequence')).toBe(17); + expect(jan8?.comparison?.firstDecisiveObjective).toBe('non-working-day-gain'); + + expect(jan8?.technicalComparison).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'non-working-day-gain', + direction: 'maximize', + before: 5, + after: 5, + }), + ]), + ); + expect( + jan8?.technicalComparison?.some((item) => item.name === 'longest-work-sequence'), + ).toBe(false); + expect(jan8?.firstDecisiveComparator).toBeUndefined(); + expect(jan8?.reason?.message).toContain('did not improve the current best result'); + }); +}); diff --git a/src/app/services/leave-planner.core.ts b/src/app/services/leave-planner.core.ts index 548cab2..44822ab 100644 --- a/src/app/services/leave-planner.core.ts +++ b/src/app/services/leave-planner.core.ts @@ -121,6 +121,7 @@ export interface PreferenceResult { } export type PlanningObjectiveId = + | 'obligation-validity' | 'unplaced-at-risk' | `preference:${string}` | 'holiday-week-value' @@ -274,6 +275,14 @@ export interface PlanningTraceCandidate { dates?: readonly string[]; reason?: PlanningReason; comparison?: PlanningObjectiveComparison; + /** Raw non-objective comparator values used by search branches. */ + technicalComparison?: readonly Readonly<{ + name: string; + direction: 'minimize' | 'maximize'; + before: number | string; + after: number | string; + }>[]; + firstDecisiveComparator?: string; placement?: LeavePlacement; } @@ -283,6 +292,11 @@ export interface PlanningTraceDecision { dates: readonly string[]; candidates: readonly PlanningTraceCandidate[]; deltas: readonly PlanningTraceDelta[]; + /** Local transition for an explored alternative branch, separate from committed history. */ + branch?: Readonly<{ + parentDecisionId?: string; + deltas: readonly PlanningTraceDelta[]; + }>; reason?: PlanningReason; } @@ -299,9 +313,52 @@ export interface PlanningTrace { initialState: PlanningTraceState; phases: readonly PlanningTracePhase[]; completedStages: readonly string[]; + nonWorkingDates?: readonly Readonly<{ date: string; reason: PlanningReason }>[]; error?: PlanningReason; } +export type PlanningTraceOperation = + | { + kind: 'phase'; + phase: Pick; + } + | { + kind: 'decision'; + phaseId: string; + decision: Pick; + } + | { + kind: 'candidate'; + phaseId: string; + decisionId: string; + index: number; + mode: 'append' | 'replace'; + candidate: PlanningTraceCandidate; + } + | { + kind: 'delta'; + phaseId: string; + decisionId: string; + index: number; + delta: PlanningTraceDelta; + }; + +/** Ordered incremental trace updates; decision and candidate indices are stable within a phase. */ +export interface PlanningTraceUpdate { + initialState?: PlanningTraceState; + nonWorkingDates?: readonly Readonly<{ date: string; reason: PlanningReason }>[]; + operations: readonly PlanningTraceOperation[]; +} + +export type PlanningTraceProgressCallback = (totalMs: number, update: PlanningTraceUpdate) => void; + +export interface PlanningArtifactTimings { + planningWorkMs: number; + traceCaptureMs: number; + traceMaterializationMs: number; + traceTransferMs: number; +} + export type CounterfactualReasonCode = | 'non-working' | 'occupied' @@ -314,10 +371,16 @@ export interface CounterfactualDateResult { date: string; status: 'eligible' | 'ineligible'; historicalStatus?: TraceCandidateStatus; + historicalReason?: PlanningReason & { code: CounterfactualReasonCode }; reason?: PlanningReason & { code: CounterfactualReasonCode }; comparison?: PlanningObjectiveComparison; } +export interface HistoricalCounterfactualReason { + date: string; + reason: PlanningReason & { code: CounterfactualReasonCode }; +} + export interface CounterfactualAnalysis { kind: 'Counterfactual'; calculationId: string; @@ -486,6 +549,8 @@ interface Requirement { candidates?: string[]; } +const obligationRequirementCache = new WeakMap(); + type MutableTraceCandidate = PlanningTraceCandidate & { candidates?: never }; type MutableTraceDecision = { id: string; @@ -493,6 +558,7 @@ type MutableTraceDecision = { dates: string[]; candidates: MutableTraceCandidate[]; deltas: PlanningTraceDelta[]; + branch?: { parentDecisionId?: string; deltas: PlanningTraceDelta[] }; reason?: PlanningReason; }; type MutableTracePhase = { @@ -510,23 +576,37 @@ type MutableTracePhase = { */ class TraceRecorder { private readonly phases: MutableTracePhase[] = []; + private readonly nonWorkingDates: readonly Readonly<{ date: string; reason: PlanningReason }>[]; private phase: MutableTracePhase | undefined; private sequence = 0; - private readonly evaluationDecisions = new WeakMap< + private readonly evaluationLocations = new WeakMap< MutableTraceCandidate, - MutableTraceDecision + { phase: MutableTracePhase; decision: MutableTraceDecision; index: number } >(); + private pendingOperations: PlanningTraceOperation[] = []; + private readonly pendingCandidateOperations = new Map< + PlanningTraceCandidate, + Extract + >(); + private initialStateSent = false; private previousPlacements = new Map(); private previousEarned = new Set(); - private previousBalances: ReadonlyMap; + private previousBalances: ReadonlyMap; constructor( private readonly initialState: PlanningTraceState, private readonly plannedPhases: readonly string[], private readonly workingDays: ReadonlySet, + private readonly streaming: boolean, ) { + this.nonWorkingDates = dateRange(initialState.horizon.start, initialState.horizon.end) + .filter((date) => !workingDays.has(date)) + .map((date) => ({ + date, + reason: reason('non-working', 'The date is a weekend or public holiday.'), + })); this.previousBalances = new Map( - initialState.balances.map((balance) => [balance.id, balance.remaining]), + initialState.balances.map((balance) => [balance.id, { ...balance }]), ); } @@ -535,6 +615,7 @@ class TraceRecorder { if (this.phase) { this.phase.completed = true; this.phase.status = 'completed'; + this.recordPhase(this.phase); } this.phase = { id: `phase:${this.phases.length + 1}`, @@ -544,6 +625,121 @@ class TraceRecorder { decisions: [], }; this.phases.push(this.phase); + this.recordPhase(this.phase); + } + + private recordPhase(phase: MutableTracePhase): void { + if (!this.streaming) return; + this.pendingOperations.push({ + kind: 'phase', + phase: { + id: phase.id, + name: phase.name, + completed: phase.completed, + status: phase.status, + }, + }); + } + + private recordDecisionUpdate(phase: MutableTracePhase, decision: MutableTraceDecision): void { + if (!this.streaming) return; + this.pendingOperations.push({ + kind: 'decision', + phaseId: phase.id, + decision: { + id: decision.id, + action: decision.action, + dates: [...decision.dates], + reason: decision.reason ? { ...decision.reason } : undefined, + branch: decision.branch + ? { + parentDecisionId: decision.branch.parentDecisionId, + deltas: decision.branch.deltas.map((delta) => ({ ...delta })), + } + : undefined, + }, + }); + } + + private recordCandidateUpdate( + phase: MutableTracePhase, + decision: MutableTraceDecision, + index: number, + mode: 'append' | 'replace', + candidate: PlanningTraceCandidate, + ): void { + if (!this.streaming) return; + const snapshot = { + ...candidate, + dates: candidate.dates ? [...candidate.dates] : undefined, + reason: candidate.reason ? { ...candidate.reason } : undefined, + technicalComparison: candidate.technicalComparison?.map((item) => ({ ...item })), + placement: candidate.placement + ? { ...candidate.placement, ruleIds: [...candidate.placement.ruleIds] } + : undefined, + }; + const pending = this.pendingCandidateOperations.get(candidate); + if (pending) { + pending.candidate = snapshot; + return; + } + const operation: Extract = { + kind: 'candidate', + phaseId: phase.id, + decisionId: decision.id, + index, + mode, + candidate: snapshot, + }; + this.pendingOperations.push(operation); + this.pendingCandidateOperations.set(candidate, operation); + } + + private recordDelta(decision: MutableTraceDecision, delta: PlanningTraceDelta): void { + const index = decision.deltas.push(delta) - 1; + if (!this.streaming) return; + this.pendingOperations.push({ + kind: 'delta', + phaseId: this.phase!.id, + decisionId: decision.id, + index, + delta: { ...delta }, + }); + } + + private recordNewCandidate( + decision: MutableTraceDecision, + candidate: MutableTraceCandidate, + ): number { + const index = decision.candidates.push(candidate) - 1; + this.recordCandidateUpdate(this.phase!, decision, index, 'append', candidate); + return index; + } + + drainUpdate(): PlanningTraceUpdate | undefined { + if (!this.pendingOperations.length && this.initialStateSent) return undefined; + const initialState = this.initialStateSent + ? undefined + : { + horizon: { ...this.initialState.horizon }, + balances: this.initialState.balances.map((balance) => ({ ...balance })), + placements: this.initialState.placements.map((placement) => ({ + ...placement, + ruleIds: [...placement.ruleIds], + })), + earnedActivations: this.initialState.earnedActivations.map((activation) => ({ + ...activation, + dates: [...activation.dates], + })), + }; + this.initialStateSent = true; + const operations = this.pendingOperations.splice(0); + this.pendingCandidateOperations.clear(); + return { + initialState, + nonWorkingDates: initialState ? this.nonWorkingDates : undefined, + operations, + }; } private decision( @@ -558,8 +754,10 @@ class TraceRecorder { dates: [...dates], candidates: [], deltas: [], + branch: undefined, }; current.decisions.push(decision); + this.recordDecisionUpdate(current, decision); return decision; } @@ -573,7 +771,7 @@ class TraceRecorder { private candidateDecision(dates: readonly string[] = []): MutableTraceDecision { const latest = this.latestDecision(); - return latest.deltas.length ? this.decision('choose', dates) : latest; + return latest.deltas.length || latest.branch ? this.decision('choose', dates) : latest; } private recordChosenCandidate(placement: LeavePlacement, decision: MutableTraceDecision): void { @@ -584,46 +782,101 @@ class TraceRecorder { candidate.placement.balanceId === placement.balanceId, ); if (!alreadyRecorded) - decision.candidates.push({ status: 'chosen', date: placement.date, placement }); + this.recordNewCandidate(decision, { + status: 'chosen', + date: placement.date, + placement, + }); } - recordCandidate(candidate: PlanningTraceCandidate, copy = true): void { + recordCandidate(candidate: PlanningTraceCandidate, copy = true): MutableTraceCandidate { const decision = this.candidateDecision( candidate.dates ?? (candidate.date ? [candidate.date] : []), ); - decision.candidates.push( - copy - ? { - ...candidate, - dates: candidate.dates ? [...candidate.dates] : undefined, - reason: candidate.reason ? { ...candidate.reason } : undefined, - placement: candidate.placement - ? { ...candidate.placement, ruleIds: [...candidate.placement.ruleIds] } - : undefined, - } - : candidate, - ); + const mutable = copy + ? { + ...candidate, + dates: candidate.dates ? [...candidate.dates] : undefined, + reason: candidate.reason ? { ...candidate.reason } : undefined, + technicalComparison: candidate.technicalComparison?.map((item) => ({ + ...item, + })), + placement: candidate.placement + ? { ...candidate.placement, ruleIds: [...candidate.placement.ruleIds] } + : undefined, + } + : (candidate as MutableTraceCandidate); + const index = this.recordNewCandidate(decision, mutable); + this.evaluationLocations.set(mutable, { phase: this.phase!, decision, index }); + return mutable; } recordDecision( action: MutableTraceDecision['action'], dates: readonly string[], candidate?: PlanningTraceCandidate, - ): void { + branch?: PlanningTraceDecision['branch'], + ): MutableTraceCandidate | undefined { const decision = this.decision(action, dates); - if (candidate) decision.candidates.push(candidate); + if (branch) { + decision.branch = { + parentDecisionId: branch.parentDecisionId, + deltas: branch.deltas.map((delta) => ({ ...delta })), + }; + this.recordDecisionUpdate(this.phase!, decision); + } + if (!candidate) return undefined; + const mutable = candidate as MutableTraceCandidate; + const index = this.recordNewCandidate(decision, mutable); + this.evaluationLocations.set(mutable, { phase: this.phase!, decision, index }); + return mutable; + } + + recordBranchTransition( + dates: readonly string[], + parentDecisionId: string | undefined, + deltas: readonly PlanningTraceDelta[], + candidate?: MutableTraceCandidate, + ): string { + const decision = candidate + ? (this.evaluationLocations.get(candidate)?.decision ?? this.decision('choose', dates)) + : this.decision('choose', dates); + decision.branch = { + parentDecisionId, + deltas: deltas.map((delta) => ({ ...delta })), + }; + this.recordDecisionUpdate(this.phase!, decision); + if (candidate) this.chooseEvaluation(candidate); + return decision.id; + } + + decisionIdFor(candidate: MutableTraceCandidate): string | undefined { + return this.evaluationLocations.get(candidate)?.decision.id; } recordEvaluation(candidate: MutableTraceCandidate): void { const decision = this.candidateDecision( candidate.dates ?? (candidate.date ? [candidate.date] : []), ); - decision.candidates.push(candidate); - this.evaluationDecisions.set(candidate, decision); + const index = this.recordNewCandidate(decision, candidate); + this.evaluationLocations.set(candidate, { phase: this.phase!, decision, index }); + } + + updateEvaluation(candidate: MutableTraceCandidate): void { + const location = this.evaluationLocations.get(candidate); + if (location) + this.recordCandidateUpdate( + location.phase, + location.decision, + location.index, + 'replace', + candidate, + ); } chooseEvaluation(candidate: MutableTraceCandidate): void { candidate.status = 'chosen'; + this.updateEvaluation(candidate); } recordPlan( @@ -643,9 +896,19 @@ class TraceRecorder { const previous = this.previousPlacements.get(date); return previous && JSON.stringify(previous) !== JSON.stringify(placement); }); - const balancesChanged = balances.some( - (balance) => this.previousBalances.get(balance.id) !== balance.remaining, - ); + const balancesChanged = balances.some((balance) => { + const previous = this.previousBalances.get(balance.id); + return ( + !previous || + previous.remaining !== balance.remaining || + previous.availableFrom !== balance.availableFrom || + previous.expiryDate !== balance.expiryDate || + previous.quantity !== balance.quantity || + previous.type !== balance.type || + previous.earned !== balance.earned || + previous.sourceRuleId !== balance.sourceRuleId + ); + }); const earnedChanged = balances.some( (balance) => balance.earned && !this.previousEarned.has(balance.id), ); @@ -673,10 +936,11 @@ class TraceRecorder { ...updated.map(([date]) => date), ]; const decision = - (acceptedCandidate && this.evaluationDecisions.get(acceptedCandidate)) || + (acceptedCandidate && this.evaluationLocations.get(acceptedCandidate)?.decision) || this.decision(action, dates); decision.action = action; decision.dates = dates; + this.recordDecisionUpdate(this.phase!, decision); const movedAdded = new Set(); const movedRemoved = new Set(); for (const previous of removed) { @@ -689,7 +953,7 @@ class TraceRecorder { if (!placement) continue; movedAdded.add(placement.date); movedRemoved.add(previous.date); - decision.deltas.push({ + this.recordDelta(decision, { kind: 'placement-moved', fromDate: previous.date, toDate: placement.date, @@ -701,7 +965,7 @@ class TraceRecorder { } for (const placement of removed) { if (movedRemoved.has(placement.date)) continue; - decision.deltas.push({ + this.recordDelta(decision, { kind: 'placement-removed', date: placement.date, balanceId: placement.balanceId, @@ -710,7 +974,7 @@ class TraceRecorder { } for (const placement of added) { if (movedAdded.has(placement.date)) continue; - decision.deltas.push({ + this.recordDelta(decision, { kind: 'placement-added', date: placement.date, balanceId: placement.balanceId, @@ -720,7 +984,7 @@ class TraceRecorder { } for (const [date, placement] of updated) { const previous = this.previousPlacements.get(date)!; - decision.deltas.push({ + this.recordDelta(decision, { kind: 'placement-updated', date, balanceId: placement.balanceId, @@ -730,18 +994,21 @@ class TraceRecorder { this.recordChosenCandidate(placement, decision); } for (const balance of balances) { - const previousRemaining = this.previousBalances.get(balance.id); - if (previousRemaining !== undefined && previousRemaining !== balance.remaining) - decision.deltas.push({ - kind: 'balance-changed', - balanceId: balance.id, - remainingBefore: previousRemaining, - remainingAfter: balance.remaining, - }); - if (previousRemaining === undefined) - decision.deltas.push({ + const previous = this.previousBalances.get(balance.id); + if ( + !previous || + previous.remaining !== balance.remaining || + previous.availableFrom !== balance.availableFrom || + previous.expiryDate !== balance.expiryDate || + previous.quantity !== balance.quantity || + previous.type !== balance.type || + previous.earned !== balance.earned || + previous.sourceRuleId !== balance.sourceRuleId + ) + this.recordDelta(decision, { kind: 'balance-changed', balanceId: balance.id, + remainingBefore: previous?.remaining, remainingAfter: balance.remaining, balance: { ...balance }, }); @@ -749,7 +1016,7 @@ class TraceRecorder { const activation = earnedActivations.find( (candidate) => candidate.id === balance.id, ); - decision.deltas.push({ + this.recordDelta(decision, { kind: 'earned-activated', balanceId: balance.id, activation: activation @@ -759,7 +1026,7 @@ class TraceRecorder { } } this.previousPlacements = current; - this.previousBalances = new Map(balances.map((balance) => [balance.id, balance.remaining])); + this.previousBalances = new Map(balances.map((balance) => [balance.id, { ...balance }])); this.previousEarned = new Set( balances.filter((balance) => balance.earned).map((balance) => balance.id), ); @@ -770,56 +1037,30 @@ class TraceRecorder { completedStages: readonly string[], error?: PlanningReason, ): PlanningTrace { - const seenDates = new Set( - this.phases.flatMap((phase) => - phase.decisions.flatMap((decision) => - decision.candidates.flatMap( - (candidate) => candidate.dates ?? (candidate.date ? [candidate.date] : []), - ), - ), - ), - ); - if (status !== 'failed') { - const decision = this.decision('reject'); - for (const date of dateRange( - this.initialState.horizon.start, - this.initialState.horizon.end, - )) { - if (seenDates.has(date)) continue; - decision.candidates.push( - this.workingDays.has(date) - ? { status: 'not-examined', date } - : { - status: 'ineligible', - date, - reason: reason( - 'non-working', - 'The date is a weekend or public holiday.', - ), - }, - ); - } - } if (this.phase && status === 'ready') { this.phase.completed = true; this.phase.status = 'completed'; + this.recordPhase(this.phase); } if (status === 'incomplete') { const existing = new Set(this.phases.map((phase) => phase.name)); for (const name of this.plannedPhases) { if (existing.has(name)) continue; - this.phases.push({ + const phase = { id: `phase:${this.phases.length + 1}`, name, completed: false, status: 'not-run', decisions: [], - }); + } satisfies MutableTracePhase; + this.phases.push(phase); + this.recordPhase(phase); } } return { status, initialState: this.initialState, + nonWorkingDates: this.nonWorkingDates, phases: this.phases.map((phase) => ({ ...phase, decisions: phase.decisions.map((decision) => ({ @@ -860,10 +1101,30 @@ interface SearchContext { anyPreferenceDemandByDate: Map; trace?: TraceRecorder; traceTime: number; + traceCaptureTime: number; + traceTransferTime: number; traceReportedTime: number; + observerTime: number; + obligationSolutionBranches: WeakMap; + traceBranchParentId?: string; + traceBranchActive?: boolean; traceError?: PlanningReason; } +interface ObligationBranchReference { + candidate: MutableTraceCandidate; + decisionId: string; + stateAfter: State; +} + +interface ObligationPreview { + obligationState: State; + state: State; + preferences: PreferenceResult[]; + metrics: PlanningMetrics; + previewBranch?: ObligationBranchReference; +} + function now(): number { return typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now(); } @@ -880,29 +1141,64 @@ function observeTrace(context: SearchContext, observe: (trace: TraceRecorder) => ); context.trace = undefined; } finally { - context.traceTime += now() - started; + const elapsed = now() - started; + context.traceTime += elapsed; + context.traceCaptureTime += elapsed; if ( context.trace && - context.traceTime - context.traceReportedTime >= 25 && + context.traceCaptureTime - context.traceReportedTime >= 25 && typeof context.request.traceProgress === 'function' ) { - try { - context.request.traceProgress(context.traceTime); - } catch (error) { - context.traceError = reason( - 'trace-error', - error instanceof Error ? error.message : String(error), - ); - context.trace = undefined; + const updateStarted = now(); + const update = context.trace.drainUpdate(); + const updateTime = now() - updateStarted; + context.traceTime += updateTime; + context.traceCaptureTime += updateTime; + if (update) { + const transferStarted = now(); + try { + (context.request.traceProgress as PlanningTraceProgressCallback)( + context.traceTime, + update, + ); + } catch (error) { + context.traceError = reason( + 'trace-error', + error instanceof Error ? error.message : String(error), + ); + context.trace = undefined; + } finally { + const transferTime = now() - transferStarted; + context.traceTime += transferTime; + context.traceTransferTime += transferTime; + context.traceReportedTime = context.traceCaptureTime; + } + } else { + context.traceReportedTime = context.traceCaptureTime; } - context.traceReportedTime = context.traceTime; } } } -function recordTraceCandidate(context: SearchContext, candidate: PlanningTraceCandidate): void { +function observeCallback(context: SearchContext, callback: () => void): void { + const started = now(); + try { + callback(); + } finally { + context.observerTime += now() - started; + } +} + +function recordTraceCandidate( + context: SearchContext, + candidate: PlanningTraceCandidate, +): MutableTraceCandidate | undefined { // These internal callsites pass fresh literals; retain the full snapshot copy for plan data. - observeTrace(context, (trace) => trace.recordCandidate(candidate, false)); + let recorded: MutableTraceCandidate | undefined; + observeTrace(context, (trace) => { + recorded = trace.recordCandidate(candidate, false); + }); + return recorded; } function recordTraceIneligible( @@ -1123,6 +1419,202 @@ function planningTraceState( }; } +function planningTraceDeltas( + before: State, + after: State, + dates: readonly string[], +): PlanningTraceDelta[] { + const deltas: PlanningTraceDelta[] = []; + const changedDates = new Set(dates); + for (const date of changedDates) { + const previous = before.placements.get(date); + const placement = after.placements.get(date); + if (!previous && placement) { + deltas.push({ + kind: 'placement-added', + date, + balanceId: placement.balanceId, + placement: { ...placement, ruleIds: [...placement.ruleIds].sort() }, + }); + } else if (previous && !placement) { + deltas.push({ + kind: 'placement-removed', + date, + balanceId: previous.balanceId, + previousPlacement: { + ...previous, + ruleIds: [...previous.ruleIds].sort(), + }, + }); + } else if ( + previous && + placement && + (JSON.stringify([...previous.ruleIds].sort()) !== + JSON.stringify([...placement.ruleIds].sort()) || + previous.balanceId !== placement.balanceId || + previous.type !== placement.type || + previous.supporting !== placement.supporting) + ) { + deltas.push({ + kind: 'placement-updated', + date, + balanceId: placement.balanceId, + previousPlacement: { + ...previous, + ruleIds: [...previous.ruleIds].sort(), + }, + placement: { ...placement, ruleIds: [...placement.ruleIds].sort() }, + }); + } + } + const previousBalances = new Map(before.balances.map((balance) => [balance.id, balance])); + for (const balance of after.balances) { + const previous = previousBalances.get(balance.id); + if ( + !previous || + previous.remaining !== balance.remaining || + previous.availableFrom !== balance.availableFrom || + previous.expiryDate !== balance.expiryDate || + previous.quantity !== balance.quantity || + previous.type !== balance.type || + previous.earned !== balance.earned || + previous.sourceRuleId !== balance.sourceRuleId + ) { + deltas.push({ + kind: 'balance-changed', + balanceId: balance.id, + remainingBefore: previous?.remaining, + remainingAfter: balance.remaining, + balance: { ...balance }, + }); + } + } + const previousActivations = new Set( + before.earnedActivations.map((activation) => activation.id), + ); + for (const activation of after.earnedActivations) { + if (previousActivations.has(activation.id)) continue; + deltas.push({ + kind: 'earned-activated', + balanceId: activation.id, + activation: { ...activation, dates: [...activation.dates] }, + }); + } + return deltas; +} + +function recordTraceBranchDecision( + context: SearchContext, + before: State, + after: State, + parentDecisionId: string | undefined, + dates: readonly string[] | (() => readonly string[]), + technicalComparison?: () => { + values: NonNullable; + firstDecisiveComparator?: string; + }, +): ObligationBranchReference | undefined { + let reference: ObligationBranchReference | undefined; + observeTrace(context, (trace) => { + const branchDates = typeof dates === 'function' ? dates() : dates; + const comparison = technicalComparison?.(); + const candidate: MutableTraceCandidate = { + status: 'evaluated-rejected', + dates: [...branchDates], + technicalComparison: comparison?.values, + firstDecisiveComparator: comparison?.firstDecisiveComparator, + }; + const recorded = trace.recordDecision('choose', branchDates, candidate, { + parentDecisionId, + deltas: planningTraceDeltas(before, after, branchDates), + }); + const decisionId = recorded && trace.decisionIdFor(recorded); + if (recorded && decisionId) + reference = { candidate: recorded, decisionId, stateAfter: after }; + }); + return reference; +} + +function recordTraceIfThenEvaluation( + context: SearchContext, + dates: readonly string[] | (() => readonly string[]), + technicalComparison: () => { + values: NonNullable; + firstDecisiveComparator?: string; + }, +): MutableTraceCandidate | undefined { + let evaluation: MutableTraceCandidate | undefined; + observeTrace(context, (trace) => { + const candidateDates = typeof dates === 'function' ? dates() : dates; + const comparison = technicalComparison(); + evaluation = trace.recordCandidate( + { + status: 'evaluated-rejected', + dates: [...candidateDates], + technicalComparison: comparison.values, + firstDecisiveComparator: comparison.firstDecisiveComparator, + }, + false, + ); + }); + return evaluation; +} + +function recordTraceBranchMutation( + context: SearchContext, + before: State, + after: State, + parentDecisionId: string | undefined, + candidate: MutableTraceCandidate | undefined, + dates: readonly string[], +): string | undefined { + let decisionId: string | undefined; + observeTrace(context, (trace) => { + decisionId = trace.recordBranchTransition( + dates, + parentDecisionId, + planningTraceDeltas(before, after, dates), + candidate, + ); + }); + return decisionId; +} + +function recordTraceAtRiskBranchMutation( + context: SearchContext, + after: State, + parentDecisionId: string | undefined, + candidate: MutableTraceCandidate | undefined, + date: string, + balanceId: string, + remainingBefore: number, +): string | undefined { + let decisionId: string | undefined; + observeTrace(context, (trace) => { + const placement = after.placements.get(date); + const balance = after.balances.find((item) => item.id === balanceId); + if (!placement || !balance) return; + const deltas: PlanningTraceDelta[] = [ + { + kind: 'placement-added', + date, + balanceId, + placement: { ...placement, ruleIds: [...placement.ruleIds].sort() }, + }, + ]; + if (remainingBefore !== balance.remaining) + deltas.push({ + kind: 'balance-changed', + balanceId, + remainingBefore, + remainingAfter: balance.remaining, + balance: { ...balance }, + }); + decisionId = trace.recordBranchTransition([date], parentDecisionId, deltas, candidate); + }); + return decisionId; +} + function recordTraceState( context: SearchContext, state: State, @@ -1558,6 +2050,10 @@ function buildRequirements( context: SearchContext, force: RuleForce, ): Requirement[] { + if (force === 'obligation' && rules === context.rules) { + const cached = obligationRequirementCache.get(context); + if (cached) return cached; + } const requirements: Requirement[] = []; let index = 0; for (const rule of rules) { @@ -1610,12 +2106,15 @@ function buildRequirements( // TIME_WINDOW consumer even when the window starts earlier; availability // checks still prevent an earned balance from funding an earlier date, while // this ordering keeps valid later chains visible to the joint search. - return requirements.sort( + const ordered = requirements.sort( (a, b) => Number(a.rule.type !== 'if_then') - Number(b.rule.type !== 'if_then') || compareDate(a.anchor, b.anchor) || a.index - b.index, ); + if (force === 'obligation' && rules === context.rules) + obligationRequirementCache.set(context, ordered); + return ordered; } function obligationBranchEstimate(requirements: Requirement[]): number { @@ -1653,8 +2152,12 @@ function assignDate( selector: LeaveSelector, ruleId: string, context: SearchContext, + selectedEvaluation?: (candidate: MutableTraceCandidate | undefined) => void, ): State | null { - return assignDateVariants(state, date, selector, ruleId, context, true)[0] ?? null; + return ( + assignDateVariants(state, date, selector, ruleId, context, true, selectedEvaluation)[0] ?? + null + ); } function assignDateVariants( @@ -1664,6 +2167,7 @@ function assignDateVariants( ruleId: string, context: SearchContext, firstOnly = false, + selectedEvaluation?: (candidate: MutableTraceCandidate | undefined) => void, ): State[] { context.check(); const existing = state.placements.get(date); @@ -1697,7 +2201,7 @@ function assignDateVariants( } const result = cloneState(state); if (ruleId) result.placements.get(date)!.ruleIds.add(ruleId); - recordTraceCandidate(context, { + const evaluation = recordTraceCandidate(context, { status: 'evaluated-rejected', date, placement: { @@ -1705,6 +2209,7 @@ function assignDateVariants( ruleIds: [...result.placements.get(date)!.ruleIds], }, }); + selectedEvaluation?.(evaluation); return [result]; } const balances = state.balances @@ -1748,6 +2253,13 @@ function assignDateVariants( }); if (firstOnly && balances.length) { const first = balances[0]; + const selectionFacts = firstCompatibleBalanceSelectionFacts( + selector, + date, + first, + balances[1], + context, + ); const result = cloneState(state); result.balances.find((item) => item.id === first.id)!.remaining--; result.placements.set(date, { @@ -1758,9 +2270,10 @@ function assignDateVariants( supporting: false, }); for (const [index, balance] of balances.entries()) { - recordTraceCandidate(context, { + const evaluation = recordTraceCandidate(context, { status: index === 0 ? 'evaluated-rejected' : 'not-examined', date, + ...(index === 0 ? selectionFacts : {}), ...(index === 0 ? {} : { @@ -1777,6 +2290,7 @@ function assignDateVariants( supporting: false, }, }); + if (index === 0) selectedEvaluation?.(evaluation); } return [result]; } @@ -1809,6 +2323,11 @@ function assignDateToBalance( balanceId: string, context: SearchContext, ruleIds = new Set(), + selectedEvaluation?: (candidate: MutableTraceCandidate | undefined) => void, + selectionFacts?: Pick< + PlanningTraceCandidate, + 'technicalComparison' | 'firstDecisiveComparator' + >, ): State | null { context.check(); if (state.placements.has(date)) { @@ -1846,14 +2365,16 @@ function assignDateToBalance( ruleIds: new Set(ruleIds), supporting: false, }); - recordTraceCandidate(context, { + const evaluation = recordTraceCandidate(context, { status: 'evaluated-rejected', date, + ...selectionFacts, placement: { ...result.placements.get(date)!, ruleIds: [...result.placements.get(date)!.ruleIds], }, }); + selectedEvaluation?.(evaluation); return result; } @@ -1863,6 +2384,12 @@ function assignDateToBalanceInPlace( balanceId: string, context: SearchContext, ruleIds = new Set(), + selectedEvaluation?: (candidate: MutableTraceCandidate | undefined) => void, + beforeMutation?: (state: State) => void, + selectionFacts?: Pick< + PlanningTraceCandidate, + 'technicalComparison' | 'firstDecisiveComparator' + >, ): boolean { context.check(); if (state.placements.has(date)) { @@ -1890,6 +2417,7 @@ function assignDateToBalanceInPlace( }); return false; } + beforeMutation?.(state); balance.remaining--; state.placements.set(date, { date, @@ -1898,14 +2426,16 @@ function assignDateToBalanceInPlace( ruleIds: new Set(ruleIds), supporting: false, }); - recordTraceCandidate(context, { + const evaluation = recordTraceCandidate(context, { status: 'evaluated-rejected', date, + ...selectionFacts, placement: { ...state.placements.get(date)!, ruleIds: [...state.placements.get(date)!.ruleIds], }, }); + selectedEvaluation?.(evaluation); return true; } @@ -2035,23 +2565,44 @@ function satisfyObligations( index = 0, solutions?: State[], solutionLimit = OBLIGATION_SOLUTION_LIMIT, + branchPath: readonly ObligationBranchReference[] = [], + parentDecisionId?: string, ): State | null { context.check(); if (index >= requirements.length) { const finalized = finalizeEarnedState(state, context); + if (!finalized) + recordTraceCandidate(context, { + status: 'pruned', + reason: reason( + 'earned-entitlement-order', + 'Generated leave is unavailable until its producing time-off period ends.', + ), + }); if (!finalized) return null; - if (solutions) solutions.push(finalized); + if (solutions) { + const allDates = [ + ...new Set([...state.placements.keys(), ...finalized.placements.keys()]), + ]; + const finalization = + finalized === state + ? undefined + : recordTraceBranchDecision( + context, + state, + finalized, + parentDecisionId, + allDates, + ); + const path = finalization ? [...branchPath, finalization] : [...branchPath]; + if (path.length) context.obligationSolutionBranches.set(finalized, path); + solutions.push(finalized); + } return solutions ? null : finalized; } const requirement = requirements[index]; if (requirement.patterns) { for (const pattern of requirement.patterns) { - observeTrace(context, (trace) => - trace.recordDecision('choose', pattern, { - status: 'evaluated-rejected', - dates: pattern, - }), - ); for (const assigned of assignDatesVariants(state, pattern, requirement.rule, context)) { const candidate = addEarnedBalance( assigned, @@ -2060,6 +2611,13 @@ function satisfyObligations( pattern, context, ); + const branch = recordTraceBranchDecision( + context, + state, + candidate, + parentDecisionId, + pattern, + ); const complete = satisfyObligations( requirements, candidate, @@ -2067,6 +2625,8 @@ function satisfyObligations( index + 1, solutions, solutionLimit, + branch ? [...branchPath, branch] : branchPath, + branch?.decisionId ?? parentDecisionId, ); if (complete && !solutions) return complete; if (solutions && solutionLimit > 0 && solutions.length >= solutionLimit) { @@ -2088,11 +2648,12 @@ function satisfyObligations( const choose = (position: number, picked: string[], current: State): State | null => { context.check(); if (picked.length === needed) { - observeTrace(context, (trace) => - trace.recordDecision('choose', picked, { - status: 'evaluated-rejected', - dates: picked, - }), + const branch = recordTraceBranchDecision( + context, + state, + current, + parentDecisionId, + picked, ); const complete = satisfyObligations( requirements, @@ -2101,6 +2662,8 @@ function satisfyObligations( index + 1, solutions, solutionLimit, + branch ? [...branchPath, branch] : branchPath, + branch?.decisionId ?? parentDecisionId, ); if (complete && !solutions) return complete; return null; @@ -2144,6 +2707,13 @@ function satisfyObligations( return choose(0, [], state); } +function chooseObligationBranches(state: State, context: SearchContext): void { + for (const branch of context.obligationSolutionBranches.get(state) ?? []) { + observeTrace(context, (trace) => trace.chooseEvaluation(branch.candidate)); + recordTraceState(context, branch.stateAfter, branch.candidate); + } +} + function placeAtRisk(state: State, context: SearchContext): State { // At-risk placement is a greedy pass over one state. Clone it once, then // mutate that private copy; cloning the whole placement map for every day is @@ -2168,27 +2738,84 @@ function placeAtRisk(state: State, context: SearchContext): State { const current = result.balances.find((item) => item.id === balance.id)!; if (current.remaining <= 0) break; let nextState: State | null = null; - for (const candidate of availableDates) { + let nextEvaluation: MutableTraceCandidate | undefined; + for (const [candidateIndex, candidate] of availableDates.entries()) { context.check(); if ( !result.placements.has(candidate) && compareDate(current.availableFrom, candidate) <= 0 && compareDate(candidate, current.expiryDate) <= 0 ) { + const nextDate = availableDates + .slice(candidateIndex + 1) + .find( + (date) => + !result.placements.has(date) && + compareDate(current.availableFrom, date) <= 0 && + compareDate(date, current.expiryDate) <= 0, + ); + const selectionFacts = nextDate + ? atRiskDateSelectionFacts(balance.type, candidate, nextDate, context) + : undefined; + let remainingBeforeTransition = current.remaining; + let evaluation: MutableTraceCandidate | undefined; const assigned = canMutate - ? assignDateToBalanceInPlace(result, candidate, current.id, context) + ? assignDateToBalanceInPlace( + result, + candidate, + current.id, + context, + new Set(), + (candidateTrace) => (evaluation = candidateTrace), + (before) => + (remainingBeforeTransition = + before.balances.find((item) => item.id === current.id) + ?.remaining ?? remainingBeforeTransition), + selectionFacts, + ) ? result : null - : assignDateToBalance(result, candidate, current.id, context); + : assignDateToBalance( + result, + candidate, + current.id, + context, + new Set(), + (candidateTrace) => (evaluation = candidateTrace), + selectionFacts, + ); const feasible = assigned ? finalizeEarnedState(assigned, context) : null; if (feasible) { nextState = feasible; + nextEvaluation = evaluation; + if (context.traceBranchActive) { + const parent = canMutate + ? recordTraceAtRiskBranchMutation( + context, + feasible, + context.traceBranchParentId, + evaluation, + candidate, + current.id, + remainingBeforeTransition, + ) + : recordTraceBranchMutation( + context, + result, + feasible, + context.traceBranchParentId, + evaluation, + [candidate], + ); + context.traceBranchParentId = parent ?? context.traceBranchParentId; + } break; } } } if (!nextState) break; result = nextState; + if (!context.traceBranchActive) recordTraceState(context, result, nextEvaluation); } } return result; @@ -2228,12 +2855,32 @@ function preferenceTimeWindow( for (const date of candidates) { context.check(); if (count >= rule.placeDays) break; - const next = assignDate(result, date, rule.placeType, rule.id, context); + const before = result; + let evaluation: MutableTraceCandidate | undefined; + const next = assignDate( + result, + date, + rule.placeType, + rule.id, + context, + (candidate) => (evaluation = candidate), + ); // A later preference can extend the generating period of an earned // entitlement. Reject that candidate immediately instead of allowing an // optional preference to turn the final plan into a conflict. const feasible = next ? finalizeEarnedState(next, context) : null; if (feasible) { + if (context.traceBranchActive) { + const parent = recordTraceBranchMutation( + context, + before, + feasible, + context.traceBranchParentId, + evaluation, + [date], + ); + context.traceBranchParentId = parent ?? context.traceBranchParentId; + } else recordTraceState(context, feasible, evaluation); result = feasible; count++; } @@ -2251,12 +2898,45 @@ function preferenceIfThen( let bestCount = 0; let bestFutureScore = Number.POSITIVE_INFINITY; let bestMetrics: PlanningMetrics | undefined; + let bestBranch: ObligationBranchReference | undefined; + let bestEvaluation: MutableTraceCandidate | undefined; const search = (index: number, current: State, count: number, futureScore: number): void => { context.check(); - if (count + matchingOccurrences.length - index < bestCount) return; + const maximumCount = count + matchingOccurrences.length - index; + if (maximumCount < bestCount) { + const comparator = `complete-occurrences:${rule.id}`; + recordTraceCandidate(context, { + status: 'pruned', + reason: reason( + 'preference-branch-bound', + 'This branch cannot complete as many preference occurrences as the current best.', + [rule.id], + ), + technicalComparison: [ + { + name: comparator, + direction: 'maximize', + before: bestCount, + after: maximumCount, + }, + ], + firstDecisiveComparator: comparator, + }); + return; + } if (index >= matchingOccurrences.length) { const finalized = finalizeEarnedState(current, context); - if (!finalized) return; + if (!finalized) { + recordTraceCandidate(context, { + status: 'pruned', + reason: reason( + 'earned-entitlement-order', + 'Generated leave is unavailable until its producing time-off period ends.', + [rule.id], + ), + }); + return; + } const metrics = qualityMetrics(finalized, context, []); const betterTie = futureScore < bestFutureScore || @@ -2266,11 +2946,52 @@ function preferenceIfThen( (compareMetrics(metrics, bestMetrics) === 0 && compareText(placementTieKey(finalized), placementTieKey(bestState)) < 0))); - if (count > bestCount || (count === bestCount && betterTie)) { + const better = count > bestCount || (count === bestCount && betterTie); + let branch: ObligationBranchReference | undefined; + let evaluation: MutableTraceCandidate | undefined; + if (context.trace) { + const beforeCount = bestCount; + const beforeFutureScore = bestFutureScore; + const beforeMetrics = bestMetrics; + const beforeState = bestState; + const candidateDates = () => + [...finalized.placements.values()] + .filter( + (placement) => + placement.ruleIds.has(rule.id) && + !state.placements.get(placement.date)?.ruleIds.has(rule.id), + ) + .map((placement) => placement.date); + const comparison = () => + preferenceIfThenComparisonFacts( + rule.id, + count, + futureScore, + metrics, + beforeCount, + beforeFutureScore, + beforeMetrics, + finalized, + beforeState, + ); + if (context.traceBranchActive) + branch = recordTraceBranchDecision( + context, + state, + finalized, + context.traceBranchParentId, + candidateDates, + comparison, + ); + else evaluation = recordTraceIfThenEvaluation(context, candidateDates, comparison); + } + if (better) { bestCount = count; bestState = finalized; bestFutureScore = futureScore; bestMetrics = metrics; + bestBranch = branch; + bestEvaluation = evaluation; } return; } @@ -2297,9 +3018,100 @@ function preferenceIfThen( search(index + 1, current, count, futureScore); }; search(0, state, 0, 0); + if (bestBranch) { + observeTrace(context, (trace) => trace.chooseEvaluation(bestBranch!.candidate)); + context.traceBranchParentId = bestBranch.decisionId; + } else if (!context.traceBranchActive) { + recordTraceState(context, bestState, bestEvaluation); + } return { state: bestState, count: bestCount }; } +function preferenceIfThenComparisonFacts( + ruleId: string, + count: number, + futureScore: number, + metrics: PlanningMetrics, + bestCount: number, + bestFutureScore: number, + bestMetrics: PlanningMetrics | undefined, + state: State, + bestState: State, +): { + values: NonNullable; + firstDecisiveComparator?: string; +} { + const values: NonNullable[number][] = []; + const countName = `complete-occurrences:${ruleId}`; + const countEqual = count === bestCount; + values.push({ name: countName, direction: 'maximize', before: bestCount, after: count }); + const scoreName = `future-pattern-conflict-score:${ruleId}`; + const scoreBefore = Number.isFinite(bestFutureScore) ? bestFutureScore : '∞'; + values.push({ + name: scoreName, + direction: 'minimize', + before: scoreBefore, + after: futureScore, + }); + let firstDecisiveComparator = !countEqual ? countName : undefined; + if (countEqual && futureScore !== bestFutureScore) firstDecisiveComparator = scoreName; + + if (futureScore === bestFutureScore && bestMetrics) { + const metricComparators = [ + { + name: 'unplaced-at-risk', + direction: 'minimize' as const, + before: bestMetrics.unplacedAtRisk, + after: metrics.unplacedAtRisk, + }, + { + name: 'holiday-week-value', + direction: 'maximize' as const, + before: bestMetrics.holidayWeekValue, + after: metrics.holidayWeekValue, + }, + { + name: 'non-working-day-gain', + direction: 'maximize' as const, + before: bestMetrics.nonWorkingDayGain, + after: metrics.nonWorkingDayGain, + }, + { + name: 'longest-work-sequence', + direction: 'minimize' as const, + before: bestMetrics.longestWorkSequence, + after: metrics.longestWorkSequence, + }, + { + name: 'short-sequence-deficit', + direction: 'minimize' as const, + before: bestMetrics.shortSequenceDeficit, + after: metrics.shortSequenceDeficit, + }, + { + name: 'supporting-placements', + direction: 'minimize' as const, + before: bestMetrics.supportingPlacements, + after: metrics.supportingPlacements, + }, + ]; + for (const comparator of metricComparators) { + values.push(comparator); + if (comparator.before !== comparator.after) { + firstDecisiveComparator ??= comparator.name; + break; + } + } + if (metricComparators.every((comparator) => comparator.before === comparator.after)) { + const before = placementTieKey(bestState); + const after = placementTieKey(state); + values.push({ name: 'placement-date', direction: 'minimize', before, after }); + if (before !== after) firstDecisiveComparator ??= 'placement-date'; + } + } + return { values, firstDecisiveComparator }; +} + function preferenceDateConflictScore( selector: LeaveSelector, date: string, @@ -2319,6 +3131,89 @@ function preferenceDateConflictScore( return demand ? -demand : 0; } +function atRiskDateSelectionFacts( + selector: LeaveSelector, + selectedDate: string, + nextDate: string, + context: SearchContext, +): Pick { + const selectedScore = preferenceDateConflictScore(selector, selectedDate, context); + const nextScore = preferenceDateConflictScore(selector, nextDate, context); + const technicalComparison: NonNullable< + PlanningTraceCandidate['technicalComparison'] + >[number][] = [ + { + name: 'preference-date-conflict', + direction: 'minimize', + before: selectedScore, + after: nextScore, + }, + ]; + if (selectedScore !== nextScore) + return { technicalComparison, firstDecisiveComparator: 'preference-date-conflict' }; + technicalComparison.push({ + name: 'placement-date', + direction: 'minimize', + before: selectedDate, + after: nextDate, + }); + return { technicalComparison, firstDecisiveComparator: 'placement-date' }; +} + +function firstCompatibleBalanceSelectionFacts( + selector: LeaveSelector, + date: string, + first: InternalBalance, + second: InternalBalance | undefined, + context: SearchContext, +): Pick { + if (!second) return {}; + const values: NonNullable[number][] = []; + let firstDecisiveComparator: string | undefined; + const add = ( + name: string, + direction: 'minimize' | 'maximize', + before: number | string, + after: number | string, + ): boolean => { + values.push({ name, direction, before, after }); + if (before !== after) firstDecisiveComparator ??= name; + return before === after; + }; + if (selector === 'any') { + const sharedTypes = context.sharedTypesByDate.get(date) ?? new Set(); + let equal = add( + 'shared-placement-type', + 'maximize', + Number(sharedTypes.has(first.type)), + Number(sharedTypes.has(second.type)), + ); + const protectedTypes = context.protectedTypesByDate.get(date) ?? new Set(); + if (equal && protectedTypes.size) + equal = add( + 'protected-preference-type', + 'maximize', + Number(protectedTypes.has(first.type)), + Number(protectedTypes.has(second.type)), + ); + if ( + equal && + context.reservedTypes.has(first.type) !== context.reservedTypes.has(second.type) + ) + add( + 'reserved-type', + 'minimize', + Number(context.reservedTypes.has(first.type)), + Number(context.reservedTypes.has(second.type)), + ); + } + if (!firstDecisiveComparator) { + const equal = add('balance-expiry', 'minimize', first.expiryDate, second.expiryDate); + if (equal) add('balance-id', 'minimize', first.id, second.id); + } + return { technicalComparison: values, firstDecisiveComparator }; +} + function patternConflictScore( selector: LeaveSelector, pattern: string[], @@ -2586,6 +3481,265 @@ function compareObligationPreviews( return compareText(placementTieKey(a.state), placementTieKey(b.state)); } +function obligationPreviewComparisonFacts( + a: { state: State; preferences: PreferenceResult[]; metrics: PlanningMetrics }, + b: { state: State; preferences: PreferenceResult[]; metrics: PlanningMetrics }, +): { + values: NonNullable; + firstDecisiveComparator?: string; +} { + const values: NonNullable[number][] = []; + const add = ( + name: string, + direction: 'minimize' | 'maximize', + before: number | string, + after: number | string, + ) => { + values.push({ name, direction, before, after }); + return before === after; + }; + let equal = add( + 'unplaced-at-risk', + 'minimize', + b.metrics.unplacedAtRisk, + a.metrics.unplacedAtRisk, + ); + if (!equal) return { values, firstDecisiveComparator: 'unplaced-at-risk' }; + for (let index = 0; index < a.metrics.preferenceSatisfaction.length; index++) { + const before = b.metrics.preferenceSatisfaction[index] ?? 0; + const after = a.metrics.preferenceSatisfaction[index] ?? 0; + const ruleId = + a.preferences[index]?.ruleId ?? b.preferences[index]?.ruleId ?? String(index); + equal = add(`preference:${ruleId}`, 'maximize', before, after); + if (!equal) return { values, firstDecisiveComparator: `preference:${ruleId}` }; + } + const metrics = [ + { + name: 'holiday-week-value', + direction: 'maximize' as const, + before: b.metrics.holidayWeekValue, + after: a.metrics.holidayWeekValue, + }, + { + name: 'non-working-day-gain', + direction: 'maximize' as const, + before: b.metrics.nonWorkingDayGain, + after: a.metrics.nonWorkingDayGain, + }, + { + name: 'longest-work-sequence', + direction: 'minimize' as const, + before: b.metrics.longestWorkSequence, + after: a.metrics.longestWorkSequence, + }, + { + name: 'short-sequence-deficit', + direction: 'minimize' as const, + before: b.metrics.shortSequenceDeficit, + after: a.metrics.shortSequenceDeficit, + }, + { + name: 'supporting-placements', + direction: 'minimize' as const, + before: b.metrics.supportingPlacements, + after: a.metrics.supportingPlacements, + }, + ]; + for (const metric of metrics) { + equal = add(metric.name, metric.direction, metric.before, metric.after); + if (!equal) return { values, firstDecisiveComparator: metric.name }; + } + const beforeExpiry = expiryTieKey(b.state); + const afterExpiry = expiryTieKey(a.state); + equal = add('balance-expiry', 'minimize', beforeExpiry, afterExpiry); + if (!equal) return { values, firstDecisiveComparator: 'balance-expiry' }; + const beforeDates = placementTieKey(b.state); + const afterDates = placementTieKey(a.state); + equal = add('placement-date', 'minimize', beforeDates, afterDates); + return { values, firstDecisiveComparator: equal ? undefined : 'placement-date' }; +} + +type QualityTraceDetails = Pick< + PlanningTraceCandidate, + 'technicalComparison' | 'firstDecisiveComparator' | 'reason' +>; + +type QualityTraceComparator = NonNullable[number]; + +function addQualityComparator( + values: QualityTraceComparator[], + name: string, + direction: QualityTraceComparator['direction'], + before: number | string, + after: number | string, +): boolean { + values.push({ name, direction, before, after }); + return before === after; +} + +function movableProtectionTraceDetails( + baseline: PlanningMetrics, + candidate: PlanningMetrics, + preferences: readonly PreferenceResult[], +): QualityTraceDetails { + const values: QualityTraceComparator[] = []; + const reject = (name: string, message: string): QualityTraceDetails => ({ + technicalComparison: values, + firstDecisiveComparator: name, + reason: reason('quality-protected-objective', message), + }); + const atRiskEqual = addQualityComparator( + values, + 'protected:unplaced-at-risk', + 'minimize', + baseline.unplacedAtRisk, + candidate.unplacedAtRisk, + ); + if (!atRiskEqual) + return reject( + 'protected:unplaced-at-risk', + 'This move changes the amount of unplaced at-risk leave protected by the quality pass.', + ); + addQualityComparator( + values, + 'protected:holiday-week-value', + 'maximize', + baseline.holidayWeekValue, + candidate.holidayWeekValue, + ); + if (candidate.holidayWeekValue < baseline.holidayWeekValue) + return reject( + 'protected:holiday-week-value', + 'This move reduces public-holiday week value protected by the quality pass.', + ); + if (candidate.preferenceSatisfaction.length !== baseline.preferenceSatisfaction.length) { + const name = 'protected:preference-count'; + addQualityComparator( + values, + name, + 'maximize', + baseline.preferenceSatisfaction.length, + candidate.preferenceSatisfaction.length, + ); + return reject( + name, + 'This move changes the preference result protected by the quality pass.', + ); + } + for (let index = 0; index < baseline.preferenceSatisfaction.length; index++) { + const name = `protected:preference:${preferences[index]?.ruleId ?? index}`; + const before = baseline.preferenceSatisfaction[index]; + const after = candidate.preferenceSatisfaction[index]; + if (!addQualityComparator(values, name, 'maximize', before, after)) + return reject( + name, + 'This move changes a preference result protected by the quality pass.', + ); + } + return { technicalComparison: values }; +} + +function movableIncumbentTraceDetails( + stage: 'non-working-day-gain' | 'longest-work-sequence' | 'short-sequence-deficit' | 'dates', + incumbent: PlanningMetrics, + candidate: PlanningMetrics, + incumbentDate: string, + candidateDate: string, +): QualityTraceDetails { + const values: QualityTraceComparator[] = []; + const add = ( + name: string, + direction: QualityTraceComparator['direction'], + before: number | string, + after: number | string, + ) => addQualityComparator(values, name, direction, before, after); + const finishIfDifferent = (name: string, equal: boolean): QualityTraceDetails | undefined => + equal ? undefined : { technicalComparison: values, firstDecisiveComparator: name }; + + let equal = add( + 'holiday-week-value', + 'maximize', + incumbent.holidayWeekValue, + candidate.holidayWeekValue, + ); + let decisive = finishIfDifferent('holiday-week-value', equal); + if (decisive) return decisive; + if (stage === 'non-working-day-gain') { + equal = add( + 'non-working-day-gain', + 'maximize', + incumbent.nonWorkingDayGain, + candidate.nonWorkingDayGain, + ); + return finishIfDifferent('non-working-day-gain', equal) ?? { technicalComparison: values }; + } + equal = add( + 'non-working-day-gain', + 'maximize', + incumbent.nonWorkingDayGain, + candidate.nonWorkingDayGain, + ); + decisive = finishIfDifferent('non-working-day-gain', equal); + if (decisive) return decisive; + if (stage === 'longest-work-sequence') { + equal = add( + 'longest-work-sequence', + 'minimize', + incumbent.longestWorkSequence, + candidate.longestWorkSequence, + ); + return finishIfDifferent('longest-work-sequence', equal) ?? { technicalComparison: values }; + } + equal = add( + 'longest-work-sequence', + 'minimize', + incumbent.longestWorkSequence, + candidate.longestWorkSequence, + ); + decisive = finishIfDifferent('longest-work-sequence', equal); + if (decisive) return decisive; + if (stage === 'short-sequence-deficit') { + equal = add( + 'short-sequence-deficit', + 'minimize', + incumbent.shortSequenceDeficit, + candidate.shortSequenceDeficit, + ); + return ( + finishIfDifferent('short-sequence-deficit', equal) ?? { technicalComparison: values } + ); + } + equal = add( + 'short-sequence-deficit', + 'minimize', + incumbent.shortSequenceDeficit, + candidate.shortSequenceDeficit, + ); + decisive = finishIfDifferent('short-sequence-deficit', equal); + if (decisive) return decisive; + if (stage === 'dates') { + const dateEqual = add('placement-date', 'minimize', incumbentDate, candidateDate); + return finishIfDifferent('placement-date', dateEqual) ?? { technicalComparison: values }; + } + return { technicalComparison: values }; +} + +function recordTraceDecision(context: SearchContext, dates: readonly string[]): void { + observeTrace(context, (trace) => trace.recordDecision('choose', dates)); +} + +function updateTraceEvaluationReason( + context: SearchContext, + evaluation: MutableTraceCandidate | undefined, + evaluationReason: PlanningReason, +): void { + if (!evaluation) return; + observeTrace(context, (trace) => { + evaluation.reason = evaluationReason; + trace.updateEvaluation(evaluation); + }); +} + function recordTraceEvaluation( context: SearchContext, beforeState: State, @@ -2595,20 +3749,25 @@ function recordTraceEvaluation( afterMetrics: PlanningMetrics, dates: readonly string[], sourceDate?: string, + qualityDetails?: () => QualityTraceDetails, ): MutableTraceCandidate | undefined { let evaluation: MutableTraceCandidate | undefined; observeTrace(context, (trace) => { const date = dates[dates.length - 1]; const placement = date ? afterState.placements.get(date) : undefined; + const details = qualityDetails?.(); evaluation = { status: 'evaluated-rejected', sourceDate, date, dates: [...dates], + reason: details?.reason, comparison: objectiveComparison( - objectiveVector(beforeMetrics, preferences, beforeState), - objectiveVector(afterMetrics, preferences, afterState), + objectiveVector(beforeMetrics, preferences, beforeState, context), + objectiveVector(afterMetrics, preferences, afterState, context), ), + technicalComparison: details?.technicalComparison, + firstDecisiveComparator: details?.firstDecisiveComparator, placement: placement ? { ...placement, ruleIds: [...placement.ruleIds].sort() } : undefined, @@ -2618,6 +3777,118 @@ function recordTraceEvaluation( return context.trace ? evaluation : undefined; } +function holidayWeekProtectionTraceDetails( + baseline: PlanningMetrics, + candidate: PlanningMetrics, + preferences: readonly PreferenceResult[], +): QualityTraceDetails { + const values: QualityTraceComparator[] = []; + const reject = (name: string, message: string): QualityTraceDetails => ({ + technicalComparison: values, + firstDecisiveComparator: name, + reason: reason('quality-protected-objective', message), + }); + addQualityComparator( + values, + 'protected:unplaced-at-risk', + 'minimize', + baseline.unplacedAtRisk, + candidate.unplacedAtRisk, + ); + if (candidate.unplacedAtRisk > baseline.unplacedAtRisk) + return reject( + 'protected:unplaced-at-risk', + 'This completion leaves more at-risk leave unplaced.', + ); + for ( + let index = 0; + index < + Math.max(baseline.preferenceSatisfaction.length, candidate.preferenceSatisfaction.length); + index++ + ) { + const name = `protected:preference:${preferences[index]?.ruleId ?? index}`; + const before = baseline.preferenceSatisfaction[index] ?? 0; + const after = candidate.preferenceSatisfaction[index] ?? 0; + if (!addQualityComparator(values, name, 'maximize', before, after)) + return reject(name, 'This completion changes a higher-priority preference result.'); + } + addQualityComparator( + values, + 'protected:holiday-week-value', + 'maximize', + baseline.holidayWeekValue, + candidate.holidayWeekValue, + ); + if (candidate.holidayWeekValue <= baseline.holidayWeekValue) + return reject( + 'protected:holiday-week-value', + 'This completion does not increase public-holiday week value.', + ); + return { technicalComparison: values }; +} + +function qualityMetricTraceDetails( + incumbent: PlanningMetrics, + candidate: PlanningMetrics, +): QualityTraceDetails { + const values: QualityTraceComparator[] = []; + const comparators = [ + ['unplaced-at-risk', 'minimize', incumbent.unplacedAtRisk, candidate.unplacedAtRisk], + ['holiday-week-value', 'maximize', incumbent.holidayWeekValue, candidate.holidayWeekValue], + [ + 'non-working-day-gain', + 'maximize', + incumbent.nonWorkingDayGain, + candidate.nonWorkingDayGain, + ], + [ + 'longest-work-sequence', + 'minimize', + incumbent.longestWorkSequence, + candidate.longestWorkSequence, + ], + [ + 'short-sequence-deficit', + 'minimize', + incumbent.shortSequenceDeficit, + candidate.shortSequenceDeficit, + ], + [ + 'supporting-placements', + 'minimize', + incumbent.supportingPlacements, + candidate.supportingPlacements, + ], + ] as const; + for (const [name, direction, before, after] of comparators) { + if (!addQualityComparator(values, name, direction, before, after)) + return { technicalComparison: values, firstDecisiveComparator: name }; + } + return { technicalComparison: values }; +} + +function qualityMetricTieTraceDetails( + incumbent: PlanningMetrics, + candidate: PlanningMetrics, + incumbentState: State, + candidateState: State, +): QualityTraceDetails { + const comparison = qualityMetricTraceDetails(incumbent, candidate); + if (comparison.firstDecisiveComparator) return comparison; + const values = [...(comparison.technicalComparison ?? [])]; + const equal = addQualityComparator( + values, + 'placement-date', + 'minimize', + placementTieKey(incumbentState), + placementTieKey(candidateState), + ); + return { + technicalComparison: values, + firstDecisiveComparator: equal ? undefined : 'placement-date', + }; +} + function optimizeMovablePlacements( state: State, context: SearchContext, @@ -2690,6 +3961,7 @@ function optimizeMovablePlacements( (!current.ruleIds.size && !isAtRisk(balance, context) && !current.supporting) ) continue; + recordTraceDecision(context, [original.date]); const baseline = qualityMetrics(result, context, preferences); const without = cloneState(result); without.placements.delete(original.date); @@ -2729,26 +4001,54 @@ function optimizeMovablePlacements( if (!feasibleCandidate) continue; feasibleCandidate.placements.get(date)!.supporting = current.supporting; const metrics = qualityMetrics(feasibleCandidate, context, preferences); - const evaluation = recordTraceEvaluation( - context, - result, - feasibleCandidate, - preferences, - baseline, - metrics, - [date], - current.date, - ); - if (metrics.unplacedAtRisk !== baseline.unplacedAtRisk) continue; - if (metrics.holidayWeekValue < baseline.holidayWeekValue) continue; + if (metrics.unplacedAtRisk !== baseline.unplacedAtRisk) { + recordTraceEvaluation( + context, + result, + feasibleCandidate, + preferences, + baseline, + metrics, + [date], + current.date, + () => movableProtectionTraceDetails(baseline, metrics, preferences), + ); + continue; + } + if (metrics.holidayWeekValue < baseline.holidayWeekValue) { + recordTraceEvaluation( + context, + result, + feasibleCandidate, + preferences, + baseline, + metrics, + [date], + current.date, + () => movableProtectionTraceDetails(baseline, metrics, preferences), + ); + continue; + } if ( metrics.preferenceSatisfaction.length !== baseline.preferenceSatisfaction.length || metrics.preferenceSatisfaction.some( (value, index) => value !== baseline.preferenceSatisfaction[index], ) - ) + ) { + recordTraceEvaluation( + context, + result, + feasibleCandidate, + preferences, + baseline, + metrics, + [date], + current.date, + () => movableProtectionTraceDetails(baseline, metrics, preferences), + ); continue; + } const better = metrics.holidayWeekValue > bestMetrics.holidayWeekValue || (metrics.holidayWeekValue === bestMetrics.holidayWeekValue && @@ -2769,7 +4069,56 @@ function optimizeMovablePlacements( metrics.shortSequenceDeficit === bestMetrics.shortSequenceDeficit && date < bestDate)); + const evaluation = recordTraceEvaluation( + context, + result, + feasibleCandidate, + preferences, + baseline, + metrics, + [date], + current.date, + () => { + const protectedDetails = movableProtectionTraceDetails( + baseline, + metrics, + preferences, + ); + const incumbentDetails = movableIncumbentTraceDetails( + stage, + bestMetrics, + metrics, + bestDate, + date, + ); + return { + technicalComparison: [ + ...(protectedDetails.technicalComparison ?? []), + ...(incumbentDetails.technicalComparison ?? []), + ], + firstDecisiveComparator: + protectedDetails.reason?.code === 'quality-protected-objective' + ? protectedDetails.firstDecisiveComparator + : incumbentDetails.firstDecisiveComparator, + reason: better + ? undefined + : reason( + 'quality-no-improvement', + `This candidate did not improve the current best result in the ${stage} pass.`, + ), + }; + }, + ); if (better) { + if (bestEvaluation) + updateTraceEvaluationReason( + context, + bestEvaluation, + reason( + 'quality-outperformed', + 'A later candidate became the best result in this pass.', + ), + ); best = cloneState(feasibleCandidate); bestMetrics = metrics; bestDate = date; @@ -2874,6 +4223,7 @@ function optimizeHolidayWeeks( const candidates = context.holidayWeeks; while (true) { context.check(); + if (candidates.length) recordTraceDecision(context, []); const baseline = qualityMetrics(result, context, preferences); const completed = candidates.filter((candidate) => candidate.dates.every((date) => result.placements.has(date)), @@ -2891,6 +4241,32 @@ function optimizeHolidayWeeks( const complete = completeHolidayWeek(result, candidate, context, protectedDates); if (!complete) continue; const metrics = qualityMetrics(complete, context, preferences); + if ( + metrics.unplacedAtRisk > baseline.unplacedAtRisk || + comparePreferenceVectors( + metrics.preferenceSatisfaction, + baseline.preferenceSatisfaction, + ) !== 0 || + metrics.holidayWeekValue <= baseline.holidayWeekValue + ) { + recordTraceEvaluation( + context, + result, + complete, + preferences, + baseline, + metrics, + candidate.dates, + undefined, + () => holidayWeekProtectionTraceDetails(baseline, metrics, preferences), + ); + continue; + } + const better = + !best || + compareMetrics(metrics, bestMetrics) < 0 || + (compareMetrics(metrics, bestMetrics) === 0 && + placementTieKey(complete) < placementTieKey(best)); const evaluation = recordTraceEvaluation( context, result, @@ -2899,22 +4275,43 @@ function optimizeHolidayWeeks( baseline, metrics, candidate.dates, + undefined, + () => { + const protectedDetails = holidayWeekProtectionTraceDetails( + baseline, + metrics, + preferences, + ); + const incumbentDetails = best + ? qualityMetricTieTraceDetails(bestMetrics, metrics, best, complete) + : undefined; + return { + technicalComparison: [ + ...(protectedDetails.technicalComparison ?? []), + ...(incumbentDetails?.technicalComparison ?? []), + ], + firstDecisiveComparator: protectedDetails.reason + ? protectedDetails.firstDecisiveComparator + : incumbentDetails?.firstDecisiveComparator, + reason: better + ? undefined + : reason( + 'quality-no-improvement', + 'This completion did not outrank the current best on its compared objectives or date tie-break.', + ), + }; + }, ); - if ( - metrics.unplacedAtRisk > baseline.unplacedAtRisk || - comparePreferenceVectors( - metrics.preferenceSatisfaction, - baseline.preferenceSatisfaction, - ) !== 0 || - metrics.holidayWeekValue <= baseline.holidayWeekValue - ) - continue; - if ( - !best || - compareMetrics(metrics, bestMetrics) < 0 || - (compareMetrics(metrics, bestMetrics) === 0 && - placementTieKey(complete) < placementTieKey(best)) - ) { + if (better) { + if (bestEvaluation) + updateTraceEvaluationReason( + context, + bestEvaluation, + reason( + 'quality-outperformed', + 'A later holiday-week completion became the best result in this pass.', + ), + ); best = complete; bestMetrics = metrics; bestEvaluation = evaluation; @@ -2938,6 +4335,81 @@ function sameHigherQuality(a: PlanningMetrics, b: PlanningMetrics): boolean { ); } +function expiryProtectionTraceDetails( + baseline: PlanningMetrics, + candidate: PlanningMetrics, + preferences: readonly PreferenceResult[], +): QualityTraceDetails { + const values: QualityTraceComparator[] = []; + const reject = (name: string): QualityTraceDetails => ({ + technicalComparison: values, + firstDecisiveComparator: name, + reason: reason( + 'quality-protected-objective', + `This replacement changes ${name.slice('protected:'.length).replaceAll('-', ' ')} protected by the expiry pass.`, + ), + }); + for (const [name, direction, before, after] of [ + [ + 'protected:unplaced-at-risk', + 'minimize', + baseline.unplacedAtRisk, + candidate.unplacedAtRisk, + ], + [ + 'protected:holiday-week-value', + 'maximize', + baseline.holidayWeekValue, + candidate.holidayWeekValue, + ], + [ + 'protected:non-working-day-gain', + 'maximize', + baseline.nonWorkingDayGain, + candidate.nonWorkingDayGain, + ], + [ + 'protected:longest-work-sequence', + 'minimize', + baseline.longestWorkSequence, + candidate.longestWorkSequence, + ], + [ + 'protected:short-sequence-deficit', + 'minimize', + baseline.shortSequenceDeficit, + candidate.shortSequenceDeficit, + ], + ] as const) { + if (!addQualityComparator(values, name, direction, before, after)) return reject(name); + } + if (baseline.preferenceSatisfaction.length !== candidate.preferenceSatisfaction.length) { + const name = 'protected:preference-count'; + addQualityComparator( + values, + name, + 'maximize', + baseline.preferenceSatisfaction.length, + candidate.preferenceSatisfaction.length, + ); + return reject(name); + } + for (let index = 0; index < baseline.preferenceSatisfaction.length; index++) { + const name = `protected:preference:${preferences[index]?.ruleId ?? index}`; + if ( + !addQualityComparator( + values, + name, + 'maximize', + baseline.preferenceSatisfaction[index], + candidate.preferenceSatisfaction[index], + ) + ) + return reject(name); + } + return { technicalComparison: values }; +} + function optimizeExpiryPlacements( state: State, context: SearchContext, @@ -2954,6 +4426,7 @@ function optimizeExpiryPlacements( if (!current) continue; const source = result.balances.find((balance) => balance.id === current.balanceId); if (!source) continue; + recordTraceDecision(context, [original.date]); const baseline = qualityMetrics(result, context, preferences); const without = cloneState(result); without.placements.delete(current.date); @@ -2985,6 +4458,9 @@ function optimizeExpiryPlacements( if (!feasibleCandidate) continue; feasibleCandidate.placements.get(current.date)!.supporting = current.supporting; const metrics = qualityMetrics(feasibleCandidate, context, preferences); + const expiry = expiryTieKey(feasibleCandidate); + const preservesQuality = sameHigherQuality(metrics, baseline); + const better = preservesQuality && expiry < bestExpiry; const evaluation = recordTraceEvaluation( context, result, @@ -2994,9 +4470,47 @@ function optimizeExpiryPlacements( metrics, [current.date], current.date, + () => { + const protectedDetails = expiryProtectionTraceDetails( + baseline, + metrics, + preferences, + ); + const values = [...(protectedDetails.technicalComparison ?? [])]; + let firstDecisiveComparator = protectedDetails.firstDecisiveComparator; + let candidateReason = protectedDetails.reason; + if (!candidateReason) { + const equalExpiry = addQualityComparator( + values, + 'balance-expiry', + 'minimize', + bestExpiry, + expiry, + ); + if (!equalExpiry) firstDecisiveComparator = 'balance-expiry'; + if (!better) + candidateReason = reason( + 'quality-no-improvement', + 'This replacement does not use an earlier-expiring balance than the current best.', + ); + } + return { + technicalComparison: values, + firstDecisiveComparator, + reason: candidateReason, + }; + }, ); - const expiry = expiryTieKey(feasibleCandidate); - if (sameHigherQuality(metrics, baseline) && expiry < bestExpiry) { + if (better) { + if (bestEvaluation) + updateTraceEvaluationReason( + context, + bestEvaluation, + reason( + 'quality-outperformed', + 'A later replacement became the best earlier-expiry choice in this pass.', + ), + ); best = feasibleCandidate; bestExpiry = expiry; bestEvaluation = evaluation; @@ -3177,12 +4691,47 @@ function remainingBalances(state: State, context: SearchContext): RemainingBalan }); } +function unsatisfiedObligationCount(state: State, context: SearchContext): number { + const placementCounts = new Map(); + for (const placement of state.placements.values()) + for (const ruleId of placement.ruleIds) + placementCounts.set(ruleId, (placementCounts.get(ruleId) ?? 0) + 1); + let unsatisfied = 0; + for (const requirement of buildRequirements(context.rules, context, 'obligation')) { + if (!requirement.patterns) { + if ((placementCounts.get(requirement.rule.id) ?? 0) !== requirement.rule.placeDays) + unsatisfied++; + continue; + } + let complete = false; + for (const pattern of requirement.patterns) { + if ( + pattern.every((date) => + state.placements.get(date)?.ruleIds.has(requirement.rule.id), + ) + ) { + complete = true; + break; + } + } + if (!complete) unsatisfied++; + } + return unsatisfied; +} + function objectiveVector( metrics: PlanningMetrics, preferences: readonly PreferenceResult[], state: State, + context: SearchContext, ): PlanningObjectiveVector { const objectives: PlanningObjective[] = [ + { + id: 'obligation-validity', + direction: 'minimize', + value: unsatisfiedObligationCount(state, context), + label: 'Unsatisfied obligations', + }, { id: 'unplaced-at-risk', direction: 'minimize', @@ -3259,7 +4808,7 @@ function planExplanation( const rawRules = Array.isArray(source.rules) ? source.rules : []; const labels = recordValue(source.labels) as Record | undefined; const identities = recordValue(source.identities) as Record | undefined; - const vector = objectiveVector(metrics, preferences, state); + const vector = objectiveVector(metrics, preferences, state, context); return { kind: 'PlanExplanation', placements: placements.map((placement) => { @@ -3294,7 +4843,7 @@ function planExplanation( const withoutPreferences = preferenceResultsForState(withoutPlacement, context); const withoutMetrics = qualityMetrics(withoutPlacement, context, withoutPreferences); const comparison = objectiveComparison( - objectiveVector(withoutMetrics, withoutPreferences, withoutPlacement), + objectiveVector(withoutMetrics, withoutPreferences, withoutPlacement, context), vector, ); const contributions: PlacementContribution[] = [ @@ -3439,7 +4988,10 @@ function markProgress(context: SearchContext, stage: string): void { trace.recordDecision(stage === 'validation' ? 'validate' : 'choose', []); }); } - if (typeof context.request.progress === 'function') context.request.progress(stage); + if (typeof context.request.progress === 'function') + observeCallback(context, () => + (context.request.progress as (stage: string) => void)(stage), + ); } function planningStages(context: SearchContext): string[] { @@ -3488,24 +5040,28 @@ function obligationConflict( .filter((rule) => rule.enabled && (rule.rewardDays ?? 0) > 0 && rule.rewardType) .map((rule) => rule.rewardType!), ); + let generatedCapacityBottleneck = false; for (const requirement of requirements) { if (requirement.rule.placeType === 'any') continue; const required = requirement.patterns?.[0]?.length ?? requirement.rule.placeDays; const capacity = context.initialBalances .filter((balance) => balance.type === requirement.rule.placeType) .reduce((sum, balance) => sum + balance.quantity, 0); - if (capacity < required && !generatedTypes.has(requirement.rule.placeType)) - return { - kind: 'PlanningConflict', - conflictingRuleIds: ruleIds, - reasons: [ - reason( - 'typed-capacity', - `The obligations require more ${requirement.rule.placeType.toUpperCase()} leave than is available.`, - ruleIds, - ), - ], - }; + if (capacity < required) { + if (!generatedTypes.has(requirement.rule.placeType)) + return { + kind: 'PlanningConflict', + conflictingRuleIds: ruleIds, + reasons: [ + reason( + 'typed-capacity', + `The obligations require more ${requirement.rule.placeType.toUpperCase()} leave than is available.`, + ruleIds, + ), + ], + }; + generatedCapacityBottleneck = true; + } } if ( requirements.some( @@ -3525,7 +5081,7 @@ function obligationConflict( ), ], }; - if (generatedTypes.size) + if (generatedCapacityBottleneck) return { kind: 'PlanningConflict', conflictingRuleIds: ruleIds, @@ -3550,6 +5106,160 @@ function obligationConflict( }; } +function supportingProtectionTraceDetails( + baseline: PlanningMetrics, + candidate: PlanningMetrics, + preferences: readonly PreferenceResult[], +): QualityTraceDetails { + const values: QualityTraceComparator[] = []; + const reject = (name: string, message: string): QualityTraceDetails => ({ + technicalComparison: values, + firstDecisiveComparator: name, + reason: reason('quality-protected-objective', message), + }); + const same = ( + name: string, + direction: QualityTraceComparator['direction'], + a: number, + b: number, + ) => addQualityComparator(values, name, direction, a, b); + if ( + !same( + 'protected:unplaced-at-risk', + 'minimize', + baseline.unplacedAtRisk, + candidate.unplacedAtRisk, + ) + ) + return reject( + 'protected:unplaced-at-risk', + 'This supporting placement changes the amount of unplaced at-risk leave.', + ); + if (baseline.preferenceSatisfaction.length !== candidate.preferenceSatisfaction.length) { + const name = 'protected:preference-count'; + same( + name, + 'maximize', + baseline.preferenceSatisfaction.length, + candidate.preferenceSatisfaction.length, + ); + return reject(name, 'This supporting placement changes a preference result.'); + } + for (let index = 0; index < baseline.preferenceSatisfaction.length; index++) { + const name = `protected:preference:${preferences[index]?.ruleId ?? index}`; + if ( + !same( + name, + 'maximize', + baseline.preferenceSatisfaction[index], + candidate.preferenceSatisfaction[index], + ) + ) + return reject(name, 'This supporting placement changes a preference result.'); + } + for (const [name, direction, before, after, isAllowed] of [ + [ + 'protected:holiday-week-value', + 'maximize', + baseline.holidayWeekValue, + candidate.holidayWeekValue, + candidate.holidayWeekValue >= baseline.holidayWeekValue, + ], + [ + 'protected:non-working-day-gain', + 'maximize', + baseline.nonWorkingDayGain, + candidate.nonWorkingDayGain, + candidate.nonWorkingDayGain >= baseline.nonWorkingDayGain, + ], + [ + 'protected:longest-work-sequence', + 'minimize', + baseline.longestWorkSequence, + candidate.longestWorkSequence, + candidate.longestWorkSequence <= baseline.longestWorkSequence, + ], + [ + 'protected:short-sequence-deficit', + 'minimize', + baseline.shortSequenceDeficit, + candidate.shortSequenceDeficit, + candidate.shortSequenceDeficit <= baseline.shortSequenceDeficit, + ], + ] as const) { + same(name, direction, before, after); + if (!isAllowed) + return reject( + name, + `This supporting placement worsens ${name.slice('protected:'.length).replaceAll('-', ' ')}.`, + ); + } + return { technicalComparison: values }; +} + +function supportingRemovalTraceDetails( + baseline: PlanningMetrics, + candidate: PlanningMetrics, +): QualityTraceDetails { + const values: QualityTraceComparator[] = []; + const reject = (name: string, message: string): QualityTraceDetails => ({ + technicalComparison: values, + firstDecisiveComparator: name, + reason: reason('quality-protected-objective', message), + }); + if ( + !addQualityComparator( + values, + 'protected:unplaced-at-risk', + 'minimize', + baseline.unplacedAtRisk, + candidate.unplacedAtRisk, + ) + ) + return reject( + 'protected:unplaced-at-risk', + 'Removing this supporting placement changes unplaced at-risk leave.', + ); + for (const [name, direction, before, after, isAllowed] of [ + [ + 'protected:holiday-week-value', + 'maximize', + baseline.holidayWeekValue, + candidate.holidayWeekValue, + candidate.holidayWeekValue >= baseline.holidayWeekValue, + ], + [ + 'protected:non-working-day-gain', + 'maximize', + baseline.nonWorkingDayGain, + candidate.nonWorkingDayGain, + candidate.nonWorkingDayGain >= baseline.nonWorkingDayGain, + ], + [ + 'protected:longest-work-sequence', + 'minimize', + baseline.longestWorkSequence, + candidate.longestWorkSequence, + candidate.longestWorkSequence <= baseline.longestWorkSequence, + ], + [ + 'protected:short-sequence-deficit', + 'minimize', + baseline.shortSequenceDeficit, + candidate.shortSequenceDeficit, + candidate.shortSequenceDeficit <= baseline.shortSequenceDeficit, + ], + ] as const) { + addQualityComparator(values, name, direction, before, after); + if (!isAllowed) + return reject( + name, + `Removing this supporting placement worsens ${name.slice('protected:'.length).replaceAll('-', ' ')}.`, + ); + } + return { technicalComparison: values }; +} + function supportingPlacements( state: State, context: SearchContext, @@ -3582,15 +5292,6 @@ function supportingPlacements( const feasibleCandidate = finalizeEarnedState(candidate, context); if (!feasibleCandidate) continue; const metrics = qualityMetrics(feasibleCandidate, context, preferences); - const evaluation = recordTraceEvaluation( - context, - result, - feasibleCandidate, - preferences, - before, - metrics, - [date], - ); const preservesHigherMetrics = metrics.unplacedAtRisk === before.unplacedAtRisk && metrics.preferenceSatisfaction.length === @@ -3602,14 +5303,89 @@ function supportingPlacements( metrics.nonWorkingDayGain >= before.nonWorkingDayGain && metrics.longestWorkSequence <= before.longestWorkSequence && metrics.shortSequenceDeficit <= before.shortSequenceDeficit; + if (!preservesHigherMetrics) { + recordTraceEvaluation( + context, + result, + feasibleCandidate, + preferences, + before, + metrics, + [date], + undefined, + () => supportingProtectionTraceDetails(before, metrics, preferences), + ); + continue; + } const useful = - preservesHigherMetrics && - (metrics.holidayWeekValue > before.holidayWeekValue || - metrics.nonWorkingDayGain > before.nonWorkingDayGain || - metrics.longestWorkSequence < before.longestWorkSequence || - metrics.shortSequenceDeficit < before.shortSequenceDeficit); - if (!useful) continue; - if (!bestMetrics || compareMetrics(metrics, bestMetrics) < 0) { + metrics.holidayWeekValue > before.holidayWeekValue || + metrics.nonWorkingDayGain > before.nonWorkingDayGain || + metrics.longestWorkSequence < before.longestWorkSequence || + metrics.shortSequenceDeficit < before.shortSequenceDeficit; + if (!useful) { + recordTraceEvaluation( + context, + result, + feasibleCandidate, + preferences, + before, + metrics, + [date], + undefined, + () => ({ + ...supportingProtectionTraceDetails(before, metrics, preferences), + reason: reason( + 'quality-no-improvement', + 'This supporting placement does not improve a protected quality objective.', + ), + }), + ); + continue; + } + const better = !bestMetrics || compareMetrics(metrics, bestMetrics) < 0; + const evaluation = recordTraceEvaluation( + context, + result, + feasibleCandidate, + preferences, + before, + metrics, + [date], + undefined, + () => { + const protectedDetails = supportingProtectionTraceDetails( + before, + metrics, + preferences, + ); + const incumbentDetails = bestMetrics + ? qualityMetricTraceDetails(bestMetrics, metrics) + : undefined; + return { + technicalComparison: [ + ...(protectedDetails.technicalComparison ?? []), + ...(incumbentDetails?.technicalComparison ?? []), + ], + firstDecisiveComparator: incumbentDetails?.firstDecisiveComparator, + reason: better + ? undefined + : reason( + 'quality-no-improvement', + 'This candidate did not outrank the current best supporting placement.', + ), + }; + }, + ); + if (better) { + if (bestEvaluation) + updateTraceEvaluationReason( + context, + bestEvaluation, + reason( + 'quality-outperformed', + 'A later candidate became the best supporting placement.', + ), + ); best = feasibleCandidate; bestMetrics = metrics; bestEvaluation = evaluation; @@ -3635,11 +5411,18 @@ function pruneRedundantSupportingPlacements( const baseline = qualityMetrics(result, context, preferences); for (const placement of [...result.placements.values()].filter((item) => item.supporting)) { context.check(); + recordTraceDecision(context, [placement.date]); const candidate = cloneState(result); candidate.placements.delete(placement.date); const balance = candidate.balances.find((item) => item.id === placement.balanceId); if (balance) balance.remaining++; const metrics = qualityMetrics(candidate, context, preferences); + const removesWithoutQualityLoss = + metrics.unplacedAtRisk === baseline.unplacedAtRisk && + metrics.holidayWeekValue >= baseline.holidayWeekValue && + metrics.nonWorkingDayGain >= baseline.nonWorkingDayGain && + metrics.longestWorkSequence <= baseline.longestWorkSequence && + metrics.shortSequenceDeficit <= baseline.shortSequenceDeficit; const evaluation = recordTraceEvaluation( context, result, @@ -3649,14 +5432,15 @@ function pruneRedundantSupportingPlacements( metrics, [placement.date], placement.date, + () => { + const details = supportingRemovalTraceDetails(baseline, metrics); + return { + ...details, + reason: removesWithoutQualityLoss ? undefined : details.reason, + }; + }, ); - if ( - metrics.unplacedAtRisk === baseline.unplacedAtRisk && - metrics.holidayWeekValue >= baseline.holidayWeekValue && - metrics.nonWorkingDayGain >= baseline.nonWorkingDayGain && - metrics.longestWorkSequence <= baseline.longestWorkSequence && - metrics.shortSequenceDeficit <= baseline.shortSequenceDeficit - ) { + if (removesWithoutQualityLoss) { recordTraceState(context, candidate, evaluation); result = candidate; removed = true; @@ -3679,15 +5463,21 @@ function compareMetrics(a: PlanningMetrics, b: PlanningMetrics): number { return a.supportingPlacements - b.supportingPlacements; } -export type PlanningCheckpoint = (plan: PlanOutcome) => void; +export type PlanningCheckpoint = (plan: PlanOutcome, state?: PlanningTraceState) => void; export interface PlanningArtifacts { explanation?: PlanningExplanation; trace?: PlanningTrace; finalState?: PlanningTraceState; explanationError?: PlanningReason; + timings?: PlanningArtifactTimings; } export type PlanningArtifactsCallback = (artifacts: PlanningArtifacts) => void; export type PlanningOutcomeCallback = (outcome: PlanningOutcome) => void; +export type PlanningExplanationCallback = ( + explanation: PlanningExplanation | undefined, + explanationError: PlanningReason | undefined, + finalState: PlanningTraceState | undefined, +) => void; /** @internal Worker-only synchronous core. Application code uses the public calculation context. */ export function calculatePlanningInternal( @@ -3695,6 +5485,7 @@ export function calculatePlanningInternal( checkpointCallback?: PlanningCheckpoint, artifactsCallback?: PlanningArtifactsCallback, outcomeCallback?: PlanningOutcomeCallback, + explanationCallback?: PlanningExplanationCallback, ): PlanningOutcome { const source = runtimeRequest(request); const normalized = normalizeRequest(source); @@ -3749,7 +5540,11 @@ export function calculatePlanningInternal( preferenceDemandByDate: new Map(), anyPreferenceDemandByDate: new Map(), traceTime: 0, + traceCaptureTime: 0, + traceTransferTime: 0, traceReportedTime: 0, + observerTime: 0, + obligationSolutionBranches: new WeakMap(), } as SearchContext; const traceStarted = now(); const traceRecorder = artifactsCallback @@ -3765,15 +5560,23 @@ export function calculatePlanningInternal( }, planningStages(context), new Set(workingDays), + typeof context.request.traceProgress === 'function', ) : undefined; - if (traceRecorder) context.traceTime += now() - traceStarted; + if (traceRecorder) { + const elapsed = now() - traceStarted; + context.traceTime += elapsed; + context.traceCaptureTime += elapsed; + } context.trace = traceRecorder; context.holidayWeeks = holidayWeekCandidates(context); context.check = () => { context.nodes++; if (context.deadline <= 1 || context.nodes % 16 === 0) { - if (now() - context.started - context.traceTime >= context.deadline) + if ( + now() - context.started - context.traceTime - context.observerTime >= + context.deadline + ) throw new DeadlineReached(); } }; @@ -3791,12 +5594,16 @@ export function calculatePlanningInternal( let checkpoint: PlanOutcome | undefined; let checkpointState: State | undefined; const finish = (outcome: PlanningOutcome, explanationState?: State): PlanningOutcome => { + const planningFinishedAt = now(); + const planningExcludedTime = context.traceTime + context.observerTime; outcomeCallback?.(outcome); + const materializationStarted = now(); // Explanation materialization is post-outcome work and must not consume or // inherit the planning deadline. context.check = () => undefined; let explanation: PlanningExplanation | undefined; let explanationError: PlanningReason | undefined; + let finalState: PlanningTraceState | undefined; try { if (outcome.kind === 'Plan' && explanationState) explanation = planExplanation( @@ -3847,6 +5654,27 @@ export function calculatePlanningInternal( error instanceof Error ? error.message : String(error), ); } + try { + if (explanationState) + finalState = planningTraceState(explanationState, context.horizon); + } catch (error) { + explanationError ??= reason( + 'explanation-error', + error instanceof Error ? error.message : String(error), + ); + } + let explanationDeliveryTime: number; + const explanationDeliveryStarted = now(); + try { + explanationCallback?.(explanation, explanationError, finalState); + } catch (error) { + explanationError ??= reason( + 'explanation-error', + error instanceof Error ? error.message : String(error), + ); + } finally { + explanationDeliveryTime = now() - explanationDeliveryStarted; + } let trace: PlanningTrace | undefined; try { trace = traceRecorder?.finish( @@ -3859,7 +5687,7 @@ export function calculatePlanningInternal( ? outcome.completedStages : outcome.kind === 'PlanningIncomplete' ? outcome.completedStages - : [], + : stages, context.traceError ?? (outcome.kind === 'PlanningIncomplete' ? outcome.reasons[0] : undefined), ); @@ -3881,23 +5709,75 @@ export function calculatePlanningInternal( }, phases: [], completedStages: [], + nonWorkingDates: dateRange(horizon.start, horizon.end) + .filter((date) => !workingDays.includes(date)) + .map((date) => ({ + date, + reason: reason('non-working', 'The date is a weekend or public holiday.'), + })), error: traceError, }; } + let finalTraceDeliveryTime = 0; + if ( + context.trace === traceRecorder && + typeof context.request.traceProgress === 'function' + ) { + const update = traceRecorder?.drainUpdate(); + if (update) { + const deliveryStarted = now(); + try { + (context.request.traceProgress as PlanningTraceProgressCallback)( + context.traceTime, + update, + ); + } catch (error) { + const traceError = reason( + 'trace-error', + error instanceof Error ? error.message : String(error), + ); + trace = trace ? { ...trace, status: 'failed', error: traceError } : undefined; + context.traceError = traceError; + } finally { + finalTraceDeliveryTime = now() - deliveryStarted; + context.traceTime += finalTraceDeliveryTime; + context.traceTransferTime += finalTraceDeliveryTime; + } + } + } + const traceMaterializationMs = Math.max( + 0, + now() - materializationStarted - explanationDeliveryTime - finalTraceDeliveryTime, + ); artifactsCallback?.({ explanation, trace, - finalState: explanationState - ? planningTraceState(explanationState, context.horizon) - : undefined, + finalState, explanationError, + timings: { + planningWorkMs: Math.max(0, planningFinishedAt - started - planningExcludedTime), + traceCaptureMs: context.traceCaptureTime, + traceMaterializationMs, + traceTransferMs: context.traceTransferTime, + }, }); return outcome; }; const publishCheckpoint = (): void => { checkpoint = makePlan(state, context, preferences, stages); checkpointState = cloneState(state); - checkpointCallback?.(checkpoint); + if (checkpointCallback) { + let checkpointSnapshot: PlanningTraceState; + const captureStarted = now(); + try { + checkpointSnapshot = planningTraceState(checkpointState, context.horizon); + } finally { + const captureTime = now() - captureStarted; + context.traceTime += captureTime; + context.traceCaptureTime += captureTime; + } + observeCallback(context, () => checkpointCallback(checkpoint!, checkpointSnapshot)); + } }; try { markProgress(context, 'validation'); @@ -3941,36 +5821,65 @@ export function calculatePlanningInternal( obligationSolutionLimit, ); if (!obligationSolutions.length) { + stages.push('obligations'); return finish(obligationConflict(obligationRequirements, context)); } const preferenceRules = normalized.rules.filter( (item) => item.enabled && item.force === 'preference', ); - let bestPreview: { - obligationState: State; - state: State; - preferences: PreferenceResult[]; - metrics: PlanningMetrics; - } | null = null; + let bestPreview: ObligationPreview | null = null; for (const obligationSolution of obligationSolutions) { context.check(); - const atRiskState = placeAtRisk(cloneState(obligationSolution), context); - const preview = applyPreferenceRules(atRiskState, preferenceRules, context); - const previewState = finalizeEarnedState(preview.state, context); - if (!previewState) continue; - const candidate = { - obligationState: obligationSolution, - state: previewState, - preferences: preview.preferences, - metrics: qualityMetrics(previewState, context, preview.preferences), - }; - if (!bestPreview || compareObligationPreviews(candidate, bestPreview) < 0) - bestPreview = candidate; + const obligationPath = context.obligationSolutionBranches.get(obligationSolution) ?? []; + const previousBranchActive = context.traceBranchActive; + const previousBranchParent = context.traceBranchParentId; + context.traceBranchActive = true; + context.traceBranchParentId = obligationPath.at(-1)?.decisionId; + try { + const atRiskState = placeAtRisk(cloneState(obligationSolution), context); + const preview = applyPreferenceRules(atRiskState, preferenceRules, context); + const previewState = finalizeEarnedState(preview.state, context); + if (!previewState) continue; + const candidate = { + obligationState: obligationSolution, + state: previewState, + preferences: preview.preferences, + metrics: qualityMetrics(previewState, context, preview.preferences), + }; + const compared: number | undefined = bestPreview + ? compareObligationPreviews(candidate, bestPreview) + : undefined; + const branch: ObligationBranchReference | undefined = + compared !== undefined + ? recordTraceBranchDecision( + context, + previewState, + previewState, + context.traceBranchParentId, + [], + () => obligationPreviewComparisonFacts(candidate, bestPreview!), + ) + : undefined; + const previewCandidate: ObligationPreview = { + ...candidate, + previewBranch: branch, + }; + if (!bestPreview || compared! < 0) bestPreview = previewCandidate; + } finally { + context.traceBranchActive = previousBranchActive; + context.traceBranchParentId = previousBranchParent; + } } if (!bestPreview) { + stages.push('obligations'); return finish(obligationConflict(obligationRequirements, context)); } state = bestPreview.obligationState; + chooseObligationBranches(state, context); + if (bestPreview.previewBranch) + observeTrace(context, (trace) => + trace.chooseEvaluation(bestPreview!.previewBranch!.candidate), + ); stages.push('obligations'); publishCheckpoint(); markProgress(context, 'at-risk'); @@ -4048,26 +5957,129 @@ export function calculatePlanningInternal( markProgress(context, 'complete'); return finish(checkpoint!, state); } catch (error) { - if (!(error instanceof DeadlineReached)) throw error; - const reasons = [ - reason( - 'deadline', - `Planning stopped after ${context.deadline}ms before all quality stages completed.`, - ), - ]; + const interruption = + error instanceof DeadlineReached + ? reason( + 'deadline', + `Planning stopped after ${context.deadline}ms before all quality stages completed.`, + ) + : reason('planner-error', error instanceof Error ? error.message : String(error)); return finish( { kind: 'PlanningIncomplete', incumbent: checkpoint, - completedStages: checkpoint?.completedStages ?? [], - lastCompletedStage: checkpoint?.lastCompletedStage, - reasons, + completedStages: [...stages], + lastCompletedStage: stages[stages.length - 1], + reasons: [interruption], }, checkpointState, ); } } +/** Materialize an incumbent explanation from a delivered checkpoint without rerunning planning. */ +export function explainIncompleteCheckpoint( + request: unknown, + incumbent: PlanOutcome, + checkpoint: PlanningTraceState, + interruption: PlanningReason, +): PlanningArtifacts { + try { + const source = runtimeRequest(request); + const normalized = normalizeRequest(source); + if (normalized.reasons.length || !normalized.horizon) + throw new Error('The checkpoint explanation request is invalid.'); + const horizon = normalized.horizon; + const holidays = holidaySet(horizon); + const calendarDays = dateRange(addDays(horizon.start, -400), addDays(horizon.end, 400)); + const calendarIndex = new Map(calendarDays.map((date, index) => [date, index])); + const workingCalendar = new Set(calendarDays.filter((date) => isWorking(date, holidays))); + const workingPrefix = [0]; + for (const date of calendarDays) + workingPrefix.push( + workingPrefix[workingPrefix.length - 1] + Number(workingCalendar.has(date)), + ); + const context = { + horizon, + holidayWeeks: [], + holidays, + calendarDays, + calendarIndex, + workingCalendar, + workingPrefix, + workingDays: dateRange(horizon.start, horizon.end).filter((date) => + isWorking(date, holidays), + ), + initialBalances: normalized.balances, + rules: normalized.rules, + deadline: Number.POSITIVE_INFINITY, + started: 0, + request: source, + nodes: 0, + check: () => undefined, + reservedTypes: new Set(), + sharedTypesByDate: new Map>(), + protectedTypesByDate: new Map>(), + preferenceDemandByDate: new Map(), + anyPreferenceDemandByDate: new Map(), + traceTime: 0, + traceCaptureTime: 0, + traceTransferTime: 0, + traceReportedTime: 0, + observerTime: 0, + obligationSolutionBranches: new WeakMap(), + } as SearchContext; + context.holidayWeeks = holidayWeekCandidates(context); + const state: State = { + balances: checkpoint.balances.map((balance) => ({ ...balance })), + placements: new Map( + checkpoint.placements.map((placement) => [ + placement.date, + { ...placement, ruleIds: new Set(placement.ruleIds) }, + ]), + ), + earnedActivations: checkpoint.earnedActivations.map((activation) => ({ + ...activation, + dates: [...activation.dates], + })), + }; + const explanation = planExplanation( + state, + context, + incumbent.placements, + incumbent.timeOffPeriods, + incumbent.preferences, + incumbent.remainingBalances, + incumbent.metrics, + ); + const uncompletedStages = planningStages(context).filter( + (stage) => !incumbent.completedStages.includes(stage), + ); + return { + explanation: + uncompletedStages.length === 0 + ? explanation + : { + ...explanation, + incomplete: { + kind: 'IncompleteExplanation', + reason: interruption, + completedStages: [...incumbent.completedStages], + uncompletedStages, + }, + }, + finalState: planningTraceState(state, horizon), + }; + } catch (error) { + return { + explanationError: reason( + 'explanation-error', + error instanceof Error ? error.message : String(error), + ), + }; + } +} + function counterfactualReason( code: CounterfactualReasonCode, message: string, @@ -4075,6 +6087,130 @@ function counterfactualReason( return { code, message }; } +function counterfactualContext( + source: RuntimePlanningRequest, + normalized: ReturnType, + horizon: { start: string; end: string }, +): SearchContext { + const holidays = holidaySet(horizon); + const calendarDays = dateRange(addDays(horizon.start, -400), addDays(horizon.end, 400)); + const calendarIndex = new Map(calendarDays.map((date, index) => [date, index])); + const workingCalendar = new Set(calendarDays.filter((date) => isWorking(date, holidays))); + const workingPrefix = [0]; + for (const date of calendarDays) + workingPrefix.push( + workingPrefix[workingPrefix.length - 1] + Number(workingCalendar.has(date)), + ); + const context = { + horizon, + holidayWeeks: [], + holidays, + calendarDays, + calendarIndex, + workingCalendar, + workingPrefix, + workingDays: dateRange(horizon.start, horizon.end).filter((date) => + isWorking(date, holidays), + ), + initialBalances: normalized.balances, + rules: normalized.rules, + deadline: Number.POSITIVE_INFINITY, + started: 0, + request: source, + nodes: 0, + check: () => undefined, + reservedTypes: new Set(), + sharedTypesByDate: new Map>(), + protectedTypesByDate: new Map>(), + preferenceDemandByDate: new Map(), + anyPreferenceDemandByDate: new Map(), + traceTime: 0, + traceCaptureTime: 0, + traceTransferTime: 0, + traceReportedTime: 0, + observerTime: 0, + obligationSolutionBranches: new WeakMap(), + } as SearchContext; + context.holidayWeeks = holidayWeekCandidates(context); + return context; +} + +function stateFromPlanningTrace(state: PlanningTraceState): State { + return { + balances: state.balances.map((balance) => ({ ...balance })), + placements: new Map( + state.placements.map((placement) => [ + placement.date, + { ...placement, ruleIds: new Set(placement.ruleIds) }, + ]), + ), + earnedActivations: state.earnedActivations.map((activation) => ({ + ...activation, + dates: [...activation.dates], + })), + }; +} + +/** Explain basic date eligibility against a historical source state without validating future obligations. */ +export function historicalCounterfactualReasons( + request: unknown, + sourceState: PlanningTraceState, + selected: LeavePlacement, + options?: CounterfactualBaselineOptions, +): readonly HistoricalCounterfactualReason[] { + const source = runtimeRequest(request); + const normalized = normalizeRequest(source); + const horizon = normalized.horizon ?? sourceState.horizon; + const context = counterfactualContext(source, normalized, horizon); + const baseline = stateFromPlanningTrace(sourceState); + const removeDate = options ? options.removeDate : selected.date; + const removed = removeDate ? baseline.placements.get(removeDate) : undefined; + if (removeDate) baseline.placements.delete(removeDate); + const balance = baseline.balances.find((candidate) => candidate.id === selected.balanceId); + if (balance && removed?.balanceId === selected.balanceId) balance.remaining++; + const results: HistoricalCounterfactualReason[] = []; + for (const date of dateRange(horizon.start, horizon.end)) { + let ineligible: (PlanningReason & { code: CounterfactualReasonCode }) | undefined; + if (!isWorking(date, context.holidays)) + ineligible = counterfactualReason( + 'non-working', + 'The date is a weekend or public holiday.', + ); + else if (baseline.placements.has(date)) + ineligible = counterfactualReason( + 'occupied', + 'Another placement already uses this date.', + ); + else if (!balance || balance.remaining <= 0) + ineligible = counterfactualReason( + 'unavailable', + 'The selected leave entitlement is unavailable.', + ); + else if (compareDate(balance.availableFrom, date) > 0) + ineligible = counterfactualReason( + 'unavailable', + 'The selected entitlement is not available yet.', + ); + else if (compareDate(date, balance.expiryDate) > 0) + ineligible = counterfactualReason( + 'expired', + 'The selected entitlement has expired by this date.', + ); + else if (balance.type !== selected.type) + ineligible = counterfactualReason( + 'type-incompatible', + 'The date cannot host this leave type.', + ); + else if (!counterfactualRuleValid(date, selected.type, selected.ruleIds, context, baseline)) + ineligible = counterfactualReason( + 'rule-invalid', + 'The date does not preserve the placement rules.', + ); + if (ineligible) results.push({ date, reason: ineligible }); + } + return results; +} + function sameObjectiveValue(a: number | string, b: number | string): boolean { return a === b; } @@ -4222,56 +6358,9 @@ export function analyzeCounterfactualInternal( const source = runtimeRequest(request); const normalized = normalizeRequest(source); const horizon = normalized.horizon ?? plan.horizon; - const holidays = holidaySet(horizon); - const calendarDays = dateRange(addDays(horizon.start, -400), addDays(horizon.end, 400)); - const calendarIndex = new Map(calendarDays.map((date, index) => [date, index])); - const workingCalendar = new Set(calendarDays.filter((date) => isWorking(date, holidays))); - const workingPrefix = [0]; - for (const date of calendarDays) - workingPrefix.push( - workingPrefix[workingPrefix.length - 1] + Number(workingCalendar.has(date)), - ); - const context = { - horizon, - holidays, - holidayWeeks: [], - calendarDays, - calendarIndex, - workingCalendar, - workingPrefix, - workingDays: dateRange(horizon.start, horizon.end).filter((date) => - isWorking(date, holidays), - ), - initialBalances: normalized.balances, - rules: normalized.rules, - deadline: Number.POSITIVE_INFINITY, - started: 0, - request: source, - nodes: 0, - check: () => undefined, - reservedTypes: new Set(), - sharedTypesByDate: new Map>(), - protectedTypesByDate: new Map>(), - preferenceDemandByDate: new Map(), - anyPreferenceDemandByDate: new Map(), - traceTime: 0, - traceReportedTime: 0, - } as SearchContext; - context.holidayWeeks = holidayWeekCandidates(context); + const context = counterfactualContext(source, normalized, horizon); const finalState: State = finalStateSnapshot - ? { - balances: finalStateSnapshot.balances.map((balance) => ({ ...balance })), - placements: new Map( - finalStateSnapshot.placements.map((placement) => [ - placement.date, - { ...placement, ruleIds: new Set(placement.ruleIds) }, - ]), - ), - earnedActivations: finalStateSnapshot.earnedActivations.map((activation) => ({ - ...activation, - dates: [...activation.dates], - })), - } + ? stateFromPlanningTrace(finalStateSnapshot) : (() => { const balances = normalized.balances.map((balance) => ({ ...balance, @@ -4320,11 +6409,11 @@ export function analyzeCounterfactualInternal( const beforeState = options?.compareOriginalState ? comparisonState : baseline; const beforePreferences = preferenceResultsForState(beforeState, context); const beforeMetrics = qualityMetrics(beforeState, context, beforePreferences); - const before = objectiveVector(beforeMetrics, beforePreferences, beforeState); + const before = objectiveVector(beforeMetrics, beforePreferences, beforeState, context); const results: CounterfactualDateResult[] = []; for (const date of dateRange(horizon.start, horizon.end)) { let ineligible: (PlanningReason & { code: CounterfactualReasonCode }) | undefined; - if (!isWorking(date, holidays)) + if (!isWorking(date, context.holidays)) ineligible = counterfactualReason( 'non-working', 'The date is a weekend or public holiday.', @@ -4382,7 +6471,26 @@ export function analyzeCounterfactualInternal( continue; } candidate.placements.get(date)!.supporting = selected.supporting; - if (!counterfactualObligationsValid(candidate, context, comparisonState)) { + if (removeDate && removeDate !== date) + for (const activation of candidate.earnedActivations) { + if (!selected.ruleIds.includes(activation.ruleId)) continue; + activation.dates = activation.dates.map((activationDate) => + activationDate === removeDate ? date : activationDate, + ); + } + const finalized = finalizeEarnedState(candidate, context); + if (!finalized) { + results.push({ + date, + status: 'ineligible', + reason: counterfactualReason( + 'unavailable', + 'This date would make generated leave unavailable until its producing period ends.', + ), + }); + continue; + } + if (!counterfactualObligationsValid(finalized, context, comparisonState)) { results.push({ date, status: 'ineligible', @@ -4393,9 +6501,9 @@ export function analyzeCounterfactualInternal( }); continue; } - const afterPreferences = preferenceResultsForState(candidate, context); - const afterMetrics = qualityMetrics(candidate, context, afterPreferences); - const after = objectiveVector(afterMetrics, afterPreferences, candidate); + const afterPreferences = preferenceResultsForState(finalized, context); + const afterMetrics = qualityMetrics(finalized, context, afterPreferences); + const after = objectiveVector(afterMetrics, afterPreferences, finalized, context); const comparison = objectiveComparison(before, after); results.push({ date, status: 'eligible', comparison }); } diff --git a/src/app/services/leave-planner.service.spec.ts b/src/app/services/leave-planner.service.spec.ts index 834f811..ede3de5 100644 --- a/src/app/services/leave-planner.service.spec.ts +++ b/src/app/services/leave-planner.service.spec.ts @@ -1,10 +1,20 @@ import { vi } from 'vitest'; import { + applyReplayDeltas, PlanningOutcome, PlanningRequest, createPlanningCalculation, planLeave, } from './leave-planner.service'; +import type { + CounterfactualAnalysis, + HistoricalEligibilityReason, + PlanOutcome, + PlanningReason, + PlanningTraceState, + PlanningTraceUpdate, +} from './leave-planner.service'; +import type { CounterfactualBaselineOptions } from './leave-planner.core'; const horizon = { start: '2024-01-01', end: '2025-01-01' }; const balance = ( @@ -1033,19 +1043,23 @@ describe('planLeave', () => { } } vi.stubGlobal('Worker', ZeroBudgetWorker); + vi.useFakeTimers(); try { - const result = await planLeave({ horizon, balances: [], rules: [], deadlineMs: 0 }); + const resultPromise = planLeave({ horizon, balances: [], rules: [], deadlineMs: 0 }); + await vi.advanceTimersByTimeAsync(30_000); + const result = await resultPromise; expect(result.kind).toBe('PlanningIncomplete'); expect(started).toBe(true); expect(terminated).toBe(true); } finally { + vi.useRealTimers(); vi.unstubAllGlobals(); } }); }); describe('planLeave transport', () => { - it('terminates an isolated calculation at its wall deadline with its incumbent', async () => { + it('terminates an isolated worker that misses the liveness deadline with its incumbent', async () => { const incumbent = isPlan( await runInNonBrowser({ horizon, @@ -1069,25 +1083,173 @@ describe('planLeave transport', () => { } let terminated = false; vi.stubGlobal('Worker', HangingWorker); + vi.useFakeTimers(); try { - const result = await planLeave({ + const resultPromise = planLeave({ horizon, balances: [balance('cp', 'cp', 2)], rules: [], deadlineMs: 10, }); + await vi.advanceTimersByTimeAsync(30_000); + const result = await resultPromise; expect(result.kind).toBe('PlanningIncomplete'); if (result.kind === 'PlanningIncomplete') { expect(result.incumbent?.kind).toBe('Plan'); expect(result.lastCompletedStage).toBe('dates'); + expect(result.reasons[0].code).toBe('worker-error'); } expect(terminated).toBe(true); } finally { + vi.useRealTimers(); vi.unstubAllGlobals(); } }); + it('keeps the delivered checkpoint prefix and recovers its explanation after watchdog failure', async () => { + const core = await import('./leave-planner.core'); + const request = { + horizon: { start: '2024-01-01', end: '2024-01-10' }, + balances: [balance('cp', 'cp', 1)], + rules: [], + }; + let checkpoint: PlanOutcome | undefined; + let checkpointState: PlanningTraceState | undefined; + const expected = core.calculatePlanningInternal(request, (plan, state) => { + checkpoint = plan; + checkpointState = state; + }); + expect(expected.kind).toBe('Plan'); + if (expected.kind !== 'Plan' || !checkpointState) return; + checkpoint = { + ...expected, + completedStages: ['validation', 'obligations', 'at-risk'], + lastCompletedStage: 'at-risk', + }; + const initialState: PlanningTraceState = { + horizon: request.horizon, + balances: [ + { + id: 'cp', + type: 'cp', + quantity: 1, + remaining: 1, + availableFrom: '2024-01-01', + expiryDate: '2024-12-31', + earned: false, + }, + ], + placements: [], + earnedActivations: [], + }; + const prefix: PlanningTraceUpdate = { + initialState, + operations: [ + { + kind: 'phase', + phase: { + id: 'phase:1', + name: 'dates', + completed: false, + status: 'interrupted', + }, + }, + { + kind: 'decision', + phaseId: 'phase:1', + decision: { id: 'decision:1', action: 'choose', dates: ['2024-01-02'] }, + }, + { + kind: 'candidate', + phaseId: 'phase:1', + decisionId: 'decision:1', + index: 0, + mode: 'append', + candidate: { status: 'evaluated-rejected', date: '2024-01-02' }, + }, + ], + }; + const workers: Array<{ terminated: boolean }> = []; + class RecoveringWorker { + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: (() => void) | null = null; + terminated = false; + + constructor() { + workers.push(this); + } + + postMessage( + message: + | { type: 'plan' } + | { + type: 'explain-checkpoint'; + request: unknown; + plan: PlanOutcome; + state: PlanningTraceState; + interruption: PlanningReason; + }, + ): void { + if (message.type === 'plan') { + queueMicrotask(() => { + this.onmessage?.({ + data: { type: 'checkpoint', plan: checkpoint, state: checkpointState }, + } as MessageEvent); + this.onmessage?.({ + data: { type: 'trace-update', totalMs: 0, update: prefix }, + } as MessageEvent); + }); + return; + } + if (message.type === 'explain-checkpoint') + queueMicrotask(() => { + const recovered = core.explainIncompleteCheckpoint( + message.request, + message.plan, + message.state, + message.interruption, + ); + this.onmessage?.({ + data: { type: 'explanation', ...recovered }, + } as MessageEvent); + }); + } + + terminate(): void { + this.terminated = true; + } + } + const browserWindow = globalThis.window; + vi.stubGlobal('window', {}); + vi.stubGlobal('Worker', RecoveringWorker); + vi.useFakeTimers(); + try { + const calculation = createPlanningCalculation({ ...request, deadlineMs: 20 }); + await vi.runAllTicks(); + await vi.advanceTimersByTimeAsync(30_000); + const outcome = await calculation.outcome; + expect(outcome.kind).toBe('PlanningIncomplete'); + if (outcome.kind === 'PlanningIncomplete') + expect(outcome.incumbent).toEqual(checkpoint); + const trace = await calculation.trace; + expect(trace?.status).toBe('failed'); + expect(trace?.error?.code).toBe('worker-error'); + expect(trace?.phases[0].decisions[0].candidates).toHaveLength(1); + const explanation = await calculation.explanation; + expect(explanation?.kind).toBe('PlanExplanation'); + if (explanation?.kind === 'PlanExplanation') + expect(explanation.incomplete?.reason.code).toBe('worker-error'); + expect((await calculation.explanationError)?.code).toBe('worker-error'); + expect(workers).toHaveLength(2); + expect(workers.every((worker) => worker.terminated)).toBe(true); + } finally { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.stubGlobal('window', browserWindow); + } + }); + it('reports no completed stage when an isolated worker never checkpoints', async () => { class SilentWorker { onmessage: ((event: MessageEvent) => void) | null = null; @@ -1100,25 +1262,30 @@ describe('planLeave transport', () => { terminate(): void {} } vi.stubGlobal('Worker', SilentWorker); + vi.useFakeTimers(); try { - const result = await planLeave({ + const resultPromise = planLeave({ horizon, balances: [balance('cp', 'cp', 2)], rules: [], deadlineMs: 10, }); + await vi.advanceTimersByTimeAsync(30_000); + const result = await resultPromise; expect(result.kind).toBe('PlanningIncomplete'); if (result.kind === 'PlanningIncomplete') { expect(result.incumbent).toBeUndefined(); expect(result.completedStages).toEqual([]); expect(result.lastCompletedStage).toBeUndefined(); + expect(result.reasons[0].code).toBe('worker-error'); } } finally { + vi.useRealTimers(); vi.unstubAllGlobals(); } }); - it('does not charge reported trace instrumentation time to the planning deadline', async () => { + it('does not let excluded trace transfer time consume worker liveness before the outcome', async () => { const expected = isPlan( await runInNonBrowser({ horizon, balances: [balance('cp', 'cp', 1)], rules: [] }), ); @@ -1127,15 +1294,18 @@ describe('planLeave transport', () => { onerror: (() => void) | null = null; postMessage(): void { - this.onmessage?.({ data: { type: 'trace-overhead', totalMs: 30 } } as MessageEvent); + this.onmessage?.({ data: { type: 'trace-overhead', totalMs: 0 } } as MessageEvent); setTimeout(() => { + this.onmessage?.({ + data: { type: 'trace-overhead', totalMs: 300 }, + } as MessageEvent); this.onmessage?.({ data: { type: 'result', outcome: expected }, } as MessageEvent); this.onmessage?.({ data: { type: 'artifacts', artifacts: {} }, } as MessageEvent); - }, 15); + }, 300); } terminate(): void {} @@ -1147,7 +1317,7 @@ describe('planLeave transport', () => { horizon, balances: [balance('cp', 'cp', 1)], rules: [], - deadlineMs: 10, + deadlineMs: 100, }), ).toEqual(expected); } finally { @@ -1241,6 +1411,98 @@ describe('planLeave transport', () => { }); describe('planning calculation context', () => { + it('replays full balance metadata when an existing balance changes', () => { + const state = { + balances: [ + { + id: 'earned:producer:2024-01-04', + type: 'rtt' as const, + quantity: 0, + remaining: 0, + availableFrom: '2024-01-01', + expiryDate: '2024-12-31', + earned: false, + }, + ], + placements: new Map(), + earnedActivations: [], + }; + + applyReplayDeltas(state, [ + { + kind: 'balance-changed', + balanceId: 'earned:producer:2024-01-04', + remainingBefore: 0, + remainingAfter: 1, + balance: { + id: 'earned:producer:2024-01-04', + type: 'rtt', + quantity: 1, + remaining: 1, + availableFrom: '2024-01-05', + expiryDate: '2024-12-31', + earned: true, + sourceRuleId: 'producer', + }, + }, + ]); + + expect(state.balances[0]).toEqual({ + id: 'earned:producer:2024-01-04', + type: 'rtt', + quantity: 1, + remaining: 1, + availableFrom: '2024-01-05', + expiryDate: '2024-12-31', + earned: true, + sourceRuleId: 'producer', + }); + }); + + it('applies placement delta balance changes to the replayed balance', () => { + const state = { + balances: [ + { + id: 'cp', + type: 'cp' as const, + quantity: 1, + remaining: 1, + availableFrom: '2024-01-01', + expiryDate: '2024-12-31', + earned: false, + }, + ], + placements: new Map(), + earnedActivations: [], + }; + + applyReplayDeltas(state, [ + { + kind: 'placement-added', + balanceId: 'cp', + remainingBefore: 1, + remainingAfter: 0, + placement: { + date: '2024-01-04', + balanceId: 'cp', + type: 'cp', + ruleIds: [], + supporting: false, + }, + }, + ]); + + expect(state.balances[0]).toEqual({ + id: 'cp', + type: 'cp', + quantity: 1, + remaining: 0, + availableFrom: '2024-01-01', + expiryDate: '2024-12-31', + earned: false, + }); + }); + it('keeps the planning outcome identical when trace instrumentation is enabled', async () => { const core = await import('./leave-planner.core'); const request = { @@ -1280,6 +1542,25 @@ describe('planning calculation context', () => { } }); + it('lets validation reject an invalid Date in the submitted horizon', async () => { + const browserWindow = globalThis.window; + vi.stubGlobal('window', undefined); + try { + const calculation = createPlanningCalculation({ + horizon: { start: new Date(Number.NaN), end: '2025-01-01' }, + balances: [], + rules: [], + }); + const outcome = await calculation.outcome; + expect(outcome.kind).toBe('InvalidInput'); + if (outcome.kind === 'InvalidInput') + expect(outcome.reasons[0].code).toBe('horizon-start'); + expect(calculation.snapshot.horizon.start).toBe('Invalid Date'); + } finally { + vi.stubGlobal('window', browserWindow); + } + }); + it('materializes a chronological explanation after the unchanged outcome', async () => { const browserWindow = globalThis.window; vi.stubGlobal('window', undefined); @@ -1670,6 +1951,168 @@ describe('planning calculation context', () => { } }); + it('keeps counterfactual worker failures local and allows a clean retry', async () => { + const request = { + horizon: { start: '2024-01-01', end: '2024-01-10' }, + balances: [balance('cp', 'cp', 1)], + rules: [], + }; + const expected = isPlan(await runInNonBrowser(request)); + const selected = expected.placements[0]; + const trace = { + status: 'ready' as const, + initialState: { + horizon: expected.horizon, + balances: [], + placements: [], + earnedActivations: [], + }, + phases: [], + completedStages: expected.completedStages, + }; + const response: CounterfactualAnalysis = { + kind: 'Counterfactual', + calculationId: 'transport-test', + placement: selected, + hypothetical: true, + baseline: { objectives: [] }, + results: [], + }; + let counterfactualCalls = 0; + class RetriableWorker { + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: (() => void) | null = null; + terminated = false; + + postMessage(message: { type: string }): void { + if (message.type === 'plan') { + queueMicrotask(() => { + this.onmessage?.({ + data: { type: 'result', outcome: expected }, + } as MessageEvent); + this.onmessage?.({ + data: { + type: 'explanation', + explanation: { kind: 'PlanExplanation', placements: [] }, + }, + } as MessageEvent); + this.onmessage?.({ + data: { + type: 'artifacts-start', + artifacts: {}, + trace, + }, + } as MessageEvent); + this.onmessage?.({ data: { type: 'artifacts-complete' } } as MessageEvent); + }); + return; + } + counterfactualCalls++; + queueMicrotask(() => { + if (counterfactualCalls === 1) + this.onmessage?.({ + data: { + type: 'error', + code: 'counterfactual-error', + message: 'Counterfactual analysis failed locally.', + }, + } as MessageEvent); + else + this.onmessage?.({ + data: { type: 'counterfactual-result', analysis: response }, + } as MessageEvent); + }); + } + + terminate(): void { + this.terminated = true; + } + } + const browserWindow = globalThis.window; + vi.stubGlobal('window', {}); + vi.stubGlobal('Worker', RetriableWorker); + try { + const calculation = createPlanningCalculation(request); + expect(await calculation.outcome).toEqual(expected); + await calculation.trace; + await expect(calculation.analyzeCounterfactual(selected)).rejects.toThrow( + 'Counterfactual analysis failed locally.', + ); + const analysis = await calculation.analyzeCounterfactual(selected); + expect(analysis.kind).toBe('Counterfactual'); + expect(analysis.calculationId).toBe(calculation.id); + expect(counterfactualCalls).toBe(2); + } finally { + vi.unstubAllGlobals(); + vi.stubGlobal('window', browserWindow); + } + }); + + it('terminates an active counterfactual worker when a newer calculation starts', async () => { + const request = { + horizon: { start: '2024-01-01', end: '2024-01-10' }, + balances: [balance('cp', 'cp', 1)], + rules: [], + }; + const expected = isPlan(await runInNonBrowser(request)); + const trace = { + status: 'ready' as const, + initialState: { + horizon: expected.horizon, + balances: [], + placements: [], + earnedActivations: [], + }, + phases: [], + completedStages: expected.completedStages, + }; + const workers: Array<{ terminated: boolean }> = []; + class SupersededCounterfactualWorker { + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: (() => void) | null = null; + terminated = false; + + constructor() { + workers.push(this); + } + + postMessage(message: { type: string }): void { + if (message.type !== 'plan') return; + queueMicrotask(() => { + this.onmessage?.({ + data: { type: 'result', outcome: expected }, + } as MessageEvent); + this.onmessage?.({ + data: { type: 'artifacts-start', artifacts: {}, trace }, + } as MessageEvent); + this.onmessage?.({ data: { type: 'artifacts-complete' } } as MessageEvent); + }); + } + + terminate(): void { + this.terminated = true; + } + } + const browserWindow = globalThis.window; + vi.stubGlobal('window', {}); + vi.stubGlobal('Worker', SupersededCounterfactualWorker); + try { + const previous = createPlanningCalculation(request); + await previous.outcome; + await previous.trace; + const pendingAnalysis = previous.analyzeCounterfactual(expected.placements[0]); + await vi.waitFor(() => expect(workers).toHaveLength(2)); + const latest = createPlanningCalculation(request); + const stale = await pendingAnalysis; + expect(stale.kind).toBe('CounterfactualStale'); + expect(workers[1].terminated).toBe(true); + expect((await latest.outcome).kind).toBe('Plan'); + } finally { + vi.unstubAllGlobals(); + vi.stubGlobal('window', browserWindow); + } + }); + it('keeps earned-entitlement availability and provenance in counterfactuals', async () => { const browserWindow = globalThis.window; vi.stubGlobal('window', undefined); @@ -1788,6 +2231,358 @@ describe('planning calculation context', () => { } }); + it('keeps source-state ineligibility separate from unexamined trace history', async () => { + const browserWindow = globalThis.window; + vi.stubGlobal('window', undefined); + try { + const calculation = createPlanningCalculation({ + horizon: { start: '2024-01-01', end: '2024-01-11' }, + balances: [ + { + ...balance('cp', 'cp', 1, '2024-01-07'), + availableFrom: '2024-01-04', + }, + ], + rules: [], + }); + const outcome = await calculation.outcome; + const trace = await calculation.trace; + expect(outcome.kind).toBe('Plan'); + if (outcome.kind !== 'Plan' || !trace) return; + const decision = trace.phases + .flatMap((phase) => phase.decisions) + .find((candidate) => + candidate.deltas.some( + (delta) => + delta.kind === 'placement-added' && + delta.placement?.date === '2024-01-04', + ), + ); + const placement = decision?.deltas.find( + (delta) => + delta.kind === 'placement-added' && delta.placement?.date === '2024-01-04', + )?.placement; + expect(placement).toBeDefined(); + expect(decision).toBeDefined(); + if (!placement || !decision) return; + + const selection = { decisionId: decision.id, placement }; + const historicalEligibility = await calculation.historicalEligibility(selection); + expect(historicalEligibility).toContainEqual({ + date: '2024-01-02', + reason: expect.objectContaining({ code: 'unavailable' }), + }); + expect(historicalEligibility).toContainEqual({ + date: '2024-01-08', + reason: expect.objectContaining({ code: 'expired' }), + }); + + const analysis = await calculation.analyzeCounterfactual(selection); + expect(analysis.kind).toBe('Counterfactual'); + if (analysis.kind !== 'Counterfactual') return; + expect(analysis.results.find((result) => result.date === '2024-01-02')).toMatchObject({ + status: 'ineligible', + historicalStatus: 'ineligible', + reason: { code: 'unavailable' }, + historicalReason: { code: 'unavailable' }, + }); + expect(analysis.results.find((result) => result.date === '2024-01-08')).toMatchObject({ + status: 'ineligible', + historicalStatus: 'ineligible', + reason: { code: 'expired' }, + historicalReason: { code: 'expired' }, + }); + expect(analysis.results.find((result) => result.date === '2024-01-04')).toMatchObject({ + historicalStatus: 'chosen', + historicalReason: undefined, + }); + } finally { + vi.stubGlobal('window', browserWindow); + } + }); + + it('replays branch-local ancestors without applying the selected decision twice', async () => { + const request = { + horizon: { start: '2024-01-01', end: '2024-01-10' }, + balances: [balance('cp', 'cp', 1)], + rules: [], + }; + const plan = isPlan(await runInNonBrowser(request)); + const firstPlacement = plan.placements[0]; + if (!firstPlacement) throw new Error('Expected the plan to contain a placement.'); + const selected = { ...firstPlacement, date: '2024-01-03' }; + const outcome = { ...plan, placements: [selected] }; + const committedPlacement = { ...selected, date: '2024-01-01' }; + const parentPlacement = { ...selected, date: '2024-01-02' }; + const initialState: PlanningTraceState = { + horizon: request.horizon, + balances: [ + { + id: 'cp', + type: 'cp', + quantity: 5, + remaining: 5, + availableFrom: '2024-01-01', + expiryDate: '2024-01-31', + earned: false, + }, + ], + placements: [], + earnedActivations: [], + }; + const committedDeltas = [ + { + kind: 'placement-added' as const, + date: committedPlacement.date, + balanceId: 'cp', + placement: committedPlacement, + }, + { + kind: 'balance-changed' as const, + balanceId: 'cp', + remainingBefore: 5, + remainingAfter: 4, + balance: { + id: 'cp', + type: 'cp' as const, + quantity: 5, + remaining: 4, + availableFrom: '2024-01-02', + expiryDate: '2024-01-31', + earned: true, + sourceRuleId: 'producer', + }, + }, + ]; + const parentDeltas = [ + { + kind: 'placement-added' as const, + date: parentPlacement.date, + balanceId: 'cp', + placement: parentPlacement, + }, + { + kind: 'balance-changed' as const, + balanceId: 'cp', + remainingBefore: 4, + remainingAfter: 3, + }, + ]; + const selectedTransition = [ + { + kind: 'placement-moved' as const, + fromDate: parentPlacement.date, + toDate: selected.date, + date: selected.date, + balanceId: 'cp', + previousPlacement: parentPlacement, + placement: selected, + }, + { + kind: 'balance-changed' as const, + balanceId: 'cp', + remainingBefore: 3, + remainingAfter: 2, + }, + ]; + const update: PlanningTraceUpdate = { + initialState, + operations: [ + { + kind: 'phase', + phase: { + id: 'obligations', + name: 'obligations', + completed: true, + status: 'completed', + }, + }, + { + kind: 'decision', + phaseId: 'obligations', + decision: { id: 'committed', action: 'choose', dates: ['2024-01-01'] }, + }, + ...committedDeltas.map((delta, index) => ({ + kind: 'delta' as const, + phaseId: 'obligations', + decisionId: 'committed', + index, + delta, + })), + { + kind: 'decision', + phaseId: 'obligations', + decision: { + id: 'branch-parent', + action: 'choose', + dates: [parentPlacement.date], + branch: { deltas: parentDeltas }, + }, + }, + { + kind: 'decision', + phaseId: 'obligations', + decision: { + id: 'branch-selected', + action: 'choose', + dates: [selected.date], + branch: { + parentDecisionId: 'branch-parent', + deltas: selectedTransition, + }, + }, + }, + ...selectedTransition.map((delta, index) => ({ + kind: 'delta' as const, + phaseId: 'obligations', + decisionId: 'branch-selected', + index, + delta, + })), + { + kind: 'decision', + phaseId: 'obligations', + decision: { + id: 'self-parent', + action: 'choose', + dates: [selected.date], + branch: { parentDecisionId: 'self-parent', deltas: [] }, + }, + }, + ], + }; + const response: CounterfactualAnalysis = { + kind: 'Counterfactual', + calculationId: 'branch-transport-test', + placement: selected, + hypothetical: true, + baseline: { objectives: [] }, + results: [], + }; + let receivedState: PlanningTraceState | undefined; + let receivedOptions: CounterfactualBaselineOptions | undefined; + class BranchTraceWorker { + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: (() => void) | null = null; + + postMessage(message: { + type: string; + state?: PlanningTraceState; + options?: CounterfactualBaselineOptions; + }): void { + if (message.type === 'plan') { + this.onmessage?.({ data: { type: 'result', outcome } } as MessageEvent); + this.onmessage?.({ + data: { + type: 'artifacts-start', + artifacts: {}, + trace: { + status: 'ready', + initialState, + phases: [], + completedStages: outcome.completedStages, + nonWorkingDates: [], + error: undefined, + }, + }, + } as MessageEvent); + this.onmessage?.({ + data: { type: 'trace-update', totalMs: 0, update }, + } as MessageEvent); + this.onmessage?.({ + data: { type: 'artifacts-complete', traceTransferMs: 0 }, + } as MessageEvent); + return; + } + receivedState = message.state; + receivedOptions = message.options; + if (message.type === 'historical-eligibility') { + queueMicrotask(() => + this.onmessage?.({ + data: { + type: 'historical-eligibility-result', + entries: [ + { + date: '2024-01-03', + reason: { + code: 'unavailable', + message: + 'The selected entitlement is not available yet.', + }, + } satisfies HistoricalEligibilityReason, + ], + }, + } as MessageEvent), + ); + return; + } + queueMicrotask(() => + this.onmessage?.({ + data: { type: 'counterfactual-result', analysis: response }, + } as MessageEvent), + ); + } + + terminate(): void {} + } + const browserWindow = globalThis.window; + vi.stubGlobal('window', {}); + vi.stubGlobal('Worker', BranchTraceWorker); + try { + const calculation = createPlanningCalculation(request); + await calculation.trace; + const historicalEligibility = await calculation.historicalEligibility({ + decisionId: 'branch-selected', + placement: selected, + }); + expect(historicalEligibility).toEqual([ + { + date: '2024-01-03', + reason: { + code: 'unavailable', + message: 'The selected entitlement is not available yet.', + }, + }, + ]); + expect(receivedState?.balances[0]).toMatchObject({ + earned: true, + sourceRuleId: 'producer', + }); + expect(receivedOptions).toEqual({ + removeDate: '2024-01-02', + compareOriginalState: true, + }); + const analysis = await calculation.analyzeCounterfactual({ + decisionId: 'branch-selected', + placement: selected, + }); + expect(analysis.kind).toBe('Counterfactual'); + expect(receivedState?.placements.map((placement) => placement.date)).toEqual([ + '2024-01-01', + '2024-01-02', + ]); + expect(receivedState?.balances[0]?.remaining).toBe(3); + expect(receivedState?.balances[0]).toMatchObject({ + availableFrom: '2024-01-02', + earned: true, + sourceRuleId: 'producer', + }); + expect(receivedOptions).toEqual({ + removeDate: '2024-01-02', + compareOriginalState: true, + }); + await expect( + calculation.analyzeCounterfactual({ + decisionId: 'self-parent', + placement: selected, + }), + ).rejects.toThrow('The selected trace decision is unavailable.'); + } finally { + vi.unstubAllGlobals(); + vi.stubGlobal('window', browserWindow); + } + }); + it('does not inherit unrelated rule-invalid history into a trace decision', async () => { const browserWindow = globalThis.window; vi.stubGlobal('window', undefined); @@ -2025,7 +2820,7 @@ describe('planning calculation context', () => { reason: candidate.reason?.code, })) ?? []; expect(candidates.slice(0, 2)).toEqual([ - { balanceId: 'earlier', status: 'evaluated-rejected', reason: undefined }, + { balanceId: 'earlier', status: 'chosen', reason: undefined }, { balanceId: 'later', status: 'not-examined', reason: 'first-compatible-balance' }, ]); } finally { @@ -2177,6 +2972,370 @@ describe('planning calculation context', () => { } }); + it('delivers every streamed evaluation after ten seconds without delaying the outcome', async () => { + const request = { + horizon: { start: '2024-01-01', end: '2024-12-31' }, + balances: [balance('cp', 'cp', 1)], + rules: [], + }; + const expected = isPlan(await runInNonBrowser(request)); + const candidateCount = 768; + const initialState: PlanningTraceState = { + horizon: request.horizon, + balances: [], + placements: [], + earnedActivations: [], + }; + const operations: PlanningTraceUpdate['operations'][number][] = [ + { + kind: 'phase', + phase: { id: 'phase:1', name: 'search', completed: true, status: 'completed' }, + }, + { + kind: 'decision', + phaseId: 'phase:1', + decision: { id: 'decision:1', action: 'choose', dates: ['2024-02-01'] }, + }, + ...Array.from({ length: candidateCount }, (_, index) => ({ + kind: 'candidate' as const, + phaseId: 'phase:1', + decisionId: 'decision:1', + index, + mode: 'append' as const, + candidate: { status: 'evaluated-rejected' as const, date: '2024-02-01' }, + })), + ]; + const trace = { + status: 'ready' as const, + initialState, + completedStages: ['dates'], + }; + class SlowTraceWorker { + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: (() => void) | null = null; + terminated = false; + + postMessage(): void { + queueMicrotask(() => { + this.onmessage?.({ + data: { type: 'result', outcome: expected }, + } as MessageEvent); + this.onmessage?.({ + data: { + type: 'explanation', + explanation: { kind: 'PlanExplanation', placements: [] }, + finalState: initialState, + }, + } as MessageEvent); + }); + setTimeout(() => { + for (let offset = 0; offset < operations.length; offset += 64) + this.onmessage?.({ + data: { + type: 'trace-update', + totalMs: 0, + update: { + initialState: offset === 0 ? initialState : undefined, + operations: operations.slice(offset, offset + 64), + }, + }, + } as MessageEvent); + this.onmessage?.({ + data: { + type: 'trace-status-index', + statuses: new Map([['2024-02-01', 'evaluated-rejected']]), + nonWorkingDates: [], + }, + } as MessageEvent); + this.onmessage?.({ + data: { type: 'artifacts-start', artifacts: {}, trace }, + } as MessageEvent); + this.onmessage?.({ data: { type: 'artifacts-complete' } } as MessageEvent); + }, 10_001); + } + + terminate(): void { + this.terminated = true; + } + } + const browserWindow = globalThis.window; + vi.stubGlobal('window', {}); + vi.stubGlobal('Worker', SlowTraceWorker); + try { + const calculation = createPlanningCalculation(request); + let traceReady = false; + const tracePromise = calculation.trace.then((value) => { + traceReady = true; + return value; + }); + expect(await calculation.outcome).toEqual(expected); + expect((await calculation.explanation)?.kind).toBe('PlanExplanation'); + expect(traceReady).toBe(false); + const traceStarted = performance.now(); + const loadedTrace = await tracePromise; + expect(performance.now() - traceStarted).toBeGreaterThanOrEqual(10_000); + expect(loadedTrace?.phases[0].decisions[0].candidates).toHaveLength(candidateCount); + expect(loadedTrace?.status).toBe('ready'); + } finally { + vi.unstubAllGlobals(); + vi.stubGlobal('window', browserWindow); + } + }, 15_000); + + it('drains queued trace packets in batches of at most sixteen', async () => { + const request = { + horizon: { start: '2024-01-01', end: '2024-12-31' }, + balances: [balance('cp', 'cp', 1)], + rules: [], + }; + const expected = isPlan(await runInNonBrowser(request)); + const packetCount = 17; + const initialState: PlanningTraceState = { + horizon: request.horizon, + balances: [], + placements: [], + earnedActivations: [], + }; + const trace = { + status: 'ready' as const, + initialState, + completedStages: ['dates'], + }; + const updates = Array.from( + { length: packetCount }, + (_, index): PlanningTraceUpdate => ({ + initialState: index === 0 ? initialState : undefined, + operations: + index === 0 + ? [ + { + kind: 'phase', + phase: { + id: 'phase:1', + name: 'search', + completed: true, + status: 'completed', + }, + }, + { + kind: 'decision', + phaseId: 'phase:1', + decision: { + id: 'decision:1', + action: 'choose', + dates: ['2024-02-01'], + }, + }, + { + kind: 'candidate', + phaseId: 'phase:1', + decisionId: 'decision:1', + index, + mode: 'append', + candidate: { + status: 'evaluated-rejected', + date: '2024-02-01', + }, + }, + ] + : [ + { + kind: 'candidate', + phaseId: 'phase:1', + decisionId: 'decision:1', + index, + mode: 'append', + candidate: { + status: 'evaluated-rejected', + date: '2024-02-01', + }, + }, + ], + }), + ); + let performanceNowCalls = 0; + let performanceNowCallsAtTermination = -1; + let terminationCount = 0; + let acknowledgementCount = 0; + class BatchedTraceWorker { + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: (() => void) | null = null; + + postMessage(message?: { type?: string }): void { + if (message?.type === 'trace-ack') { + acknowledgementCount++; + return; + } + this.onmessage?.({ data: { type: 'result', outcome: expected } } as MessageEvent); + this.onmessage?.({ + data: { + type: 'explanation', + explanation: { kind: 'PlanExplanation', placements: [] }, + finalState: initialState, + }, + } as MessageEvent); + this.onmessage?.({ + data: { type: 'artifacts-start', artifacts: {}, trace }, + } as MessageEvent); + for (const update of updates) + this.onmessage?.({ + data: { type: 'trace-update', totalMs: 0, update }, + } as MessageEvent); + this.onmessage?.({ + data: { type: 'artifacts-complete', traceTransferMs: 0 }, + } as MessageEvent); + } + + terminate(): void { + terminationCount++; + performanceNowCallsAtTermination = performanceNowCalls; + } + } + const browserWindow = globalThis.window; + const timeoutSpy = vi.spyOn(globalThis, 'setTimeout'); + vi.spyOn(performance, 'now').mockImplementation(() => { + performanceNowCalls++; + return 0; + }); + vi.stubGlobal('window', {}); + vi.stubGlobal('Worker', BatchedTraceWorker); + try { + const calculation = createPlanningCalculation(request); + const loadedTrace = await calculation.trace; + expect(loadedTrace?.phases[0].decisions[0].candidates).toHaveLength(packetCount); + expect(timeoutSpy.mock.calls.filter(([, delay]) => delay === 0)).toHaveLength(2); + expect(terminationCount).toBe(1); + expect(performanceNowCallsAtTermination).toBe(2); + expect(acknowledgementCount).toBe(0); + } finally { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.stubGlobal('window', browserWindow); + } + }); + + it('acknowledges marked trace packets only after applying them and waits for the tail', async () => { + const request = { + horizon: { start: '2024-01-01', end: '2024-12-31' }, + balances: [balance('cp', 'cp', 1)], + rules: [], + }; + const expected = isPlan(await runInNonBrowser(request)); + const initialState: PlanningTraceState = { + horizon: request.horizon, + balances: [], + placements: [], + earnedActivations: [], + }; + const trace = { + status: 'ready' as const, + initialState, + completedStages: ['dates'], + }; + const phase = { + kind: 'phase' as const, + phase: { id: 'phase:1', name: 'search', completed: true, status: 'completed' as const }, + }; + const decision = { + kind: 'decision' as const, + phaseId: 'phase:1', + decision: { id: 'decision:1', action: 'choose' as const, dates: ['2024-02-01'] }, + }; + const candidate = (index: number) => ({ + kind: 'candidate' as const, + phaseId: 'phase:1', + decisionId: 'decision:1', + index, + mode: 'append' as const, + candidate: { status: 'evaluated-rejected' as const, date: '2024-02-01' }, + }); + const firstUpdate: PlanningTraceUpdate = { + initialState, + operations: [phase, decision, candidate(0)], + }; + const tailUpdate: PlanningTraceUpdate = { operations: [candidate(1)] }; + let acknowledgePackets = 0; + let tailSent = false; + let resolveFirstAcknowledgement!: () => void; + const firstAcknowledgement = new Promise( + (resolve) => (resolveFirstAcknowledgement = resolve), + ); + class AcknowledgedTraceWorker { + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: (() => void) | null = null; + + postMessage(message: { type?: string; count?: number }): void { + if (message.type === 'trace-ack') { + acknowledgePackets += message.count ?? 0; + if (acknowledgePackets === 1) { + tailSent = true; + queueMicrotask(() => + this.onmessage?.({ + data: { + type: 'trace-update', + totalMs: 0, + update: tailUpdate, + acknowledge: true, + }, + } as MessageEvent), + ); + resolveFirstAcknowledgement(); + } else if (acknowledgePackets === 2) + queueMicrotask(() => + this.onmessage?.({ + data: { type: 'artifacts-complete', traceTransferMs: 0 }, + } as MessageEvent), + ); + return; + } + this.onmessage?.({ data: { type: 'result', outcome: expected } } as MessageEvent); + this.onmessage?.({ + data: { + type: 'explanation', + explanation: { kind: 'PlanExplanation', placements: [] }, + finalState: initialState, + }, + } as MessageEvent); + this.onmessage?.({ + data: { type: 'artifacts-start', artifacts: {}, trace }, + } as MessageEvent); + this.onmessage?.({ + data: { + type: 'trace-update', + totalMs: 0, + update: firstUpdate, + acknowledge: true, + }, + } as MessageEvent); + } + + terminate(): void {} + } + const browserWindow = globalThis.window; + vi.stubGlobal('window', {}); + vi.stubGlobal('Worker', AcknowledgedTraceWorker); + try { + const calculation = createPlanningCalculation(request); + let traceReady = false; + const tracePromise = calculation.trace.then((result) => { + traceReady = true; + return result; + }); + await calculation.outcome; + expect(acknowledgePackets).toBe(0); + expect(traceReady).toBe(false); + await firstAcknowledgement; + expect(tailSent).toBe(true); + expect(traceReady).toBe(false); + const loadedTrace = await tracePromise; + expect(acknowledgePackets).toBe(2); + expect(loadedTrace?.phases[0].decisions[0].candidates).toHaveLength(2); + } finally { + vi.unstubAllGlobals(); + vi.stubGlobal('window', browserWindow); + } + }); + it('keeps a valid outcome when trace delivery fails afterward', async () => { const expected = isPlan( await runInNonBrowser({ horizon, balances: [balance('cp', 'cp', 1)], rules: [] }), diff --git a/src/app/services/leave-planner.service.ts b/src/app/services/leave-planner.service.ts index eb4a5ad..716b39b 100644 --- a/src/app/services/leave-planner.service.ts +++ b/src/app/services/leave-planner.service.ts @@ -13,9 +13,16 @@ import type { PlanningOutcome, PlanningRequest, PlanningSnapshot, + PlanningArtifactTimings, PlanningTrace, + PlanningTraceCandidate, + PlanningTraceDecision, + PlanningTraceDelta, + PlanningTracePhase, + PlanningTraceUpdate, PlanningReason, PlanningTraceState, + HistoricalCounterfactualReason, StaleCounterfactualAnalysis, TraceCandidateStatus, TraceCounterfactualSelection, @@ -53,12 +60,15 @@ export type { PlanningRequest, PlanningRule, PlanningSnapshot, + PlanningArtifactTimings, PlanningTrace, PlanningTraceCandidate, PlanningTraceDecision, PlanningTraceDelta, PlanningTracePhase, PlanningTraceState, + PlanningTraceOperation, + PlanningTraceUpdate, PreferenceResult, RemainingBalance, RemainingClassification, @@ -66,9 +76,14 @@ export type { TimeOffPeriod, TimeWindowPlanningRule, TraceCounterfactualSelection, + TraceCandidateStatus, } from './leave-planner.core'; const MAX_PLANNING_MS = 10_000; +// Core owns the work deadline; this independent timer only recovers a stuck worker. +const WORKER_LIVENESS_TIMEOUT_MS = 30_000; +const MAX_TRACE_UPDATES_PER_DRAIN = 16; +const TRACE_DRAIN_TIME_BUDGET_MS = 4; interface UntrustedRecord { horizon?: unknown; @@ -135,7 +150,8 @@ function cloneUnknown(value: unknown, seen = new WeakMap()): un } function snapshotValue(value: unknown): unknown { - if (value instanceof Date) return value.toISOString(); + if (value instanceof Date) + return Number.isNaN(value.getTime()) ? 'Invalid Date' : value.toISOString(); if (Array.isArray(value)) return value.map(snapshotValue); if (typeof value !== 'object' || value === null) return value; const result: Record = {}; @@ -273,17 +289,65 @@ function invalidDeadline(request: TransportSnapshot): InvalidInputOutcome | unde type PlannerWorkerResponse = | { type: 'progress'; stage: string } | { type: 'trace-overhead'; totalMs: number } - | { type: 'checkpoint'; plan: PlanOutcome } + | { type: 'checkpoint'; plan: PlanOutcome; state?: PlanningTraceState } | { type: 'result'; outcome: PlanningOutcome } | { type: 'artifacts'; artifacts: PlanningArtifacts } - | { type: 'error'; message: string }; + | { + 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[]; + } + | { type: 'error'; code?: string; message: string }; + +export type HistoricalEligibilityReason = HistoricalCounterfactualReason; interface PlanningRun { outcome: Promise; + explanation: Promise; + explanationError: Promise; artifacts: Promise; + timings: Promise; 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 : []; @@ -338,17 +402,38 @@ function interruptedOutcome( }; } -let latestWorkerCancel: (() => void) | undefined; +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, deadline: number): PlanningRun { let resolveOutcome!: (outcome: PlanningOutcome) => void; + let resolveExplanation!: (explanation: PlanningExplanation | undefined) => void; + let resolveExplanationError!: (error: PlanningReason | undefined) => void; let resolveArtifacts!: (artifacts: PlanningArtifacts) => void; + let resolveTimings!: (timings: PlanningCalculationTimings) => void; const outcome = new Promise((resolve) => { resolveOutcome = resolve; }); + const explanation = new Promise((resolve) => { + resolveExplanation = resolve; + }); + const explanationError = new Promise((resolve) => { + resolveExplanationError = resolve; + }); const artifacts = new Promise((resolve) => { resolveArtifacts = resolve; }); + const timings = new Promise((resolve) => { + resolveTimings = resolve; + }); let cancel: (() => void) | undefined; try { const worker = new Worker(new URL('./leave-planner.worker', import.meta.url), { @@ -356,30 +441,164 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu }); let outcomeSettled = false; + let explanationSettled = false; let artifactsSettled = false; let checkpoint: PlanOutcome | undefined; - let traceAllowance = 0; - let timeout: ReturnType; - const timeoutStarted = performance.now(); - const terminate = () => { - clearTimeout(timeout); + let checkpointState: PlanningTraceState | undefined; + let receivedArtifacts: Omit< + PlanningArtifacts, + 'trace' | 'explanation' | 'explanationError' | 'finalState' + > = {}; + 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 livenessWatchdog: ReturnType; + let workerStopped = false; + const stopWorker = () => { + if (workerStopped) return; + workerStopped = true; 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) { outcomeSettled = true; - clearTimeout(timeout); + clearTimeout(livenessWatchdog); resolveOutcome(result); } }; - const finishArtifacts = (result: PlanningArtifacts) => { + const finishExplanation = ( + result: PlanningExplanation | undefined, + error: PlanningReason | undefined, + finalState?: PlanningTraceState, + ) => { + if (!explanationSettled) { + explanationSettled = true; + receivedExplanation = result; + receivedExplanationError = error; + receivedFinalState = finalState; + resolveExplanation(result); + resolveExplanationError(error); + } + }; + const finishArtifacts = (result: PlanningArtifacts, finishExplanationPromise = true) => { if (!artifactsSettled) { artifactsSettled = true; - resolveArtifacts(result); + const complete = { + ...result, + explanation: result.explanation ?? receivedExplanation, + explanationError: result.explanationError ?? receivedExplanationError, + finalState: result.finalState ?? receivedFinalState, + }; + 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 (latestWorkerCancel === cancel) latestWorkerCancel = undefined; + if (cancel) latestWorkerCancels.delete(cancel); + } + }; + 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, + }; + }; + 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); } }; cancel = () => { @@ -393,63 +612,230 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu finishOutcome( interruptedOutcome(checkpoint, deadline, reason.code, reason.message), ); - finishArtifacts({ - trace: fallbackTrace(snapshot, 'failed', reason), - explanationError: hadOutcome ? undefined : reason, - }); - }; - const fail = (code: string, message: string) => { - const reason = { code, message }; - if (!outcomeSettled) - finishOutcome(interruptedOutcome(checkpoint, deadline, code, message)); - finishArtifacts({ - trace: fallbackTrace( - snapshot, - code === 'deadline' ? 'incomplete' : 'failed', - reason, - ), - explanationError: reason, - }); - }; - const scheduleTimeout = () => { - clearTimeout(timeout); - timeout = setTimeout( - () => - fail( - 'deadline', - `Planning stopped after ${deadline}ms before all quality stages completed.`, - ), - Math.max(0, deadline + traceAllowance - (performance.now() - timeoutStarted)), + finishExplanation( + receivedExplanation, + hadOutcome ? receivedExplanationError : reason, + receivedFinalState ?? checkpointState, + ); + traceUpdates = []; + traceUpdateIndex = 0; + finishArtifacts( + hadOutcome + ? partialArtifacts('failed', reason, false) + : { + trace: fallbackTrace(snapshot, 'failed', reason), + explanationError: reason, + }, ); }; - scheduleTimeout(); + if (cancel) latestWorkerCancels.add(cancel); + const finalizeFailure = (reason: { code: string; message: string }) => { + if (!explanationSettled && checkpoint && checkpointState) { + receivedFinalState ??= checkpointState; + finishArtifacts(partialArtifacts('failed', reason), false); + void explainCheckpointInWorker( + publicSnapshot(snapshot), + checkpoint, + checkpointState, + reason, + ) + .then((result) => + finishExplanation( + result.explanation, + result.explanationError ?? reason, + result.finalState ?? checkpointState, + ), + ) + .catch((error: unknown) => + finishExplanation( + undefined, + { + code: 'explanation-error', + message: error instanceof Error ? error.message : String(error), + }, + 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, deadline, code, message)); + pendingFailure ??= { code, message }; + if (traceUpdateIndex < traceUpdates.length) scheduleTraceDrain(); + else finalizeFailure(pendingFailure); + }; + const scheduleLivenessWatchdog = () => { + if (outcomeSettled || artifactsSettled) return; + clearTimeout(livenessWatchdog); + livenessWatchdog = setTimeout( + () => + fail( + 'worker-error', + 'Planning worker stopped responding before returning its outcome.', + ), + 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(); worker.onmessage = (event: MessageEvent) => { const message = event.data; if (!message) return; if (message.type === 'progress') { + scheduleLivenessWatchdog(); if (typeof snapshot.progress === 'function') snapshot.progress(message.stage); return; } if (message.type === 'trace-overhead') { - traceAllowance = Math.max(traceAllowance, message.totalMs); - scheduleTimeout(); + scheduleLivenessWatchdog(); return; } if (message.type === 'checkpoint') { + scheduleLivenessWatchdog(); checkpoint = message.plan; + checkpointState = message.state; return; } if (message.type === 'result') { finishOutcome(message.outcome); - if (message.outcome.kind === 'InvalidInput') finishArtifacts({}); + if (message.outcome.kind === 'InvalidInput') { + finishExplanation(undefined, undefined); + finishArtifacts({}); + } + return; + } + if (message.type === 'explanation') { + finishExplanation( + message.explanation, + message.explanationError, + message.finalState, + ); return; } if (message.type === 'artifacts') { finishArtifacts(message.artifacts); return; } - fail('worker-error', message.message); + 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.'); @@ -473,8 +859,11 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu trace: fallbackTrace(snapshot, 'failed', result.reasons[0]), explanationError: result.reasons[0], }); + resolveExplanation(undefined); + resolveExplanationError(result.reasons[0]); + resolveTimings({}); } - return { outcome, artifacts, cancel }; + return { outcome, explanation, explanationError, artifacts, timings, cancel }; } let calculationSequence = 0; @@ -510,28 +899,29 @@ const traceStatusPriority = new Map([ ]); const historicalStatusCache = new WeakMap>(); const historicalNonWorkingCache = new WeakMap>(); +const historicalDecisionCache = new WeakMap< + PlanningTrace, + { id: string; statuses: Map } +>(); -function historicalStatuses( +async function historicalStatuses( trace: PlanningTrace, decisionId?: string, -): Map { +): Promise> { if (!decisionId) { const cached = historicalStatusCache.get(trace); if (cached) return cached; + } else { + const cached = historicalDecisionCache.get(trace); + if (cached?.id === decisionId) return cached.statuses; } const statuses = new Map(); - const nonWorkingDates = new Set(); + let count = 0; for (const phase of trace.phases) { for (const decision of phase.decisions) { if (decisionId && decision.id !== decisionId) continue; for (const candidate of decision.candidates) { for (const date of candidate.dates ?? (candidate.date ? [candidate.date] : [])) { - if ( - !decisionId && - candidate.status === 'ineligible' && - candidate.reason?.code === 'non-working' - ) - nonWorkingDates.add(date); const previous = statuses.get(date); if ( !previous || @@ -540,21 +930,110 @@ function historicalStatuses( ) statuses.set(date, candidate.status); } + if (++count % 256 === 0) + await new Promise((resolve) => setTimeout(resolve, 0)); } } } if (!decisionId) { historicalStatusCache.set(trace, statuses); - historicalNonWorkingCache.set(trace, nonWorkingDates); - } + historicalNonWorkingCache.set( + trace, + new Set(trace.nonWorkingDates?.map(({ date }) => date) ?? []), + ); + } else historicalDecisionCache.set(trace, { id: decisionId, statuses }); return statuses; } function historicalNonWorkingDates(trace: PlanningTrace): ReadonlySet { const cached = historicalNonWorkingCache.get(trace); if (cached) return cached; - historicalStatuses(trace); - return historicalNonWorkingCache.get(trace) ?? new Set(); + const dates = new Set(trace.nonWorkingDates?.map(({ date }) => date) ?? []); + historicalNonWorkingCache.set(trace, dates); + return dates; +} + +interface ReplayState { + balances: PlanningTraceState['balances'][number][]; + placements: Map; + earnedActivations: PlanningTraceState['earnedActivations'][number][]; +} + +interface QueuedTraceUpdate { + update: PlanningTraceUpdate; + acknowledge: boolean; +} + +function replayState(trace: PlanningTrace): ReplayState { + return { + balances: trace.initialState.balances.map((balance) => ({ ...balance })), + placements: new Map( + trace.initialState.placements.map((placement) => [placement.date, { ...placement }]), + ), + earnedActivations: trace.initialState.earnedActivations.map((activation) => ({ + ...activation, + dates: [...activation.dates], + })), + }; +} + +export function applyReplayDeltas( + state: ReplayState, + deltas: readonly PlanningTraceDelta[], + finalState?: PlanningTraceState, +): void { + for (const delta of deltas) { + if (delta.previousPlacement) state.placements.delete(delta.previousPlacement.date); + if (delta.placement) + state.placements.set(delta.placement.date, { + ...delta.placement, + ruleIds: [...delta.placement.ruleIds], + }); + const balanceId = delta.balanceId ?? delta.balance?.id; + if (balanceId) { + let index = state.balances.findIndex((candidate) => candidate.id === balanceId); + const changedBalance = delta.balance?.id === balanceId ? delta.balance : undefined; + if (index < 0 && changedBalance) { + state.balances.push({ ...changedBalance }); + index = state.balances.length - 1; + } + if (index < 0) { + const source = finalState?.balances.find((candidate) => candidate.id === balanceId); + if (source) { + state.balances.push({ ...source }); + index = state.balances.length - 1; + } + } + if (index >= 0) { + const current = state.balances[index]!; + state.balances[index] = { + ...current, + ...changedBalance, + ...(delta.remainingAfter === undefined + ? {} + : { remaining: delta.remainingAfter }), + }; + } + } + if ( + delta.kind === 'earned-activated' && + delta.activation && + !state.earnedActivations.some((activation) => activation.id === delta.activation!.id) + ) + state.earnedActivations.push({ + ...delta.activation, + dates: [...delta.activation.dates], + }); + } +} + +function replayedState(trace: PlanningTrace, state: ReplayState): PlanningTraceState { + return { + horizon: { ...trace.initialState.horizon }, + balances: state.balances, + placements: [...state.placements.values()], + earnedActivations: state.earnedActivations, + }; } function stateBeforeDecision( @@ -562,59 +1041,49 @@ function stateBeforeDecision( decisionId: string, finalState?: PlanningTraceState, ): PlanningTraceState | undefined { - const balances = trace.initialState.balances.map((balance) => ({ ...balance })); - const placements = new Map( - trace.initialState.placements.map((placement) => [placement.date, { ...placement }]), - ); - const earnedActivations = trace.initialState.earnedActivations.map((activation) => ({ - ...activation, - dates: [...activation.dates], - })); - for (const decision of trace.phases.flatMap((phase) => phase.decisions)) { - if (decision.id === decisionId) - return { - horizon: { ...trace.initialState.horizon }, - balances, - placements: [...placements.values()], - earnedActivations, - }; - for (const delta of decision.deltas) { - if (delta.previousPlacement) placements.delete(delta.previousPlacement.date); - if (delta.placement) - placements.set(delta.placement.date, { - ...delta.placement, - ruleIds: [...delta.placement.ruleIds], - }); - if (delta.kind === 'balance-changed' && delta.balanceId) { - let balance = balances.find((candidate) => candidate.id === delta.balanceId); - if (!balance && delta.balance) { - balance = { ...delta.balance }; - balances.push(balance); - } - if (!balance) { - const source = finalState?.balances.find( - (candidate) => candidate.id === delta.balanceId, - ); - if (source) { - balance = { ...source }; - balances.push(balance); - } - } - if (balance && delta.remainingAfter !== undefined) - balance.remaining = delta.remainingAfter; + const decisions = trace.phases.flatMap((phase) => phase.decisions); + const targetIndex = decisions.findIndex((decision) => decision.id === decisionId); + if (targetIndex < 0) return undefined; + const target = decisions[targetIndex]; + if (!target) return undefined; + const decisionsById = new Map(decisions.map((decision) => [decision.id, decision])); + let replayThrough = targetIndex - 1; + const branchAncestors: PlanningTraceDecision[] = []; + if (target.branch) { + let current = target; + const branchSeen = new Set(); + let committedParent: PlanningTraceDecision | undefined; + while (current.branch) { + if (branchSeen.has(current.id)) return undefined; + branchSeen.add(current.id); + branchAncestors.unshift(current); + const parentId = current.branch.parentDecisionId; + if (!parentId) break; + const parent = decisionsById.get(parentId); + if (!parent) return undefined; + if (!parent.branch) { + committedParent = parent; + break; } - if ( - delta.kind === 'earned-activated' && - delta.activation && - !earnedActivations.some((activation) => activation.id === delta.activation!.id) - ) - earnedActivations.push({ - ...delta.activation, - dates: [...delta.activation.dates], - }); + current = parent; } + const branchRootIndex = decisions.findIndex( + (decision) => decision.id === branchAncestors[0]?.id, + ); + if (branchRootIndex < 0) return undefined; + replayThrough = committedParent + ? decisions.findIndex((decision) => decision.id === committedParent!.id) + : branchRootIndex - 1; + if (replayThrough >= branchRootIndex) return undefined; } - return undefined; + const state = replayState(trace); + for (let index = 0; index <= replayThrough; index++) { + const decision = decisions[index]; + if (decision) applyReplayDeltas(state, decision.deltas, finalState); + } + for (const ancestor of branchAncestors.slice(0, -1)) + if (ancestor.branch) applyReplayDeltas(state, ancestor.branch.deltas, finalState); + return replayedState(trace, state); } function decisionBaseline( @@ -622,12 +1091,15 @@ function decisionBaseline( selection: TraceCounterfactualSelection, finalState?: PlanningTraceState, ): { state: PlanningTraceState; options: CounterfactualBaselineOptions } | undefined { - const decision = trace.phases - .flatMap((phase) => phase.decisions) - .find((candidate) => candidate.id === selection.decisionId); + let decision: PlanningTraceDecision | undefined; + for (const phase of trace.phases) { + decision = phase.decisions.find((candidate) => candidate.id === selection.decisionId); + if (decision) break; + } const state = stateBeforeDecision(trace, selection.decisionId, finalState); if (!decision || !state) return undefined; - const replacement = decision.deltas.find( + const transition = decision.branch?.deltas ?? decision.deltas; + const replacement = transition.find( (delta) => (delta.kind === 'placement-moved' || delta.kind === 'placement-updated') && delta.placement?.date === selection.placement.date && @@ -645,34 +1117,94 @@ function decisionBaseline( }; } -function withHistoricalStatuses( +async function withHistoricalStatuses( analysis: CounterfactualAnalysis, trace?: PlanningTrace, decisionId?: string, -): CounterfactualAnalysis { +): Promise { if (!trace) return analysis; - const statuses = historicalStatuses(trace, decisionId); - const nonWorkingDates = decisionId ? historicalNonWorkingDates(trace) : undefined; + const statuses = await historicalStatuses(trace, decisionId); + const nonWorkingDates = historicalNonWorkingDates(trace); return { ...analysis, - results: analysis.results.map((result) => ({ - ...result, - historicalStatus: - statuses.get(result.date) ?? - (decisionId - ? nonWorkingDates?.has(result.date) - ? 'ineligible' - : 'not-examined' - : undefined), - })), + results: analysis.results.map((result) => { + const observedStatus = statuses.get(result.date); + const historicalReason = result.historicalReason; + const nonWorking = nonWorkingDates.has(result.date); + return { + ...result, + historicalStatus: + observedStatus ?? + (trace.status === 'failed' + ? undefined + : historicalReason || nonWorking + ? 'ineligible' + : 'not-examined'), + historicalReason: observedStatus === undefined ? historicalReason : undefined, + }; + }), }; } +function addHistoricalReasons( + analysis: CounterfactualAnalysis, + entries: readonly HistoricalEligibilityReason[], +): CounterfactualAnalysis { + const reasons = new Map(entries.map(({ date, reason }) => [date, reason])); + return { + ...analysis, + results: analysis.results.map((result) => { + const historicalReason = reasons.get(result.date); + return historicalReason ? { ...result, historicalReason } : result; + }), + }; +} + +async function runHistoricalEligibility( + context: PlanningCalculationContext, + selection: TraceCounterfactualSelection, + artifacts: Promise, + isCurrent: () => boolean, +): Promise { + if (context.id !== latestCalculationId || !isCurrent()) return []; + const completed = await artifacts; + if (context.id !== latestCalculationId || !isCurrent()) return []; + const trace = completed.trace; + const baseline = trace && decisionBaseline(trace, selection, completed.finalState); + if (!baseline) throw new Error('The selected trace decision is unavailable.'); + const entries = + typeof window !== 'undefined' && typeof Worker !== 'undefined' + ? await historicalEligibilityInWorker( + context.snapshot, + baseline.state, + selection.placement, + baseline.options, + ) + : await (async () => { + const core = await import('./leave-planner.core'); + return core.historicalCounterfactualReasons( + context.snapshot, + baseline.state, + selection.placement, + baseline.options, + ); + })(); + return context.id === latestCalculationId && isCurrent() ? entries : []; +} + async function runCounterfactual( context: PlanningCalculationContext, selection: CounterfactualSelection, artifacts: Promise, + isCurrent: () => boolean, ): Promise { + const stale = (placement: LeavePlacement): StaleCounterfactualAnalysis => ({ + kind: 'CounterfactualStale', + calculationId: context.id, + placement, + hypothetical: true, + results: [], + }); if (context.id !== latestCalculationId) { const plan = await context.outcome; const finalPlan = @@ -684,16 +1216,10 @@ async function runCounterfactual( const placement = finalPlan ? selectionFor(finalPlan, selection) : undefined; if (!placement) throw new Error('The selected placement is not present in the calculation.'); - return { - kind: 'CounterfactualStale', - calculationId: context.id, - placement, - hypothetical: true, - results: [], - }; + return stale(placement); } const outcome = await context.outcome; - if (context.id !== latestCalculationId) { + if (context.id !== latestCalculationId || !isCurrent()) { const plan = outcome.kind === 'Plan' ? outcome @@ -703,13 +1229,7 @@ async function runCounterfactual( const placement = plan ? selectionFor(plan, selection) : undefined; if (!placement) throw new Error('The selected placement is not present in the calculation.'); - return { - kind: 'CounterfactualStale', - calculationId: context.id, - placement, - hypothetical: true, - results: [], - }; + return stale(placement); } const plan = outcome.kind === 'Plan' @@ -721,6 +1241,7 @@ async function runCounterfactual( const placement = selectionFor(plan, selection); if (!placement) throw new Error('The selected placement is not present in the calculation.'); const completedArtifacts = await artifacts; + if (context.id !== latestCalculationId || !isCurrent()) return stale(placement); const selectedDecision = traceSelection(selection) ? completedArtifacts.trace && decisionBaseline(completedArtifacts.trace, selection, completedArtifacts.finalState) @@ -729,57 +1250,53 @@ async function runCounterfactual( throw new Error('The selected trace decision is unavailable.'); const analysisState = selectedDecision?.state ?? completedArtifacts.finalState; const options = selectedDecision?.options; - if (context.id !== latestCalculationId) - return { - kind: 'CounterfactualStale', - calculationId: context.id, - placement, - hypothetical: true, - results: [], - }; + if (context.id !== latestCalculationId || !isCurrent()) return stale(placement); if (typeof window !== 'undefined' && typeof Worker !== 'undefined') { - const result = await counterfactualInWorker( - context.snapshot, - plan, - placement, - analysisState, - options, + let result: CounterfactualAnalysis; + try { + result = await counterfactualInWorker( + context.snapshot, + plan, + placement, + analysisState, + options, + ); + } catch (error) { + if (context.id !== latestCalculationId || !isCurrent()) return stale(placement); + throw error; + } + if (context.id !== latestCalculationId || !isCurrent()) return stale(placement); + return withHistoricalStatuses( + { ...result, calculationId: context.id }, + completedArtifacts.trace, + traceSelection(selection) ? selection.decisionId : undefined, ); - return context.id === latestCalculationId - ? withHistoricalStatuses( - { ...result, calculationId: context.id }, - completedArtifacts.trace, - traceSelection(selection) ? selection.decisionId : undefined, - ) - : { - kind: 'CounterfactualStale', - calculationId: context.id, - placement, - hypothetical: true, - results: [], - }; } const core = await import('./leave-planner.core'); - const result = core.analyzeCounterfactualInternal( + let result = core.analyzeCounterfactualInternal( context.snapshot, plan, placement, analysisState, options, ); - return context.id === latestCalculationId + if (selectedDecision) + result = addHistoricalReasons( + result, + core.historicalCounterfactualReasons( + context.snapshot, + selectedDecision.state, + placement, + selectedDecision.options, + ), + ); + return context.id === latestCalculationId && isCurrent() ? withHistoricalStatuses( { ...result, calculationId: context.id }, completedArtifacts.trace, traceSelection(selection) ? selection.decisionId : undefined, ) - : { - kind: 'CounterfactualStale', - calculationId: context.id, - placement, - hypothetical: true, - results: [], - }; + : stale(placement); } function counterfactualInWorker( @@ -790,6 +1307,7 @@ function counterfactualInWorker( options?: CounterfactualBaselineOptions, ): Promise { return new Promise((resolve, reject) => { + latestCounterfactualCancel?.(); let worker: Worker; try { worker = new Worker(new URL('./leave-planner.worker', import.meta.url), { @@ -799,16 +1317,26 @@ function counterfactualInWorker( reject(error); return; } + let settled = false; const finish = (callback: () => void) => { + if (settled) return; + settled = true; worker.onmessage = null; worker.onerror = null; worker.terminate(); + if (cancel) latestWorkerCancels.delete(cancel); + if (latestCounterfactualCancel === cancel) latestCounterfactualCancel = undefined; callback(); }; + const cancel = () => + finish(() => reject(new Error('Counterfactual analysis was superseded.'))); + latestCounterfactualCancel = cancel; + latestWorkerCancels.add(cancel); worker.onmessage = ( event: MessageEvent<{ type: string; analysis?: CounterfactualAnalysis; + code?: string; message?: string; }>, ) => { @@ -816,7 +1344,12 @@ function counterfactualInWorker( finish(() => resolve(event.data.analysis!)); else if (event.data?.type === 'error') finish(() => - reject(new Error(event.data.message ?? 'Counterfactual analysis failed.')), + reject( + new Error( + event.data.message ?? + `Counterfactual analysis failed (${event.data.code ?? 'worker-error'}).`, + ), + ), ); }; worker.onerror = () => finish(() => reject(new Error('Counterfactual worker failed.'))); @@ -835,6 +1368,126 @@ function counterfactualInWorker( }); } +function historicalEligibilityInWorker( + snapshot: PlanningSnapshot, + state: PlanningTraceState, + placement: LeavePlacement, + options?: CounterfactualBaselineOptions, +): Promise { + return new Promise((resolve, reject) => { + latestHistoricalEligibilityCancel?.(); + let worker: Worker; + try { + worker = new Worker(new URL('./leave-planner.worker', import.meta.url), { + type: 'module', + }); + } catch (error) { + reject(error); + return; + } + let settled = false; + const finish = (callback: () => void) => { + if (settled) return; + settled = true; + worker.onmessage = null; + worker.onerror = null; + worker.terminate(); + if (cancel) latestWorkerCancels.delete(cancel); + if (latestHistoricalEligibilityCancel === cancel) + latestHistoricalEligibilityCancel = undefined; + callback(); + }; + const cancel = () => + finish(() => reject(new Error('Historical eligibility analysis was superseded.'))); + latestHistoricalEligibilityCancel = cancel; + latestWorkerCancels.add(cancel); + worker.onmessage = (event: MessageEvent) => { + const response = event.data; + if (response?.type === 'historical-eligibility-result') + finish(() => resolve(response.entries)); + else if (response?.type === 'error') + finish(() => + reject( + new Error( + response.message ?? + `Historical eligibility analysis failed (${response.code ?? 'worker-error'}).`, + ), + ), + ); + }; + worker.onerror = () => + finish(() => reject(new Error('Historical eligibility worker failed.'))); + try { + worker.postMessage({ + type: 'historical-eligibility', + request: snapshot, + state, + placement, + options, + }); + } catch (error) { + finish(() => reject(error)); + } + }); +} + +function explainCheckpointInWorker( + snapshot: PlanningSnapshot, + plan: PlanOutcome, + state: PlanningTraceState, + interruption: PlanningReason, +): Promise> { + return new Promise((resolve, reject) => { + let worker: Worker; + try { + worker = new Worker(new URL('./leave-planner.worker', import.meta.url), { + type: 'module', + }); + } catch (error) { + reject(error); + return; + } + let settled = false; + const finish = (callback: () => void) => { + if (settled) return; + settled = true; + worker.onmessage = null; + worker.onerror = null; + worker.terminate(); + if (cancel) latestWorkerCancels.delete(cancel); + callback(); + }; + const cancel = () => + finish(() => reject(new Error('Checkpoint explanation was superseded.'))); + latestWorkerCancels.add(cancel); + worker.onmessage = (event: MessageEvent) => { + const response = event.data; + if (response?.type === 'explanation') + finish(() => + resolve({ + explanation: response.explanation, + explanationError: response.explanationError, + finalState: response.finalState, + }), + ); + else if (response?.type === 'error') finish(() => reject(new Error(response.message))); + }; + worker.onerror = () => + finish(() => reject(new Error('Checkpoint explanation worker failed.'))); + try { + worker.postMessage({ + type: 'explain-checkpoint', + request: snapshot, + plan, + state, + interruption, + }); + } catch (error) { + finish(() => reject(error)); + } + }); +} + export interface PlanningCalculationContext { readonly id: string; readonly snapshot: PlanningSnapshot; @@ -842,14 +1495,17 @@ export interface PlanningCalculationContext { readonly explanation: Promise; readonly explanationError: Promise; readonly trace: Promise; + readonly timings?: Promise; readonly analyzeCounterfactual: ( selection: CounterfactualSelection, ) => Promise; + readonly historicalEligibility: ( + selection: TraceCounterfactualSelection, + ) => Promise; } export function createPlanningCalculation(request: PlanningRequest): PlanningCalculationContext { - latestWorkerCancel?.(); - latestWorkerCancel = undefined; + cancelLatestWorkers(); const transport = snapshotRequest(request); const snapshot = publicSnapshot(transport); const id = `calculation:${++calculationSequence}`; @@ -859,28 +1515,37 @@ export function createPlanningCalculation(request: PlanningRequest): PlanningCal if (deadlineError) { run = { outcome: new Promise((resolve) => queueMicrotask(() => resolve(deadlineError))), + explanation: Promise.resolve(undefined), + explanationError: Promise.resolve(undefined), artifacts: Promise.resolve({}), + timings: Promise.resolve({}), }; } else { const deadline = deadlineFor(transport); if (typeof window !== 'undefined') { if (typeof Worker !== 'undefined') { run = planInWorker(transport, deadline); - latestWorkerCancel = run.cancel; - void run.artifacts.then(() => { - if (latestWorkerCancel === run.cancel) latestWorkerCancel = undefined; - }); } else { let resolveArtifacts!: (artifacts: PlanningArtifacts) => void; + let resolveExplanation!: (explanation: PlanningExplanation | undefined) => void; + let resolveExplanationError!: (error: PlanningReason | undefined) => void; const artifacts = new Promise( (resolve) => (resolveArtifacts = resolve), ); + const explanation = new Promise( + (resolve) => (resolveExplanation = resolve), + ); + const explanationError = new Promise( + (resolve) => (resolveExplanationError = resolve), + ); const outcome = new Promise((resolve) => { queueMicrotask(async () => { const core = await import('./leave-planner.core'); const validation = core.invalidInputFor(transport); if (validation) { resolve(validation); + resolveExplanation(undefined); + resolveExplanationError(undefined); resolveArtifacts({}); return; } @@ -891,24 +1556,45 @@ export function createPlanningCalculation(request: PlanningRequest): PlanningCal 'Planning could not start because isolated execution is unavailable.', ); resolve(result); + const incompleteExplanation: PlanningExplanation = { + kind: 'IncompleteExplanation', + reason: result.reasons[0], + completedStages: [], + uncompletedStages: [], + }; + resolveExplanation(incompleteExplanation); + resolveExplanationError(undefined); resolveArtifacts({ - explanation: { - kind: 'IncompleteExplanation', - reason: result.reasons[0], - completedStages: [], - uncompletedStages: [], - }, + explanation: incompleteExplanation, trace: fallbackTrace(transport, 'failed', result.reasons[0]), }); }); }); - run = { outcome, artifacts }; + run = { + outcome, + explanation, + explanationError, + artifacts, + timings: Promise.resolve({}), + }; } } else { let resolveArtifacts!: (artifacts: PlanningArtifacts) => void; + let resolveExplanation!: (explanation: PlanningExplanation | undefined) => void; + let resolveExplanationError!: (error: PlanningReason | undefined) => void; + let resolveTimings!: (timings: PlanningCalculationTimings) => void; const artifacts = new Promise( (resolve) => (resolveArtifacts = resolve), ); + const explanation = new Promise( + (resolve) => (resolveExplanation = resolve), + ); + const explanationError = new Promise( + (resolve) => (resolveExplanationError = resolve), + ); + const timings = new Promise( + (resolve) => (resolveTimings = resolve), + ); const outcome = new Promise((resolve) => { queueMicrotask(async () => { try { @@ -918,9 +1604,21 @@ export function createPlanningCalculation(request: PlanningRequest): PlanningCal transport, undefined, (value) => (completedArtifacts = value), + undefined, + (value, error) => { + resolveExplanation(value); + resolveExplanationError(error); + }, ); + if (result.kind === 'InvalidInput') { + resolveExplanation(undefined); + resolveExplanationError(undefined); + } resolve(result); - queueMicrotask(() => resolveArtifacts(completedArtifacts)); + queueMicrotask(() => { + resolveArtifacts(completedArtifacts); + resolveTimings(completedArtifacts.timings ?? {}); + }); } catch (error) { const reason: PlanningReason = { code: 'planner-error', @@ -931,14 +1629,17 @@ export function createPlanningCalculation(request: PlanningRequest): PlanningCal completedStages: [], reasons: [reason], }); + resolveExplanation(undefined); + resolveExplanationError(reason); resolveArtifacts({ trace: fallbackTrace(transport, 'failed', reason), explanationError: reason, }); + resolveTimings({}); } }); }); - run = { outcome, artifacts }; + run = { outcome, explanation, explanationError, artifacts, timings }; } } const retainedArtifacts = run.artifacts.then((artifacts) => ({ @@ -951,15 +1652,35 @@ export function createPlanningCalculation(request: PlanningRequest): PlanningCal message: 'This trace belongs to an older calculation and was discarded.', }), })); + let counterfactualSequence = 0; + let historicalEligibilitySequence = 0; const context: PlanningCalculationContext = { id, snapshot, outcome: run.outcome, - explanation: retainedArtifacts.then((artifacts) => artifacts.explanation), - explanationError: retainedArtifacts.then((artifacts) => artifacts.explanationError), + explanation: run.explanation, + explanationError: run.explanationError, trace: retainedArtifacts.then((artifacts) => artifacts.trace), - analyzeCounterfactual: (selection) => - runCounterfactual(context, selection, retainedArtifacts), + timings: run.timings, + analyzeCounterfactual: (selection) => { + const sequence = ++counterfactualSequence; + if (id === latestCalculationId) latestCounterfactualCancel?.(); + return runCounterfactual( + context, + selection, + retainedArtifacts, + () => sequence === counterfactualSequence, + ); + }, + historicalEligibility: (selection) => { + const sequence = ++historicalEligibilitySequence; + return runHistoricalEligibility( + context, + selection, + retainedArtifacts, + () => sequence === historicalEligibilitySequence, + ); + }, }; return context; } diff --git a/src/app/services/leave-planner.worker.ts b/src/app/services/leave-planner.worker.ts index f0d36a1..617be9d 100644 --- a/src/app/services/leave-planner.worker.ts +++ b/src/app/services/leave-planner.worker.ts @@ -4,13 +4,20 @@ import type { CounterfactualAnalysis, LeavePlacement, PlanOutcome, + PlanningExplanation, PlanningOutcome, + PlanningReason, + PlanningTrace, + PlanningTraceUpdate, PlanningSnapshot, PlanningTraceState, + TraceCandidateStatus, } from './leave-planner.service'; import { analyzeCounterfactualInternal, calculatePlanningInternal, + explainIncompleteCheckpoint, + historicalCounterfactualReasons, type CounterfactualBaselineOptions, type PlanningArtifacts, } from './leave-planner.core'; @@ -30,6 +37,7 @@ type WorkerRequest = type: 'plan'; request: WorkerPlanningRequest; } + | { type: 'trace-ack'; count: number } | { type: 'counterfactual'; request: PlanningSnapshot; @@ -37,59 +45,373 @@ type WorkerRequest = placement: LeavePlacement; state?: PlanningTraceState; options?: CounterfactualBaselineOptions; + } + | { + type: 'historical-eligibility'; + request: PlanningSnapshot; + placement: LeavePlacement; + state: PlanningTraceState; + options?: CounterfactualBaselineOptions; + } + | { + type: 'explain-checkpoint'; + request: PlanningSnapshot; + plan: PlanOutcome; + state: PlanningTraceState; + interruption: PlanningReason; }; type WorkerResponse = | { type: 'progress'; stage: string } | { type: 'trace-overhead'; totalMs: number } - | { type: 'checkpoint'; plan: PlanOutcome } + | { type: 'checkpoint'; plan: PlanOutcome; state?: PlanningTraceState } | { 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?: true; + } + | { + type: 'trace-status-index'; + statuses: ReadonlyMap; + nonWorkingDates: readonly string[]; + } + | { type: 'artifacts-complete'; traceTransferMs: number } | { type: 'counterfactual-result'; analysis: CounterfactualAnalysis } - | { type: 'error'; message: string }; + | { + type: 'historical-eligibility-result'; + entries: ReturnType; + } + | { type: 'error'; code?: string; message: string }; const worker = self as DedicatedWorkerGlobalScope; +const TRACE_OPERATIONS_PER_MESSAGE = 64; +const MAX_TRACE_PACKETS_IN_FLIGHT = 16; -worker.onmessage = (event: MessageEvent) => { - try { - if (event.data?.type === 'plan') { - let artifacts: PlanningArtifacts = {}; - let outcomePosted = false; - const outcome = calculatePlanningInternal( - { - ...event.data.request, - progress: (stage: string) => - worker.postMessage({ type: 'progress', stage } satisfies WorkerResponse), - traceProgress: (totalMs: number) => - worker.postMessage({ - type: 'trace-overhead', - totalMs, - } satisfies WorkerResponse), - }, - (plan) => worker.postMessage({ type: 'checkpoint', plan } satisfies WorkerResponse), - (value) => (artifacts = value), - (value) => { - outcomePosted = true; - worker.postMessage({ type: 'result', outcome: value } satisfies WorkerResponse); - }, - ); - if (!outcomePosted) - worker.postMessage({ type: 'result', outcome } satisfies WorkerResponse); - worker.postMessage({ type: 'artifacts', artifacts } satisfies WorkerResponse); - return; +interface StagedTraceUpdate { + totalMs: number; + update?: PlanningTraceUpdate; + nextOperation: number; +} + +interface TraceDelivery { + updates: StagedTraceUpdate[]; + nextUpdate: number; + inFlight: number; + traceTransferMs: number; + artifacts?: PlanningArtifacts; + calculationComplete: boolean; +} + +let activeTraceDelivery: TraceDelivery | undefined; + +function traceStatusIndex(trace: PlanningTrace): { + statuses: Map; + nonWorkingDates: string[]; +} { + const priority: Record = { + ineligible: 0, + 'not-examined': 1, + pruned: 2, + 'evaluated-rejected': 3, + chosen: 4, + }; + const statuses = new Map(); + const nonWorkingDates = new Set(trace.nonWorkingDates?.map(({ date }) => date) ?? []); + for (const phase of trace.phases) { + for (const decision of phase.decisions) { + for (const candidate of decision.candidates) { + const dates = candidate.dates ?? (candidate.date ? [candidate.date] : []); + for (const date of dates) { + const previous = statuses.get(date); + if (!previous || priority[candidate.status] > priority[previous]) + statuses.set(date, candidate.status); + } + } } - if (event.data?.type !== 'counterfactual') return; - const analysis = analyzeCounterfactualInternal( - event.data.request, - event.data.plan, - event.data.placement, - event.data.state, - event.data.options, - ); - worker.postMessage({ type: 'counterfactual-result', analysis } satisfies WorkerResponse); + } + return { statuses, nonWorkingDates: [...nonWorkingDates] }; +} + +function sendArtifacts(artifacts: PlanningArtifacts, deferredTraceTransferMs = 0): void { + const trace = artifacts.trace; + let traceMaterializationMs = artifacts.timings?.traceMaterializationMs ?? 0; + let traceTransferMs = (artifacts.timings?.traceTransferMs ?? 0) + deferredTraceTransferMs; + if (trace) { + const materializationStarted = performance.now(); + const statusIndex = traceStatusIndex(trace); + traceMaterializationMs += Math.max(0, performance.now() - materializationStarted); + const transferStarted = performance.now(); + worker.postMessage({ + type: 'trace-status-index', + statuses: statusIndex.statuses, + nonWorkingDates: statusIndex.nonWorkingDates, + } satisfies WorkerResponse); + traceTransferMs += Math.max(0, performance.now() - transferStarted); + } + const rest: Omit< + PlanningArtifacts, + 'trace' | 'explanation' | 'explanationError' | 'finalState' + > = artifacts.timings + ? { + timings: { + ...artifacts.timings, + traceMaterializationMs, + traceTransferMs, + }, + } + : {}; + const transferStarted = performance.now(); + worker.postMessage({ + type: 'artifacts-start', + artifacts: rest, + trace: trace + ? { + status: trace.status, + initialState: trace.initialState, + completedStages: trace.completedStages, + nonWorkingDates: trace.nonWorkingDates, + error: trace.error, + } + : undefined, + } satisfies WorkerResponse); + traceTransferMs += Math.max(0, performance.now() - transferStarted); + // Capture updates already carried every candidate in bounded operations. + // The final packet closes the trace without cloning it a second time. + worker.postMessage({ type: 'artifacts-complete', traceTransferMs } satisfies WorkerResponse); +} + +function finishTraceDelivery(delivery: TraceDelivery): void { + if ( + !delivery.calculationComplete || + delivery.nextUpdate < delivery.updates.length || + delivery.inFlight > 0 || + !delivery.artifacts + ) + return; + if (activeTraceDelivery === delivery) activeTraceDelivery = undefined; + try { + sendArtifacts(delivery.artifacts, delivery.traceTransferMs); } catch (error) { worker.postMessage({ type: 'error', + code: 'trace-error', + message: error instanceof Error ? error.message : String(error), + } satisfies WorkerResponse); + } + delivery.updates = []; +} + +function pumpTraceUpdates(delivery: TraceDelivery): void { + while ( + delivery.inFlight < MAX_TRACE_PACKETS_IN_FLIGHT && + delivery.nextUpdate < delivery.updates.length + ) { + const staged = delivery.updates[delivery.nextUpdate]; + const update = staged.update; + if (!update) { + delivery.nextUpdate++; + continue; + } + const start = staged.nextOperation; + const end = Math.min(start + TRACE_OPERATIONS_PER_MESSAGE, update.operations.length); + const firstPacket = start === 0; + const packet: WorkerResponse = { + type: 'trace-update', + totalMs: staged.totalMs, + acknowledge: true, + update: { + initialState: firstPacket ? update.initialState : undefined, + nonWorkingDates: firstPacket ? update.nonWorkingDates : undefined, + operations: update.operations.slice(start, end), + }, + }; + const transferStarted = performance.now(); + try { + worker.postMessage(packet satisfies WorkerResponse); + } catch (error) { + if (activeTraceDelivery === delivery) activeTraceDelivery = undefined; + delivery.updates = []; + worker.postMessage({ + type: 'error', + code: 'trace-error', + message: error instanceof Error ? error.message : String(error), + } satisfies WorkerResponse); + return; + } + delivery.traceTransferMs += Math.max(0, performance.now() - transferStarted); + delivery.inFlight++; + staged.nextOperation = end; + if (update.operations.length === 0 || end >= update.operations.length) { + // Drop the full drain array as soon as its last bounded packet is posted. + staged.update = undefined; + delivery.nextUpdate++; + } + } + if (delivery.nextUpdate === delivery.updates.length) { + // All queued operation arrays are released; only in-flight structured clones remain. + delivery.updates = []; + delivery.nextUpdate = 0; + } + finishTraceDelivery(delivery); +} + +worker.onmessage = (event: MessageEvent) => { + try { + if (event.data?.type === 'trace-ack') { + const delivery = activeTraceDelivery; + if (!delivery || !Number.isInteger(event.data.count) || event.data.count <= 0) return; + delivery.inFlight = Math.max(0, delivery.inFlight - event.data.count); + pumpTraceUpdates(delivery); + return; + } + if (event.data?.type === 'plan') { + let artifacts: PlanningArtifacts = {}; + let outcomePosted = false; + let outcome: PlanningOutcome; + const traceDelivery: TraceDelivery = { + updates: [], + nextUpdate: 0, + inFlight: 0, + traceTransferMs: 0, + calculationComplete: false, + }; + activeTraceDelivery = traceDelivery; + try { + outcome = calculatePlanningInternal( + { + ...event.data.request, + progress: (stage: string) => + worker.postMessage({ + type: 'progress', + stage, + } satisfies WorkerResponse), + traceProgress: (totalMs: number, update: PlanningTraceUpdate) => { + worker.postMessage({ + type: 'trace-overhead', + totalMs, + } satisfies WorkerResponse); + traceDelivery.updates.push({ totalMs, update, nextOperation: 0 }); + }, + }, + (plan, state) => + worker.postMessage({ + type: 'checkpoint', + plan, + state, + } satisfies WorkerResponse), + (value) => (artifacts = value), + (value) => { + outcomePosted = true; + worker.postMessage({ + type: 'result', + outcome: value, + } satisfies WorkerResponse); + }, + (explanation, explanationError, finalState) => + worker.postMessage({ + type: 'explanation', + explanation, + explanationError, + finalState, + } satisfies WorkerResponse), + ); + } catch (error) { + if (activeTraceDelivery === traceDelivery) activeTraceDelivery = undefined; + traceDelivery.updates = []; + worker.postMessage({ + type: 'error', + code: 'planner-error', + message: error instanceof Error ? error.message : String(error), + } satisfies WorkerResponse); + return; + } + if (!outcomePosted) + worker.postMessage({ type: 'result', outcome } satisfies WorkerResponse); + traceDelivery.artifacts = artifacts; + traceDelivery.calculationComplete = true; + pumpTraceUpdates(traceDelivery); + return; + } + if (event.data?.type === 'explain-checkpoint') { + const result = explainIncompleteCheckpoint( + event.data.request, + event.data.plan, + event.data.state, + event.data.interruption, + ); + worker.postMessage({ type: 'explanation', ...result } satisfies WorkerResponse); + return; + } + if (event.data?.type === 'historical-eligibility') { + worker.postMessage({ + type: 'historical-eligibility-result', + entries: historicalCounterfactualReasons( + event.data.request, + event.data.state, + event.data.placement, + event.data.options, + ), + } satisfies WorkerResponse); + return; + } + if (event.data?.type !== 'counterfactual') return; + try { + let analysis = analyzeCounterfactualInternal( + event.data.request, + event.data.plan, + event.data.placement, + event.data.state, + event.data.options, + ); + if (event.data.options && event.data.state) { + const historicalReasons = new Map( + historicalCounterfactualReasons( + event.data.request, + event.data.state, + event.data.placement, + event.data.options, + ).map(({ date, reason }) => [date, reason]), + ); + analysis = { + ...analysis, + results: analysis.results.map((result) => { + const historicalReason = historicalReasons.get(result.date); + return historicalReason ? { ...result, historicalReason } : result; + }), + }; + } + worker.postMessage({ + type: 'counterfactual-result', + analysis, + } satisfies WorkerResponse); + } catch (error) { + worker.postMessage({ + type: 'error', + code: 'counterfactual-error', + message: error instanceof Error ? error.message : String(error), + } satisfies WorkerResponse); + } + } catch (error) { + worker.postMessage({ + type: 'error', + code: 'worker-error', message: error instanceof Error ? error.message : String(error), } satisfies WorkerResponse); } diff --git a/src/app/services/translation-service.ts b/src/app/services/translation-service.ts index 0dfa4e6..6fff8b1 100644 --- a/src/app/services/translation-service.ts +++ b/src/app/services/translation-service.ts @@ -124,6 +124,10 @@ export interface AppTranslations { expires: string; criteria: string; criteriaHelp: string; + traceImpactTitle: string; + traceImpactHelp: string; + traceStageTitle: string; + traceStageHelp: string; firstDifference: string; higher: string; lower: string; @@ -142,6 +146,7 @@ export interface AppTranslations { leaveUsed: string; expires: string; linkedRules: string; + atRiskContribution: string; impact: string; impactHelp: string; criterion: string; @@ -192,10 +197,16 @@ export interface AppTranslations { next: string; last: string; remaining: string; + balanceRemaining: string; counterfactual: string; counterfactualLoading: string; counterfactualReady: string; counterfactualError: string; + retryCounterfactual: string; + historicalEligibilityLoading: string; + historicalEligibilityReady: string; + historicalEligibilityError: string; + retryHistoricalEligibility: string; eligible: string; ineligible: string; historical: string; @@ -213,6 +224,7 @@ export interface AppTranslations { outsideHorizon: string; traceTitle: string; traceLoading: string; + traceReady: string; traceUnavailable: string; phases: string; firstPhase: string; diff --git a/src/i18n/de.json b/src/i18n/de.json index 36d278d..1746450 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -119,10 +119,16 @@ "next": "Nächste Platzierung", "last": "Letzte Platzierung", "remaining": "Ungenutzte Tage im endgültigen Plan", + "balanceRemaining": "verbleibend", "counterfactual": "Alternative Termine", "counterfactualLoading": "Alle Daten im Horizont werden analysiert…", "counterfactualReady": "Alternative Termine sind bereit.", "counterfactualError": "Alternative Termine konnten nicht berechnet werden.", + "retryCounterfactual": "Datumsanalyse erneut versuchen", + "historicalEligibilityLoading": "Prüfe, an welchen Tagen dieses Urlaubsguthaben verfügbar war …", + "historicalEligibilityReady": "Die historische Datumseignung ist bereit.", + "historicalEligibilityError": "Die historische Datumseignung konnte nicht geprüft werden.", + "retryHistoricalEligibility": "Historische Datumseignung erneut prüfen", "eligible": "Geeignet", "ineligible": "Nicht geeignet", "historical": "Status der ursprünglichen Suche", @@ -140,6 +146,7 @@ "outsideHorizon": "Kontextdatum außerhalb des Planungshorizonts", "traceTitle": "Erweitert: ursprüngliche Entscheidungsspur", "traceLoading": "Der Plan ist fertig; seine Entscheidungsspur wird noch geladen…", + "traceReady": "Der Plan ist fertig und seine Entscheidungsspur wurde geladen.", "traceUnavailable": "Für dieses Ergebnis ist keine Entscheidungsspur verfügbar.", "phases": "Spurphasen", "firstPhase": "Erste Phase", @@ -200,6 +207,7 @@ "leaveUsed": "Dieses Datum nutzt 1 Tag aus „{balance}“.", "expires": "Dieses Guthaben ist nutzbar bis", "linkedRules": "Zugehörige Regeln:", + "atRiskContribution": "Schützt Urlaub, der zu verfallen droht.", "impact": "Was dieser Tag verändert", "impactHelp": "Vergleichen Sie den gesamten Plan mit und ohne diesen Urlaubstag. Alle anderen Urlaubstermine bleiben gleich; nur geänderte Werte werden angezeigt.", "criterion": "Im gesamten Plan", @@ -218,6 +226,10 @@ "withCandidate": "Mit diesem Kandidaten", "comparisonUnavailable": "Der Vergleich für das ursprüngliche Datum ist nicht verfügbar. Daher lässt sich die Änderung nicht anzeigen.", "objectives": { + "obligation-validity": { + "label": "Unerfüllte Verpflichtungen", + "help": "Anzahl nicht erfüllter Pflichtregeln. Ein gültiger Plan hat den Wert null." + }, "unplaced-at-risk": { "label": "Noch nicht genutzte Tage mit Verfallsrisiko", "help": "Ungenutzte Tage, die im Planungszeitraum verfallen. Weniger ist besser." @@ -282,6 +294,10 @@ "expires": "Verfällt am", "criteria": "Die Prioritäten des Planers", "criteriaHelp": "Von oben nach unten lesen: Der erste Unterschied hat Vorrang. Wählen Sie ein Kriterium, um seine Berechnung zu sehen.", + "traceImpactTitle": "Auswirkung dieses Zweigs auf den Gesamtplan", + "traceImpactHelp": "Diese Werte vergleichen den vollständigen Zustand vor und nach diesem Zweig. Sie zeigen seine Auswirkungen, aber nicht, warum der Planer ihn gewählt hat.", + "traceStageTitle": "Tatsächlicher Vergleich in dieser Planungsstufe", + "traceStageHelp": "Diese Rohwerte wurden bei dieser Entscheidung verglichen; der erste Unterschied erklärt den Status des Zweigs. Die Tabelle zum Gesamtplan unten zeigt die Auswirkung dieser Wahl.", "firstDifference": "Erster Unterschied", "higher": "Höher ist besser", "lower": "Niedriger ist besser", diff --git a/src/i18n/en.json b/src/i18n/en.json index 09ab578..7076d33 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -119,10 +119,16 @@ "next": "Next placement", "last": "Last placement", "remaining": "Unused days in the final plan", + "balanceRemaining": "remaining", "counterfactual": "Date alternatives", "counterfactualLoading": "Analyzing every date in the horizon…", "counterfactualReady": "Date alternatives are ready.", "counterfactualError": "Date alternatives could not be calculated.", + "retryCounterfactual": "Retry date analysis", + "historicalEligibilityLoading": "Checking which dates this leave could use…", + "historicalEligibilityReady": "Original date eligibility is ready.", + "historicalEligibilityError": "Original date eligibility could not be checked.", + "retryHistoricalEligibility": "Retry date eligibility check", "eligible": "Eligible", "ineligible": "Ineligible", "historical": "Original search status", @@ -140,6 +146,7 @@ "outsideHorizon": "Context date outside the planning horizon", "traceTitle": "Advanced: original decision trace", "traceLoading": "The plan is ready; its decision trace is still loading…", + "traceReady": "The plan is ready and its decision trace has loaded.", "traceUnavailable": "No decision trace is available for this result.", "phases": "Trace phases", "firstPhase": "First phase", @@ -200,6 +207,7 @@ "leaveUsed": "This date uses 1 day from {balance}.", "expires": "Use this balance by", "linkedRules": "Linked rules:", + "atRiskContribution": "Protects leave at risk of expiry.", "impact": "What this day changes", "impactHelp": "Compare the whole plan with and without this day of leave. All other leave dates stay the same; only changed totals are shown.", "criterion": "Across your plan", @@ -218,6 +226,10 @@ "withCandidate": "With this candidate", "comparisonUnavailable": "The comparison for the original date is unavailable, so the change cannot be shown.", "objectives": { + "obligation-validity": { + "label": "Unmet obligations", + "help": "Number of required obligations left unsatisfied. A valid plan has zero." + }, "unplaced-at-risk": { "label": "Leave left at risk of expiry", "help": "Unused days expiring within the planning period. Fewer is better." @@ -282,6 +294,10 @@ "expires": "Expires on", "criteria": "The planner’s priorities", "criteriaHelp": "Read from top to bottom: the first difference takes priority. Select a criterion to see how it is calculated.", + "traceImpactTitle": "Full-plan effect of this branch", + "traceImpactHelp": "These values compare the complete state before and after this branch. They show its impact; they do not explain why the planner chose it.", + "traceStageTitle": "Actual stage comparison", + "traceStageHelp": "These raw values were compared at this decision; the first difference explains the candidate’s status. The full-plan table below shows the effect after this choice.", "firstDifference": "First difference", "higher": "Higher is better", "lower": "Lower is better", diff --git a/src/i18n/es.json b/src/i18n/es.json index 2b3349d..5dd14fd 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -119,10 +119,16 @@ "next": "Asignación siguiente", "last": "Última asignación", "remaining": "Días sin usar en el plan final", + "balanceRemaining": "restantes", "counterfactual": "Fechas alternativas", "counterfactualLoading": "Analizando cada fecha del horizonte…", "counterfactualReady": "Las fechas alternativas están listas.", "counterfactualError": "No se pudieron calcular las fechas alternativas.", + "retryCounterfactual": "Reintentar el análisis de fechas", + "historicalEligibilityLoading": "Comprobando qué fechas podía usar este permiso…", + "historicalEligibilityReady": "La elegibilidad histórica de las fechas está lista.", + "historicalEligibilityError": "No se pudo comprobar la elegibilidad histórica de las fechas.", + "retryHistoricalEligibility": "Reintentar la comprobación de elegibilidad", "eligible": "Válida", "ineligible": "No válida", "historical": "Estado de la búsqueda original", @@ -140,6 +146,7 @@ "outsideHorizon": "Fecha de contexto fuera del horizonte de planificación", "traceTitle": "Avanzado: traza de decisiones original", "traceLoading": "El plan está listo; su traza de decisiones aún se está cargando…", + "traceReady": "El plan está listo y se ha cargado su traza de decisiones.", "traceUnavailable": "No hay una traza de decisiones disponible para este resultado.", "phases": "Fases de la traza", "firstPhase": "Primera fase", @@ -200,6 +207,7 @@ "leaveUsed": "Esta fecha usa 1 día del saldo «{balance}».", "expires": "Usa este saldo hasta el", "linkedRules": "Reglas vinculadas:", + "atRiskContribution": "Protege permisos que podrían vencer.", "impact": "Qué cambia con este día", "impactHelp": "Compara todo el plan con y sin este día de permiso. Las demás fechas no cambian; solo se muestran los totales que varían.", "criterion": "En todo tu plan", @@ -218,6 +226,10 @@ "withCandidate": "Con este candidato", "comparisonUnavailable": "La comparación de la fecha original no está disponible, por lo que no se puede mostrar el cambio.", "objectives": { + "obligation-validity": { + "label": "Obligaciones sin cumplir", + "help": "Número de reglas obligatorias incumplidas. Un plan válido tiene cero." + }, "unplaced-at-risk": { "label": "Días sin usar que podrían vencer", "help": "Días sin planificar que vencen durante el período. Menos es mejor." @@ -282,6 +294,10 @@ "expires": "Vence el", "criteria": "Las prioridades del cálculo", "criteriaHelp": "Lee de arriba abajo: la primera diferencia tiene prioridad. Selecciona un criterio para ver cómo se calcula.", + "traceImpactTitle": "Efecto de esta rama en el plan completo", + "traceImpactHelp": "Estos valores comparan el estado completo antes y después de esta rama. Muestran su efecto, no por qué el planificador la eligió.", + "traceStageTitle": "Comparación real en esta etapa", + "traceStageHelp": "Estos valores brutos se compararon en esta decisión; la primera diferencia explica el estado de la opción. La tabla del plan completo muestra el efecto de esta elección.", "firstDifference": "Primera diferencia", "higher": "Más es mejor", "lower": "Menos es mejor", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 6502016..63640c6 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -119,10 +119,16 @@ "next": "Placement suivant", "last": "Dernier placement", "remaining": "Jours non utilisés dans le planning final", + "balanceRemaining": "restants", "counterfactual": "Dates alternatives", "counterfactualLoading": "Analyse de chaque date de l’horizon…", "counterfactualReady": "Les dates alternatives sont prêtes.", "counterfactualError": "Les dates alternatives n’ont pas pu être calculées.", + "retryCounterfactual": "Relancer l’analyse des dates", + "historicalEligibilityLoading": "Vérification des dates utilisables pour ce congé…", + "historicalEligibilityReady": "L’éligibilité historique des dates est prête.", + "historicalEligibilityError": "L’éligibilité historique des dates n’a pas pu être vérifiée.", + "retryHistoricalEligibility": "Revérifier l’éligibilité historique", "eligible": "Éligible", "ineligible": "Inéligible", "historical": "Statut de la recherche originale", @@ -140,6 +146,7 @@ "outsideHorizon": "Date de contexte hors de l’horizon de planification", "traceTitle": "Avancé : trace des décisions originales", "traceLoading": "Le plan est prêt ; sa trace de décision est encore en cours de chargement…", + "traceReady": "Le plan est prêt et sa trace de décision est chargée.", "traceUnavailable": "Aucune trace de décision n’est disponible pour ce résultat.", "phases": "Phases de la trace", "firstPhase": "Première phase", @@ -200,6 +207,7 @@ "leaveUsed": "Cette date utilise 1 jour du solde « {balance} ».", "expires": "Solde à utiliser au plus tard le", "linkedRules": "Règles liées :", + "atRiskContribution": "Protège des congés à risque d’expiration.", "impact": "Ce que ce jour change", "impactHelp": "Comparez tout le planning avec et sans ce jour de congé. Les autres dates restent identiques ; seuls les totaux qui changent sont affichés.", "criterion": "Sur tout le planning", @@ -218,6 +226,10 @@ "withCandidate": "Avec ce candidat", "comparisonUnavailable": "La comparaison pour la date initiale est indisponible ; la différence ne peut donc pas être affichée.", "objectives": { + "obligation-validity": { + "label": "Obligations non satisfaites", + "help": "Nombre de règles obligatoires non respectées. Un plan valide en compte zéro." + }, "unplaced-at-risk": { "label": "Congés risquant d’expirer sans être utilisés", "help": "Jours non posés expirant pendant la période planifiée. Moins, c’est mieux." @@ -282,6 +294,10 @@ "expires": "Expire le", "criteria": "Les priorités du calcul", "criteriaHelp": "Lisez de haut en bas : la première différence est prioritaire. Sélectionnez un critère pour comprendre son calcul.", + "traceImpactTitle": "Effet de cette branche sur le plan complet", + "traceImpactHelp": "Ces valeurs comparent l’état complet avant et après cette branche. Elles montrent son effet, pas pourquoi le calcul l’a choisie.", + "traceStageTitle": "Comparaison réelle à cette étape", + "traceStageHelp": "Ces valeurs brutes ont été comparées lors de cette décision ; la première différence explique le statut de la possibilité. Le tableau du plan complet ci-dessous montre l’effet de ce choix.", "firstDifference": "Première différence", "higher": "Plus, c’est mieux", "lower": "Moins, c’est mieux",