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",