feat(planning): explain and trace planning decisions
This commit is contained in:
1 parent
609b3dece9
commit
d7a13b0b1e
24 files changed
+6073
-342
No files matched your search
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user