feat(planning): explain and trace planning decisions

This commit is contained in:
nessar committed 2026-08-31 00:02:45 +02:00
1 parent 609b3dece9
commit d7a13b0b1e
24 files changed
+6073 -342

No files matched your search

+10
View File
@@ -105,6 +105,16 @@ describe('CalendarService', () => {
);
expect(hasToday).toBe(true);
});
it('includes the final partial month of a half-open one-year horizon', () => {
const selectedDates = service.getSelectedDatesForHorizon({
start: '2024-01-15',
end: '2025-01-15',
});
expect(selectedDates.getClasses(new Date('2025-01-04T00:00:00'))).toContain(
DayType.WEEKEND,
);
});
});
describe('setWeekendSelection', () => {
+30 -11
View File
@@ -6,6 +6,7 @@ import weekOfYear from 'dayjs/plugin/weekOfYear';
import isoWeek from 'dayjs/plugin/isoWeek';
import { DateCacheService } from './date-cache.service';
import {
createPlanningCalculation as createPlanningCalculationOperation,
planLeave as planLeaveOperation,
LeaveType,
PlanOutcome,
@@ -30,14 +31,19 @@ export class CalendarService {
private dateCache = inject(DateCacheService);
readonly planLeave = planLeaveOperation;
readonly createPlanningCalculation = createPlanningCalculationOperation;
getDefaultPlanningHorizon(now = new Date()): { start: string; end: string } {
const start = planningDateInParis(now);
return { start, end: addPlanningYears(start, 1) };
}
selectedDatesForPlan(plan: PlanOutcome): SelectedDates {
const selected = this.getSelectedDatesForYear();
selectedDatesForPlan(
plan: Pick<PlanOutcome, 'placements'> & { horizon?: { start: string; end: string } },
): SelectedDates {
const selected = plan.horizon
? this.getSelectedDatesForHorizon(plan.horizon)
: this.getSelectedDatesForYear();
for (const placement of plan.placements) {
selected.push(
new SelectedDate(
@@ -65,8 +71,22 @@ export class CalendarService {
}
getSelectedDatesForYear(): SelectedDates {
const holidays = this.getHolidays();
const monthInAYear = this.monthsInAYearFromNow();
return this.getSelectedDatesForHorizon(this.getDefaultPlanningHorizon());
}
getSelectedDatesForHorizon(horizon: { start: string; end: string }): SelectedDates {
const start = new Date(`${horizon.start}T00:00:00`);
const last = new Date(`${horizon.end}T00:00:00`);
last.setDate(last.getDate() - 1);
const holidays = this.getHolidays(start.getFullYear());
const monthInHorizon: Year = [];
const cursor = new Date(start.getFullYear(), start.getMonth(), 1);
while (cursor <= last) {
monthInHorizon.push(
CalendarService.weeksInMonth(cursor.getMonth(), cursor.getFullYear()),
);
cursor.setMonth(cursor.getMonth() + 1);
}
const selectedDates: SelectedDates = new SelectedDates();
// Use DateCacheService to get today's date components
@@ -79,7 +99,7 @@ export class CalendarService {
// Create a Set of holiday timestamps for O(1) lookup instead of O(n)
const holidayTimestamps = new Set(holidays.map((h) => h.getTime()));
for (const weeksInAMonth of monthInAYear) {
for (const weeksInAMonth of monthInHorizon) {
for (const daysInAWeek of weeksInAMonth) {
for (const day of daysInAWeek) {
if (day) {
@@ -131,10 +151,9 @@ export class CalendarService {
}
}
getHolidays(): Date[] {
getHolidays(startYear = this.dateCache.getCurrentYear()): Date[] {
// Cache holidays to avoid recalculation
const currentYear = this.dateCache.getCurrentYear();
if (this.holidayCacheYear === currentYear && this.holidayTimestampsCache) {
if (this.holidayCacheYear === startYear && this.holidayTimestampsCache) {
// Return timestamps directly for efficient lookup
return Array.from(this.holidayTimestampsCache).map((ts) => new Date(ts));
}
@@ -142,10 +161,10 @@ export class CalendarService {
// Logic to return holidays for the given year
const hd = new Holidays('FR', allTypesOptions);
const holidays = hd
.getHolidays(currentYear, lang)
.getHolidays(startYear, lang)
.filter((holiday) => holiday.type === 'public');
const holidaysPlusOne = hd
.getHolidays(currentYear + 1, lang)
.getHolidays(startYear + 1, lang)
.filter((holiday) => holiday.type === 'public');
holidays.push(...holidaysPlusOne);
const holidaysDate: Date[] = holidays.map((holiday) => {
@@ -155,7 +174,7 @@ export class CalendarService {
// Update cache
this.holidayTimestampsCache = new Set(holidaysDate.map((d) => d.getTime()));
this.holidayCacheYear = currentYear;
this.holidayCacheYear = startYear;
return holidaysDate;
}
File diff suppressed because it is too large. Load diff
+642 -1
View File
@@ -1,5 +1,10 @@
import { vi } from 'vitest';
import { PlanningOutcome, PlanningRequest, planLeave } from './leave-planner.service';
import {
PlanningOutcome,
PlanningRequest,
createPlanningCalculation,
planLeave,
} from './leave-planner.service';
const horizon = { start: '2024-01-01', end: '2025-01-01' };
const balance = (
@@ -1113,6 +1118,43 @@ describe('planLeave transport', () => {
}
});
it('does not charge reported trace instrumentation time to the planning deadline', async () => {
const expected = isPlan(
await runInNonBrowser({ horizon, balances: [balance('cp', 'cp', 1)], rules: [] }),
);
class TraceAwareWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
postMessage(): void {
this.onmessage?.({ data: { type: 'trace-overhead', totalMs: 30 } } as MessageEvent);
setTimeout(() => {
this.onmessage?.({
data: { type: 'result', outcome: expected },
} as MessageEvent);
this.onmessage?.({
data: { type: 'artifacts', artifacts: {} },
} as MessageEvent);
}, 15);
}
terminate(): void {}
}
vi.stubGlobal('Worker', TraceAwareWorker);
try {
expect(
await planLeave({
horizon,
balances: [balance('cp', 'cp', 1)],
rules: [],
deadlineMs: 10,
}),
).toEqual(expected);
} finally {
vi.unstubAllGlobals();
}
});
it('sends a detached, callback-free request to the worker transport', async () => {
const sourceDate = new Date('2024-01-01T00:00:00Z');
const request = {
@@ -1197,3 +1239,602 @@ describe('planLeave transport', () => {
}
});
});
describe('planning calculation context', () => {
it('keeps the planning outcome identical when trace instrumentation is enabled', async () => {
const core = await import('./leave-planner.core');
const request = {
horizon: { start: '2024-01-01', end: '2024-02-01' },
balances: [balance('cp', 'cp', 3, '2024-01-31')],
rules: [],
};
const withoutTrace = core.calculatePlanningInternal(request);
let traceStatus: string | undefined;
const withTrace = core.calculatePlanningInternal(request, undefined, (artifacts) => {
traceStatus = artifacts.trace?.status;
});
expect(withTrace).toEqual(withoutTrace);
expect(traceStatus).toBe('ready');
});
it('keeps an immutable submitted snapshot beside the outcome', async () => {
const request = {
horizon,
balances: [balance('cp', 'cp', 1)],
rules: [],
labels: { cp: 'Paid leave' },
};
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
const calculation = createPlanningCalculation(request);
request.balances[0].quantity = 99;
request.labels.cp = 'Changed later';
expect(calculation.snapshot.balances[0].quantity).toBe(1);
expect(calculation.snapshot.labels['cp']).toBe('Paid leave');
expect((await calculation.outcome).kind).toBe('Plan');
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('materializes a chronological explanation after the unchanged outcome', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-01-15' },
balances: [
{
...balance('cp', 'cp', 1, '2026-01-01'),
name: 'Paid leave entitlement',
},
],
rules: [
{
id: 'winter',
name: 'Winter break',
type: 'time_window',
startDate: '2024-01-02',
endDate: '2024-01-12',
placeDays: 1,
placeType: 'cp',
},
],
labels: { winter: 'Winter break' },
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan') return;
expect('explanation' in outcome).toBe(false);
const explanation = await calculation.explanation;
expect(explanation?.kind).toBe('PlanExplanation');
if (explanation?.kind !== 'PlanExplanation') return;
expect(explanation.placements).toHaveLength(outcome.placements.length);
expect(explanation.placements[0].balance?.name).toBe('Paid leave entitlement');
expect(explanation.placements[0].ruleIds).toContain('winter');
expect(explanation.placements[0].objectiveVector.objectives.length).toBeGreaterThan(1);
expect('score' in explanation.placements[0].objectiveVector).toBe(false);
expect(explanation.placements[0].firstDecisiveObjective).toBe(
explanation.placements[0].comparison.firstDecisiveObjective,
);
expect(explanation.placements[0].firstDecisiveObjective).toBe('preference:winter');
const alternatives = await calculation.analyzeCounterfactual(outcome.placements[0]);
expect(alternatives.kind).toBe('Counterfactual');
if (alternatives.kind === 'Counterfactual')
expect(
alternatives.results.find(
(result) => result.date === outcome.placements[0].date,
)?.comparison?.firstDecisiveObjective,
).toBe('preference:winter');
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('reports every rule contribution when one placement is shared', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-01-06' },
balances: [balance('cp', 'cp', 1)],
rules: [
{
id: 'wide',
name: 'Wide window',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-02',
endDate: '2024-01-05',
placeDays: 1,
placeType: 'cp',
},
{
id: 'exact',
name: 'Exact day',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-05',
endDate: '2024-01-05',
placeDays: 1,
placeType: 'cp',
},
],
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('Plan');
const explanation = await calculation.explanation;
expect(explanation?.kind).toBe('PlanExplanation');
if (explanation?.kind !== 'PlanExplanation') return;
expect(
explanation.placements[0].contributions
.filter((contribution) => contribution.kind === 'rule')
.map((contribution) => contribution.ruleId),
).toEqual(['exact', 'wide']);
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('compares the selected placement across the half-open horizon without changing the plan', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-01-10' },
balances: [balance('cp', 'cp', 1, '2024-12-31')],
rules: [],
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan') return;
const before = structuredClone(outcome);
const analysis = await calculation.analyzeCounterfactual(outcome.placements[0]);
expect(analysis.kind).toBe('Counterfactual');
if (analysis.kind !== 'Counterfactual') return;
expect(analysis.results).toHaveLength(9);
expect(analysis.results.some((result) => result.reason?.code === 'non-working')).toBe(
true,
);
expect(
analysis.results.some(
(result) => result.status === 'eligible' && result.comparison,
),
).toBe(true);
expect(outcome).toEqual(before);
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('preserves supporting provenance in counterfactual placements', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-05', end: '2024-01-10' },
balances: [balance('cp', 'cp', 1, '2025-01-01')],
rules: [],
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan') return;
const selected = outcome.placements.find((placement) => placement.supporting);
expect(selected).toBeDefined();
if (!selected) return;
const analysis = await calculation.analyzeCounterfactual(selected);
expect(analysis.kind).toBe('Counterfactual');
if (analysis.kind !== 'Counterfactual') return;
const restored = analysis.results.find((result) => result.date === selected.date);
expect(
restored?.comparison?.after.objectives.find(
(objective) => objective.id === 'supporting-placements',
)?.value,
).toBe(1);
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('discards counterfactual work from an older calculation', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
const older = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-01-10' },
balances: [balance('cp', 'cp', 1)],
rules: [],
});
const olderOutcome = await older.outcome;
expect(olderOutcome.kind).toBe('Plan');
if (olderOutcome.kind !== 'Plan') return;
createPlanningCalculation({ horizon, balances: [], rules: [] });
const analysis = await older.analyzeCounterfactual(olderOutcome.placements[0]);
expect(analysis.kind).toBe('CounterfactualStale');
expect(analysis.results).toEqual([]);
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('keeps earned-entitlement availability and provenance in counterfactuals', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
const calculation = createPlanningCalculation({
horizon: { start: '2024-04-25', end: '2024-05-15' },
balances: [balance('cp', 'cp', 2), balance('rtt', 'rtt', 0, '2026-01-01')],
rules: [
{
id: 'wednesday-holidays',
type: 'if_then',
force: 'obligation',
dayOfWeek: 3,
placeDays: 1,
placeType: 'cp',
placement: 'after',
rewardDays: 1,
rewardType: 'rtt',
},
{
id: 'thursday-consumer',
type: 'if_then',
force: 'obligation',
dayOfWeek: 4,
placeDays: 1,
placeType: 'rtt',
placement: 'before',
rewardDays: 0,
},
],
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan') return;
const earned = outcome.placements.find((placement) =>
placement.balanceId.startsWith('earned:'),
);
expect(earned).toBeDefined();
if (!earned) return;
const analysis = await calculation.analyzeCounterfactual(earned);
expect(analysis.kind).toBe('Counterfactual');
if (analysis.kind !== 'Counterfactual') return;
expect(analysis.placement).toEqual(earned);
expect(
analysis.results.find((result) => result.date === '2024-04-25')?.reason?.code,
).toBe('unavailable');
const trace = await calculation.trace;
const earnedBalanceDelta = trace?.phases
.flatMap((phase) => phase.decisions)
.flatMap((decision) => decision.deltas)
.find((delta) => delta.balance?.id === earned.balanceId);
expect(earnedBalanceDelta?.balance).toMatchObject({
id: earned.balanceId,
type: earned.type,
earned: true,
});
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('replays a trace decision from its pre-decision state', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-02-01' },
balances: [balance('cp', 'cp', 2, '2024-01-31')],
rules: [
{
id: 'wide',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-01',
endDate: '2024-01-31',
placeDays: 1,
placeType: 'cp',
},
],
});
const outcome = await calculation.outcome;
const trace = await calculation.trace;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan' || !trace) return;
const decision = trace.phases
.flatMap((phase) => phase.decisions)
.find((candidate) =>
candidate.deltas.some((delta) => delta.kind === 'placement-added'),
);
const placement = decision?.deltas.find(
(delta) => delta.kind === 'placement-added',
)?.placement;
expect(decision).toBeDefined();
expect(placement).toBeDefined();
if (!decision || !placement) return;
const laterPlacement = outcome.placements.find(
(candidate) => candidate.date !== placement.date,
);
expect(laterPlacement).toBeDefined();
if (!laterPlacement) return;
const analysis = await calculation.analyzeCounterfactual({
decisionId: decision.id,
placement,
});
expect(analysis.kind).toBe('Counterfactual');
if (analysis.kind !== 'Counterfactual') return;
expect(
analysis.results.find((result) => result.date === laterPlacement.date)?.status,
).toBe('eligible');
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('reconstructs the returned plan from the original trace deltas', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-02-01' },
balances: [balance('cp', 'cp', 4, '2024-01-31')],
rules: [],
});
const outcome = await calculation.outcome;
const trace = await calculation.trace;
expect(outcome.kind).toBe('Plan');
expect(trace?.status).toBe('ready');
if (outcome.kind !== 'Plan' || !trace) return;
const placements = new Map(
trace.initialState.placements.map((placement) => [placement.date, placement]),
);
for (const delta of trace.phases.flatMap((phase) =>
phase.decisions.flatMap((decision) => decision.deltas),
)) {
if (delta.previousPlacement) placements.delete(delta.previousPlacement.date);
if (delta.placement) placements.set(delta.placement.date, delta.placement);
}
expect([...placements.values()].sort((a, b) => a.date.localeCompare(b.date))).toEqual(
outcome.placements,
);
expect(trace.phases.map((phase) => phase.name)).toEqual(
expect.arrayContaining([
'obligations',
'at-risk',
'holiday-week-value',
'non-working-day-gain',
'longest-work-sequence',
'short-sequence-deficit',
'supporting-placements',
'expiry',
'dates',
]),
);
expect(
trace.phases
.flatMap((phase) => phase.decisions)
.flatMap((decision) => decision.candidates).length,
).toBeGreaterThan(20);
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('records loaded-plan pruning and full quality comparisons without truncation', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-04-01' },
balances: [balance('cp', 'cp', 25, '2024-03-31')],
rules: [],
});
expect((await calculation.outcome).kind).toBe('Plan');
const trace = await calculation.trace;
const candidates =
trace?.phases.flatMap((phase) =>
phase.decisions.flatMap((decision) => decision.candidates),
) ?? [];
expect(
candidates.some(
(candidate) =>
candidate.status === 'not-examined' &&
candidate.reason?.code === 'loaded-plan-sampling',
),
).toBe(true);
const comparison = candidates.find((candidate) => candidate.comparison)?.comparison;
expect(comparison?.before.objectives.length).toBeGreaterThan(1);
expect(comparison?.after.objectives.length).toBe(comparison?.before.objectives.length);
expect(
candidates.some((candidate) => candidate.comparison && candidate.sourceDate),
).toBe(true);
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('offers no explanation trace for invalid input and explains conflicts', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
const invalidCalculation = createPlanningCalculation({
horizon: { start: '2025-01-01', end: '2024-01-01' },
balances: [],
rules: [],
});
expect((await invalidCalculation.outcome).kind).toBe('InvalidInput');
expect(await invalidCalculation.explanation).toBeUndefined();
expect(await invalidCalculation.trace).toBeUndefined();
const conflictCalculation = createPlanningCalculation({
horizon,
balances: [balance('rtt', 'rtt', 1)],
rules: [
{
id: 'cp-only',
name: 'Paid leave only',
type: 'time_window',
force: 'obligation',
startDate: '2024-04-01',
endDate: '2024-04-05',
placeDays: 1,
placeType: 'cp',
},
],
});
expect((await conflictCalculation.outcome).kind).toBe('PlanningConflict');
const explanation = await conflictCalculation.explanation;
expect(explanation?.kind).toBe('ConflictExplanation');
if (explanation?.kind === 'ConflictExplanation')
expect(explanation.conflictingRules).toEqual([
{ id: 'cp-only', label: 'Paid leave only' },
]);
const trace = await conflictCalculation.trace;
expect(trace?.phases.some((phase) => phase.name === 'holiday-week-value')).toBe(false);
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('marks interrupted and unexecuted phases when no incumbent exists', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
const calculation = createPlanningCalculation({
horizon,
balances: [balance('cp', 'cp', 5)],
rules: [],
deadlineMs: 0,
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('PlanningIncomplete');
const explanation = await calculation.explanation;
expect(explanation?.kind).toBe('IncompleteExplanation');
const trace = await calculation.trace;
expect(trace?.phases.some((phase) => phase.status === 'interrupted')).toBe(true);
expect(trace?.phases.some((phase) => phase.status === 'not-run')).toBe(true);
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('explains an incomplete incumbent without reusing the expired planning deadline', async () => {
const core = await import('./leave-planner.core');
let artifacts: import('./leave-planner.core').PlanningArtifacts | undefined;
const outcome = core.calculatePlanningInternal(
{
horizon,
balances: Array.from({ length: 12 }, (_, index) =>
balance(`balance-${index}`, index % 2 ? 'rtt' : 'cp', 12),
),
rules: [],
deadlineMs: 20,
},
undefined,
(value) => (artifacts = value),
);
expect(outcome.kind).toBe('PlanningIncomplete');
if (outcome.kind !== 'PlanningIncomplete') return;
expect(outcome.incumbent).toBeDefined();
expect(artifacts?.explanation?.kind).toBe('PlanExplanation');
if (artifacts?.explanation?.kind !== 'PlanExplanation') return;
expect(artifacts.explanation.incomplete?.reason.code).toBe('deadline');
expect(artifacts.explanation.placements).toHaveLength(
outcome.incumbent?.placements.length ?? 0,
);
});
it('publishes the worker outcome before delayed explanation artifacts', async () => {
const expected = isPlan(
await runInNonBrowser({ horizon, balances: [balance('cp', 'cp', 1)], rules: [] }),
);
const trace = {
status: 'ready' as const,
initialState: {
horizon,
balances: [],
placements: [],
earnedActivations: [],
},
phases: [],
completedStages: [],
};
class DelayedArtifactsWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
postMessage(): void {
queueMicrotask(() =>
this.onmessage?.({
data: { type: 'result', outcome: expected },
} as MessageEvent),
);
setTimeout(
() =>
this.onmessage?.({
data: { type: 'artifacts', artifacts: { trace } },
} as MessageEvent),
20,
);
}
terminate(): void {}
}
vi.stubGlobal('Worker', DelayedArtifactsWorker);
try {
const calculation = createPlanningCalculation({
horizon,
balances: [balance('cp', 'cp', 1)],
rules: [],
});
let traceReady = false;
void calculation.trace.then(() => (traceReady = true));
expect((await calculation.outcome).kind).toBe('Plan');
expect(traceReady).toBe(false);
expect((await calculation.trace)?.status).toBe('ready');
} finally {
vi.unstubAllGlobals();
}
});
it('keeps a valid outcome when trace delivery fails afterward', async () => {
const expected = isPlan(
await runInNonBrowser({ horizon, balances: [balance('cp', 'cp', 1)], rules: [] }),
);
class FailingArtifactsWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
postMessage(): void {
queueMicrotask(() => {
this.onmessage?.({
data: { type: 'result', outcome: expected },
} as MessageEvent);
queueMicrotask(() => this.onerror?.());
});
}
terminate(): void {}
}
vi.stubGlobal('Worker', FailingArtifactsWorker);
try {
const calculation = createPlanningCalculation({
horizon,
balances: [balance('cp', 'cp', 1)],
rules: [],
});
expect(await calculation.outcome).toEqual(expected);
expect((await calculation.trace)?.status).toBe('failed');
expect((await calculation.explanationError)?.code).toBe('worker-error');
} finally {
vi.unstubAllGlobals();
}
});
});
+679 -69
View File
@@ -1,35 +1,71 @@
import type {
CounterfactualAnalysis,
CounterfactualBaselineOptions,
CounterfactualDateResult,
CounterfactualSelection,
InvalidInputOutcome,
LeaveBalanceInput,
LeavePlacement,
LeaveType,
PlanOutcome,
PlanningArtifacts,
PlanningExplanation,
PlanningIncompleteOutcome,
PlanningOutcome,
PlanningRequest,
PlanningSnapshot,
PlanningTrace,
PlanningReason,
PlanningTraceState,
StaleCounterfactualAnalysis,
TraceCounterfactualSelection,
} from './leave-planner.core';
export type {
IfThenPlanningRule,
ConflictExplanation,
CounterfactualAnalysis,
CounterfactualSelection,
CounterfactualDateResult,
CounterfactualReasonCode,
InvalidInputOutcome,
IncompleteExplanation,
LeaveBalanceInput,
LeavePlacement,
LeaveSelector,
LeaveType,
PlanOutcome,
PlanExplanation,
PlacementMode,
PlanningConflictOutcome,
PlanningDate,
PlanningHorizon,
PlanningIncompleteOutcome,
PlanningExplanation,
PlanningMetrics,
PlanningObjective,
PlanningObjectiveComparison,
PlanningObjectiveDelta,
PlanningObjectiveId,
PlanningObjectiveVector,
PlanningOutcome,
PlanningReason,
PlanningRequest,
PlanningRule,
PlanningSnapshot,
PlanningTrace,
PlanningTraceCandidate,
PlanningTraceDecision,
PlanningTraceDelta,
PlanningTracePhase,
PlanningTraceState,
PreferenceResult,
RemainingBalance,
RemainingClassification,
RuleForce,
TimeOffPeriod,
TimeWindowPlanningRule,
TraceCounterfactualSelection,
} from './leave-planner.core';
const MAX_PLANNING_MS = 10_000;
@@ -40,12 +76,15 @@ interface UntrustedRecord {
end?: unknown;
balances?: unknown;
rules?: unknown;
identities?: unknown;
labels?: unknown;
deadlineMs?: unknown;
progress?: unknown;
id?: unknown;
name?: unknown;
type?: unknown;
quantity?: unknown;
label?: unknown;
availableFrom?: unknown;
expiryDate?: unknown;
enabled?: unknown;
@@ -60,10 +99,12 @@ interface UntrustedRecord {
rewardType?: unknown;
}
interface PlanningSnapshot {
interface TransportSnapshot {
horizon?: { start?: unknown; end?: unknown };
balances?: unknown;
rules?: unknown;
identities?: unknown;
labels?: unknown;
deadlineMs?: unknown;
progress?: unknown;
}
@@ -76,7 +117,71 @@ function copyDate(value: unknown): unknown {
return value instanceof Date ? new Date(value.getTime()) : value;
}
function snapshotRequest(request: unknown): PlanningSnapshot {
function cloneUnknown(value: unknown, seen = new WeakMap<object, unknown>()): unknown {
if (value instanceof Date) return new Date(value.getTime());
if (typeof value !== 'object' || value === null) return value;
const previous = seen.get(value);
if (previous) return previous;
if (Array.isArray(value)) {
const result: unknown[] = [];
seen.set(value, result);
for (const item of value) result.push(cloneUnknown(item, seen));
return result;
}
const result: Record<string, unknown> = {};
seen.set(value, result);
for (const [key, item] of Object.entries(value)) result[key] = cloneUnknown(item, seen);
return result;
}
function snapshotValue(value: unknown): unknown {
if (value instanceof Date) return value.toISOString();
if (Array.isArray(value)) return value.map(snapshotValue);
if (typeof value !== 'object' || value === null) return value;
const result: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) result[key] = snapshotValue(item);
return result;
}
function deepFreeze<T>(value: T, seen = new WeakSet<object>()): T {
if (typeof value !== 'object' || value === null || seen.has(value)) return value;
seen.add(value);
for (const child of Object.values(value as Record<string, unknown>)) deepFreeze(child, seen);
return Object.freeze(value);
}
function stringMap(value: unknown): Readonly<Record<string, string>> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return {};
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
key,
typeof item === 'string' ? item : String(item),
]),
);
}
function publicSnapshot(request: TransportSnapshot): PlanningSnapshot {
const horizon = request.horizon ?? {};
const balances = Array.isArray(request.balances)
? request.balances.map((value) => snapshotValue(value) as LeaveBalanceInput)
: [];
const rules = Array.isArray(request.rules)
? request.rules.map((value) => snapshotValue(value) as PlanningRequest['rules'][number])
: [];
const labels = stringMap(request.labels);
return deepFreeze({
horizon: {
start: String(snapshotValue(horizon.start) ?? ''),
end: String(snapshotValue(horizon.end) ?? ''),
},
balances,
rules,
identities: stringMap(request.identities),
labels,
}) as PlanningSnapshot;
}
function snapshotRequest(request: unknown): TransportSnapshot {
const source = asRecord(request) ?? {};
const horizon = asRecord(source.horizon);
const balances = Array.isArray(source.balances)
@@ -86,6 +191,8 @@ function snapshotRequest(request: unknown): PlanningSnapshot {
id: value?.id,
type: value?.type,
quantity: value?.quantity,
name: value?.name,
label: value?.label,
availableFrom:
value?.availableFrom === undefined
? undefined
@@ -133,17 +240,19 @@ function snapshotRequest(request: unknown): PlanningSnapshot {
: undefined,
balances,
rules,
identities: cloneUnknown(source.identities),
labels: cloneUnknown(source.labels),
deadlineMs: source.deadlineMs,
progress: typeof source.progress === 'function' ? source.progress : undefined,
};
}
function deadlineFor(request: PlanningSnapshot): number {
function deadlineFor(request: TransportSnapshot): number {
const configured = typeof request.deadlineMs === 'number' ? request.deadlineMs : undefined;
return Math.min(Math.max(0, configured ?? MAX_PLANNING_MS), MAX_PLANNING_MS);
}
function invalidDeadline(request: PlanningSnapshot): InvalidInputOutcome | undefined {
function invalidDeadline(request: TransportSnapshot): InvalidInputOutcome | undefined {
const deadline = request.deadlineMs;
if (
deadline !== undefined &&
@@ -163,10 +272,56 @@ function invalidDeadline(request: PlanningSnapshot): InvalidInputOutcome | undef
type PlannerWorkerResponse =
| { type: 'progress'; stage: string }
| { type: 'trace-overhead'; totalMs: number }
| { type: 'checkpoint'; plan: PlanOutcome }
| { type: 'result'; outcome: PlanningOutcome }
| { type: 'artifacts'; artifacts: PlanningArtifacts }
| { type: 'error'; message: string };
interface PlanningRun {
outcome: Promise<PlanningOutcome>;
artifacts: Promise<PlanningArtifacts>;
}
function traceStateFor(snapshot: TransportSnapshot): PlanningTraceState {
const horizon = snapshot.horizon ?? {};
const balances = Array.isArray(snapshot.balances) ? snapshot.balances : [];
return {
horizon: {
start: String(horizon.start ?? ''),
end: String(horizon.end ?? ''),
},
balances: balances.map((item) => {
const balance = asRecord(item);
return {
id: String(balance?.id ?? ''),
type: String(balance?.type ?? 'other') as LeaveType,
quantity: typeof balance?.quantity === 'number' ? balance.quantity : 0,
remaining: typeof balance?.quantity === 'number' ? balance.quantity : 0,
availableFrom: String(balance?.availableFrom ?? horizon.start ?? ''),
expiryDate: String(balance?.expiryDate ?? ''),
earned: false,
};
}),
placements: [],
earnedActivations: [],
};
}
function fallbackTrace(
snapshot: TransportSnapshot,
status: PlanningTrace['status'],
error?: PlanningReason,
): PlanningTrace {
return {
status,
initialState: traceStateFor(snapshot),
phases: [],
completedStages: [],
error,
};
}
function interruptedOutcome(
checkpoint: PlanOutcome | undefined,
deadline: number,
@@ -182,40 +337,71 @@ function interruptedOutcome(
};
}
function planInWorker(snapshot: PlanningSnapshot, deadline: number): Promise<PlanningOutcome> {
return new Promise((resolve) => {
let worker: Worker;
try {
worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
type: 'module',
});
} catch {
resolve(
interruptedOutcome(
undefined,
deadline,
'worker-unavailable',
'Planning could not start because isolated execution is unavailable.',
),
);
return;
}
function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRun {
let resolveOutcome!: (outcome: PlanningOutcome) => void;
let resolveArtifacts!: (artifacts: PlanningArtifacts) => void;
const outcome = new Promise<PlanningOutcome>((resolve) => {
resolveOutcome = resolve;
});
const artifacts = new Promise<PlanningArtifacts>((resolve) => {
resolveArtifacts = resolve;
});
try {
const worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
type: 'module',
});
let settled = false;
let outcomeSettled = false;
let artifactsSettled = false;
let checkpoint: PlanOutcome | undefined;
const finish = (outcome: PlanningOutcome) => {
if (settled) return;
settled = true;
let traceAllowance = 0;
let timeout: ReturnType<typeof setTimeout>;
const timeoutStarted = performance.now();
const terminate = () => {
clearTimeout(timeout);
worker.onmessage = null;
worker.onerror = null;
worker.terminate();
resolve(outcome);
};
const timeout = setTimeout(
() => finish(interruptedOutcome(checkpoint, deadline)),
Math.max(0, deadline - 1),
);
const finishOutcome = (result: PlanningOutcome) => {
if (!outcomeSettled) {
outcomeSettled = true;
clearTimeout(timeout);
resolveOutcome(result);
}
};
const finishArtifacts = (result: PlanningArtifacts) => {
if (!artifactsSettled) {
artifactsSettled = true;
resolveArtifacts(result);
terminate();
}
};
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)),
);
};
scheduleTimeout();
worker.onmessage = (event: MessageEvent<PlannerWorkerResponse>) => {
const message = event.data;
@@ -224,65 +410,489 @@ function planInWorker(snapshot: PlanningSnapshot, deadline: number): Promise<Pla
if (typeof snapshot.progress === 'function') snapshot.progress(message.stage);
return;
}
if (message.type === 'trace-overhead') {
traceAllowance = Math.max(traceAllowance, message.totalMs);
scheduleTimeout();
return;
}
if (message.type === 'checkpoint') {
checkpoint = message.plan;
return;
}
if (message.type === 'result') {
finish(message.outcome);
finishOutcome(message.outcome);
if (message.outcome.kind === 'InvalidInput') finishArtifacts({});
return;
}
finish(interruptedOutcome(checkpoint, deadline, 'worker-error', message.message));
if (message.type === 'artifacts') {
finishArtifacts(message.artifacts);
return;
}
fail('worker-error', message.message);
};
worker.onerror = () =>
finish(
interruptedOutcome(
checkpoint,
deadline,
'worker-error',
'Planning worker failed before returning an outcome.',
),
);
fail('worker-error', 'Planning worker failed before returning explanation data.');
try {
worker.postMessage({
type: 'plan',
request: { ...snapshot, deadlineMs: deadline, progress: undefined },
});
} catch {
finish(
interruptedOutcome(
checkpoint,
deadline,
'worker-error',
'Planning input could not be sent to the isolated worker.',
),
);
fail('worker-error', 'Planning input could not be sent to the isolated worker.');
}
});
}
export async function planLeave(request: PlanningRequest): Promise<PlanningOutcome> {
const snapshot = snapshotRequest(request);
const deadlineError = invalidDeadline(snapshot);
if (deadlineError) {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
return deadlineError;
}
const deadline = deadlineFor(snapshot);
if (typeof window !== 'undefined') {
if (typeof Worker !== 'undefined') return planInWorker(snapshot, deadline);
await new Promise<void>((resolve) => setTimeout(resolve, 0));
const core = await import('./leave-planner.core');
const validation = core.invalidInputFor(snapshot);
if (validation) return validation;
return interruptedOutcome(
} catch {
const result = interruptedOutcome(
undefined,
deadline,
'worker-unavailable',
'Planning could not start because isolated execution is unavailable.',
);
resolveOutcome(result);
resolveArtifacts({
trace: fallbackTrace(snapshot, 'failed', result.reasons[0]),
explanationError: result.reasons[0],
});
}
await new Promise<void>((resolve) => setTimeout(resolve, 0));
return { outcome, artifacts };
}
let calculationSequence = 0;
let latestCalculationId = '';
function selectionFor(
plan: PlanOutcome,
selection: CounterfactualSelection,
): LeavePlacement | undefined {
if (typeof selection === 'object' && 'decisionId' in selection) return selection.placement;
if (typeof selection !== 'string')
return plan.placements.find(
(candidate) =>
candidate.date === selection.date && candidate.balanceId === selection.balanceId,
);
return plan.placements.find(
(candidate) => candidate.date === selection || candidate.balanceId === selection,
);
}
function traceSelection(
selection: CounterfactualSelection,
): selection is TraceCounterfactualSelection {
return typeof selection === 'object' && 'decisionId' in selection;
}
function stateBeforeDecision(
trace: PlanningTrace,
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;
}
if (
delta.kind === 'earned-activated' &&
delta.activation &&
!earnedActivations.some((activation) => activation.id === delta.activation!.id)
)
earnedActivations.push({
...delta.activation,
dates: [...delta.activation.dates],
});
}
}
return undefined;
}
function decisionBaseline(
trace: PlanningTrace,
selection: TraceCounterfactualSelection,
finalState?: PlanningTraceState,
): { state: PlanningTraceState; options: CounterfactualBaselineOptions } | undefined {
const decision = trace.phases
.flatMap((phase) => phase.decisions)
.find((candidate) => candidate.id === selection.decisionId);
const state = stateBeforeDecision(trace, selection.decisionId, finalState);
if (!decision || !state) return undefined;
const replacement = decision.deltas.find(
(delta) =>
(delta.kind === 'placement-moved' || delta.kind === 'placement-updated') &&
delta.placement?.date === selection.placement.date &&
delta.placement.balanceId === selection.placement.balanceId,
);
return {
state,
options: {
removeDate:
replacement?.kind === 'placement-moved'
? (replacement.fromDate ?? null)
: (replacement?.date ?? null),
compareOriginalState: true,
},
};
}
function withHistoricalStatuses(
analysis: CounterfactualAnalysis,
trace?: PlanningTrace,
decisionId?: string,
): CounterfactualAnalysis {
if (!trace) return analysis;
const priority = new Map([
['ineligible', 0],
['not-examined', 1],
['pruned', 2],
['evaluated-rejected', 3],
['chosen', 4],
]);
const statuses = new Map<string, CounterfactualDateResult['historicalStatus']>();
for (const candidate of trace.phases.flatMap((phase) =>
phase.decisions
.filter((decision) => !decisionId || decision.id === decisionId)
.flatMap((decision) => decision.candidates),
)) {
for (const date of candidate.dates ?? (candidate.date ? [candidate.date] : [])) {
const previous = statuses.get(date);
if (!previous || priority.get(candidate.status)! > priority.get(previous)!)
statuses.set(date, candidate.status);
}
}
return {
...analysis,
results: analysis.results.map((result) => ({
...result,
historicalStatus:
statuses.get(result.date) ?? (decisionId ? 'not-examined' : undefined),
})),
};
}
async function runCounterfactual(
context: PlanningCalculationContext,
selection: CounterfactualSelection,
artifacts: Promise<PlanningArtifacts>,
): Promise<CounterfactualAnalysis | StaleCounterfactualAnalysis> {
if (context.id !== latestCalculationId) {
const plan = await context.outcome;
const finalPlan =
plan.kind === 'Plan'
? plan
: plan.kind === 'PlanningIncomplete'
? plan.incumbent
: undefined;
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: [],
};
}
const outcome = await context.outcome;
if (context.id !== latestCalculationId) {
const plan =
outcome.kind === 'Plan'
? outcome
: outcome.kind === 'PlanningIncomplete'
? outcome.incumbent
: undefined;
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: [],
};
}
const plan =
outcome.kind === 'Plan'
? outcome
: outcome.kind === 'PlanningIncomplete'
? outcome.incumbent
: undefined;
if (!plan) throw new Error('Counterfactual analysis requires a plan outcome.');
const placement = selectionFor(plan, selection);
if (!placement) throw new Error('The selected placement is not present in the calculation.');
const completedArtifacts = await artifacts;
const selectedDecision = traceSelection(selection)
? completedArtifacts.trace &&
decisionBaseline(completedArtifacts.trace, selection, completedArtifacts.finalState)
: undefined;
if (traceSelection(selection) && !selectedDecision)
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 (typeof window !== 'undefined' && typeof Worker !== 'undefined') {
const result = await counterfactualInWorker(
context.snapshot,
plan,
placement,
analysisState,
options,
);
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');
return core.calculatePlanningInternal(snapshot);
const result = core.analyzeCounterfactualInternal(
context.snapshot,
plan,
placement,
analysisState,
options,
);
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: [],
};
}
function counterfactualInWorker(
snapshot: PlanningSnapshot,
plan: PlanOutcome,
placement: LeavePlacement,
state?: PlanningTraceState,
options?: CounterfactualBaselineOptions,
): Promise<CounterfactualAnalysis> {
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;
}
const finish = (callback: () => void) => {
worker.onmessage = null;
worker.onerror = null;
worker.terminate();
callback();
};
worker.onmessage = (
event: MessageEvent<{
type: string;
analysis?: CounterfactualAnalysis;
message?: string;
}>,
) => {
if (event.data?.type === 'counterfactual-result' && event.data.analysis)
finish(() => resolve(event.data.analysis!));
else if (event.data?.type === 'error')
finish(() =>
reject(new Error(event.data.message ?? 'Counterfactual analysis failed.')),
);
};
worker.onerror = () => finish(() => reject(new Error('Counterfactual worker failed.')));
try {
worker.postMessage({
type: 'counterfactual',
request: snapshot,
plan,
placement,
state,
options,
});
} catch (error) {
finish(() => reject(error));
}
});
}
export interface PlanningCalculationContext {
readonly id: string;
readonly snapshot: PlanningSnapshot;
readonly outcome: Promise<PlanningOutcome>;
readonly explanation: Promise<PlanningExplanation | undefined>;
readonly explanationError: Promise<PlanningReason | undefined>;
readonly trace: Promise<PlanningTrace | undefined>;
readonly analyzeCounterfactual: (
selection: CounterfactualSelection,
) => Promise<CounterfactualAnalysis | StaleCounterfactualAnalysis>;
}
export function createPlanningCalculation(request: PlanningRequest): PlanningCalculationContext {
const transport = snapshotRequest(request);
const snapshot = publicSnapshot(transport);
const id = `calculation:${++calculationSequence}`;
latestCalculationId = id;
const deadlineError = invalidDeadline(transport);
let run: PlanningRun;
if (deadlineError) {
run = {
outcome: new Promise((resolve) => queueMicrotask(() => resolve(deadlineError))),
artifacts: Promise.resolve({}),
};
} else {
const deadline = deadlineFor(transport);
if (typeof window !== 'undefined') {
if (typeof Worker !== 'undefined') {
run = planInWorker(transport, deadline);
} else {
let resolveArtifacts!: (artifacts: PlanningArtifacts) => void;
const artifacts = new Promise<PlanningArtifacts>(
(resolve) => (resolveArtifacts = resolve),
);
const outcome = new Promise<PlanningOutcome>((resolve) => {
queueMicrotask(async () => {
const core = await import('./leave-planner.core');
const validation = core.invalidInputFor(transport);
if (validation) {
resolve(validation);
resolveArtifacts({});
return;
}
const result = interruptedOutcome(
undefined,
deadline,
'worker-unavailable',
'Planning could not start because isolated execution is unavailable.',
);
resolve(result);
resolveArtifacts({
explanation: {
kind: 'IncompleteExplanation',
reason: result.reasons[0],
completedStages: [],
uncompletedStages: [],
},
trace: fallbackTrace(transport, 'failed', result.reasons[0]),
});
});
});
run = { outcome, artifacts };
}
} else {
let resolveArtifacts!: (artifacts: PlanningArtifacts) => void;
const artifacts = new Promise<PlanningArtifacts>(
(resolve) => (resolveArtifacts = resolve),
);
const outcome = new Promise<PlanningOutcome>((resolve) => {
queueMicrotask(async () => {
try {
const core = await import('./leave-planner.core');
let completedArtifacts: PlanningArtifacts = {};
const result = core.calculatePlanningInternal(
transport,
undefined,
(value) => (completedArtifacts = value),
);
resolve(result);
queueMicrotask(() => resolveArtifacts(completedArtifacts));
} catch (error) {
const reason: PlanningReason = {
code: 'planner-error',
message: error instanceof Error ? error.message : String(error),
};
resolve({
kind: 'PlanningIncomplete',
completedStages: [],
reasons: [reason],
});
resolveArtifacts({
trace: fallbackTrace(transport, 'failed', reason),
explanationError: reason,
});
}
});
});
run = { outcome, artifacts };
}
}
const retainedArtifacts = run.artifacts.then((artifacts) => ({
...artifacts,
trace:
id === latestCalculationId
? artifacts.trace
: fallbackTrace(transport, 'failed', {
code: 'stale-calculation',
message: 'This trace belongs to an older calculation and was discarded.',
}),
}));
const context: PlanningCalculationContext = {
id,
snapshot,
outcome: run.outcome,
explanation: retainedArtifacts.then((artifacts) => artifacts.explanation),
explanationError: retainedArtifacts.then((artifacts) => artifacts.explanationError),
trace: retainedArtifacts.then((artifacts) => artifacts.trace),
analyzeCounterfactual: (selection) =>
runCounterfactual(context, selection, retainedArtifacts),
};
return context;
}
export async function planLeave(request: PlanningRequest): Promise<PlanningOutcome> {
return createPlanningCalculation(request).outcome;
}
+66 -15
View File
@@ -1,41 +1,92 @@
/// <reference lib="webworker" />
import type { PlanningOutcome, PlanOutcome } from './leave-planner.service';
import { calculatePlanningInternal } from './leave-planner.core';
import type {
CounterfactualAnalysis,
LeavePlacement,
PlanOutcome,
PlanningOutcome,
PlanningSnapshot,
PlanningTraceState,
} from './leave-planner.service';
import {
analyzeCounterfactualInternal,
calculatePlanningInternal,
type CounterfactualBaselineOptions,
type PlanningArtifacts,
} from './leave-planner.core';
type WorkerPlanningRequest = {
horizon?: { start?: unknown; end?: unknown };
balances?: unknown;
rules?: unknown;
identities?: unknown;
labels?: unknown;
deadlineMs?: unknown;
progress?: unknown;
};
type WorkerRequest = {
type: 'plan';
request: WorkerPlanningRequest;
};
type WorkerRequest =
| {
type: 'plan';
request: WorkerPlanningRequest;
}
| {
type: 'counterfactual';
request: PlanningSnapshot;
plan: PlanOutcome;
placement: LeavePlacement;
state?: PlanningTraceState;
options?: CounterfactualBaselineOptions;
};
type WorkerResponse =
| { type: 'progress'; stage: string }
| { type: 'trace-overhead'; totalMs: number }
| { type: 'checkpoint'; plan: PlanOutcome }
| { type: 'result'; outcome: PlanningOutcome }
| { type: 'artifacts'; artifacts: PlanningArtifacts }
| { type: 'counterfactual-result'; analysis: CounterfactualAnalysis }
| { type: 'error'; message: string };
const worker = self as DedicatedWorkerGlobalScope;
worker.onmessage = (event: MessageEvent<WorkerRequest>) => {
if (event.data?.type !== 'plan') return;
try {
const outcome = calculatePlanningInternal(
{
...event.data.request,
progress: (stage: string) =>
worker.postMessage({ type: 'progress', stage } satisfies WorkerResponse),
},
(plan) => worker.postMessage({ type: 'checkpoint', plan } satisfies WorkerResponse),
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;
}
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: 'result', outcome } satisfies WorkerResponse);
worker.postMessage({ type: 'counterfactual-result', analysis } satisfies WorkerResponse);
} catch (error) {
worker.postMessage({
type: 'error',
+97
View File
@@ -109,6 +109,103 @@ export interface AppTranslations {
incomplete: string;
error: string;
complete: string;
whyPlacement: string;
placementNavigation: string;
placement: string;
placements: string;
decision: string;
of: string;
first: string;
previous: string;
next: string;
last: string;
balance: string;
rules: string;
noRules: string;
supporting: string;
yes: string;
no: string;
remaining: string;
timeOffPeriod: string;
firstDecisive: string;
contributions: string;
objectiveVector: string;
finalMetrics: string;
unplacedAtRisk: string;
holidayWeekValue: string;
shortSequenceDeficit: string;
supportingPlacements: string;
preferenceSatisfaction: string;
removedRestoredComparison: string;
fullComparison: string;
comparisonHelp: string;
counterfactual: string;
hypothetical: string;
counterfactualLoading: string;
counterfactualReady: string;
counterfactualError: string;
eligible: string;
ineligible: string;
historical: string;
reason: string;
explanationError: string;
conflictDetails: string;
conflictingRules: string;
bottleneck: string;
incompleteDetails: string;
incumbentExplanation: string;
completedStages: string;
uncompletedStages: string;
none: string;
currentPlacement: string;
outsideHorizon: string;
traceTitle: string;
traceLoading: string;
traceUnavailable: string;
phases: string;
firstPhase: string;
previousPhase: string;
nextPhase: string;
lastPhase: string;
decisions: string;
firstDecision: string;
previousDecision: string;
nextDecision: string;
lastDecision: string;
replay: string;
before: string;
after: string;
finalPlan: string;
dates: string;
candidates: string;
previousCandidates: string;
nextCandidates: string;
page: string;
compareAlternatives: string;
tieBreak: string;
deltas: string;
activations: string;
noDecisions: string;
traceStatuses: {
chosen: string;
'evaluated-rejected': string;
pruned: string;
'not-examined': string;
ineligible: string;
};
tracePhaseStatuses: {
completed: string;
interrupted: string;
'not-run': string;
};
traceChanges: {
added: string;
'moved-from': string;
'moved-to': string;
removed: string;
shared: string;
unchanged: string;
};
};
benchmark: {
title: string;