- Introduced calculationDate in various service interfaces and snapshots to track the date of planning calculations. - Added futureEntitlement and includedEntitlement fields to the translation service and respective language files for better user feedback. - Updated leave-planner.worker to handle calculationDate and declaredDays in requests. - Enhanced tests to ensure proper handling of calculation dates and artifact retention during planning processes. - Improved snapshot and explanation outputs to include calculation date for clarity in planning results.
1818 lines
56 KiB
TypeScript
1818 lines
56 KiB
TypeScript
import type {
|
|
CounterfactualAnalysis,
|
|
CounterfactualBaselineOptions,
|
|
CounterfactualSelection,
|
|
InvalidInputOutcome,
|
|
LeaveBalanceInput,
|
|
LeavePlacement,
|
|
LeaveType,
|
|
DeclaredNonWorkingDay,
|
|
PlanOutcome,
|
|
PlanningArtifacts,
|
|
PlanningExplanation,
|
|
PlanningIncompleteOutcome,
|
|
PlanningOutcome,
|
|
PlanningRequest,
|
|
PlanningSnapshot,
|
|
PlanningArtifactTimings,
|
|
PlanningTrace,
|
|
PlanningTraceCandidate,
|
|
PlanningTraceDecision,
|
|
PlanningTraceDelta,
|
|
PlanningTracePhase,
|
|
PlanningTraceUpdate,
|
|
PlanningReason,
|
|
PlanningTraceState,
|
|
HistoricalCounterfactualReason,
|
|
StaleCounterfactualAnalysis,
|
|
TraceCandidateStatus,
|
|
TraceCounterfactualSelection,
|
|
} from './leave-planner.core';
|
|
import { planningDateInParis } from './planning-date';
|
|
|
|
export type {
|
|
IfThenPlanningRule,
|
|
IfThenContribution,
|
|
ConflictExplanation,
|
|
CounterfactualAnalysis,
|
|
CounterfactualSelection,
|
|
CounterfactualDateResult,
|
|
CounterfactualReasonCode,
|
|
InvalidInputOutcome,
|
|
IncompleteExplanation,
|
|
LeaveBalanceInput,
|
|
LeavePlacement,
|
|
LeaveSelector,
|
|
LeaveType,
|
|
DeclaredNonWorkingDay,
|
|
PlanOutcome,
|
|
PlanExplanation,
|
|
PlacementMode,
|
|
PlanningConflictOutcome,
|
|
PlanningDate,
|
|
PlanningHorizon,
|
|
PlanningIncompleteOutcome,
|
|
PlanningExplanation,
|
|
PlanningMetrics,
|
|
PlanningObjective,
|
|
PlanningObjectiveComparison,
|
|
PlanningObjectiveDelta,
|
|
PlanningObjectiveId,
|
|
PlanningObjectiveVector,
|
|
PlanningOutcome,
|
|
PlanningReason,
|
|
PlanningRequest,
|
|
PlanningRule,
|
|
PlanningSnapshot,
|
|
PlanningArtifactTimings,
|
|
PlanningTrace,
|
|
PlanningTraceCandidate,
|
|
PlanningTraceDecision,
|
|
PlanningTraceDelta,
|
|
PlanningTracePhase,
|
|
PlanningTraceState,
|
|
PlanningTraceOperation,
|
|
PlanningTraceUpdate,
|
|
PreferenceResult,
|
|
RemainingBalance,
|
|
RemainingClassification,
|
|
RuleForce,
|
|
TimeOffPeriod,
|
|
TimeWindowContribution,
|
|
TimeWindowPlanningRule,
|
|
TraceCounterfactualSelection,
|
|
TraceCandidateStatus,
|
|
} from './leave-planner.core';
|
|
|
|
// Responsive workers renew this inactivity timer regardless of calculation duration.
|
|
const WORKER_LIVENESS_TIMEOUT_MS = 30_000;
|
|
const MAX_TRACE_UPDATES_PER_DRAIN = 16;
|
|
const TRACE_DRAIN_TIME_BUDGET_MS = 4;
|
|
|
|
interface UntrustedRecord {
|
|
horizon?: unknown;
|
|
start?: unknown;
|
|
end?: unknown;
|
|
balances?: unknown;
|
|
declaredDays?: 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;
|
|
force?: unknown;
|
|
startDate?: unknown;
|
|
endDate?: unknown;
|
|
placeDays?: unknown;
|
|
placeType?: unknown;
|
|
dayOfWeek?: unknown;
|
|
placement?: unknown;
|
|
rewardDays?: unknown;
|
|
rewardType?: unknown;
|
|
}
|
|
|
|
interface TransportSnapshot {
|
|
calculationDate?: string;
|
|
horizon?: { start?: unknown; end?: unknown };
|
|
balances?: unknown;
|
|
declaredDays?: unknown;
|
|
rules?: unknown;
|
|
identities?: unknown;
|
|
labels?: unknown;
|
|
deadlineMs?: unknown;
|
|
progress?: unknown;
|
|
}
|
|
|
|
function asRecord(value: unknown): UntrustedRecord | undefined {
|
|
return typeof value === 'object' && value !== null ? (value as UntrustedRecord) : undefined;
|
|
}
|
|
|
|
function copyDate(value: unknown): unknown {
|
|
return value instanceof Date ? new Date(value.getTime()) : value;
|
|
}
|
|
|
|
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 Number.isNaN(value.getTime()) ? 'Invalid Date' : planningDateInParis(value);
|
|
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({
|
|
calculationDate: request.calculationDate,
|
|
horizon: {
|
|
start: String(snapshotValue(horizon.start) ?? ''),
|
|
end: String(snapshotValue(horizon.end) ?? ''),
|
|
},
|
|
balances,
|
|
...(request.declaredDays === undefined
|
|
? {}
|
|
: { declaredDays: snapshotValue(request.declaredDays) }),
|
|
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)
|
|
? source.balances.map((balance) => {
|
|
const value = asRecord(balance);
|
|
return {
|
|
id: value?.id,
|
|
type: value?.type,
|
|
quantity: value?.quantity,
|
|
name: value?.name,
|
|
label: value?.label,
|
|
availableFrom:
|
|
value?.availableFrom === undefined
|
|
? undefined
|
|
: copyDate(value.availableFrom),
|
|
expiryDate: copyDate(value?.expiryDate),
|
|
};
|
|
})
|
|
: source.balances;
|
|
const rules = Array.isArray(source.rules)
|
|
? source.rules.map((rule) => {
|
|
const value = asRecord(rule);
|
|
const base = {
|
|
id: value?.id,
|
|
name: value?.name,
|
|
type: value?.type,
|
|
enabled: value?.enabled,
|
|
force: value?.force,
|
|
};
|
|
if (typeof value?.type === 'string' && value.type.toLowerCase() === 'time_window') {
|
|
return {
|
|
...base,
|
|
startDate: copyDate(value.startDate),
|
|
endDate: copyDate(value.endDate),
|
|
placeDays: value.placeDays,
|
|
placeType: value.placeType,
|
|
};
|
|
}
|
|
return {
|
|
...base,
|
|
dayOfWeek: value?.dayOfWeek,
|
|
placeDays: value?.placeDays,
|
|
placeType: value?.placeType,
|
|
placement: value?.placement,
|
|
rewardDays: value?.rewardDays,
|
|
rewardType: value?.rewardType,
|
|
};
|
|
})
|
|
: source.rules;
|
|
return {
|
|
horizon: horizon
|
|
? {
|
|
start: copyDate(horizon.start),
|
|
end: copyDate(horizon.end),
|
|
}
|
|
: undefined,
|
|
balances,
|
|
declaredDays: cloneUnknown(source.declaredDays),
|
|
rules,
|
|
identities: cloneUnknown(source.identities),
|
|
labels: cloneUnknown(source.labels),
|
|
deadlineMs: source.deadlineMs,
|
|
progress: typeof source.progress === 'function' ? source.progress : undefined,
|
|
};
|
|
}
|
|
|
|
function invalidDeadline(request: TransportSnapshot): InvalidInputOutcome | undefined {
|
|
const deadline = request.deadlineMs;
|
|
if (
|
|
deadline !== undefined &&
|
|
(typeof deadline !== 'number' || !Number.isFinite(deadline) || deadline < 0)
|
|
)
|
|
return {
|
|
kind: 'InvalidInput',
|
|
reasons: [
|
|
{
|
|
code: 'deadline',
|
|
message: 'The planning deadline must be a finite non-negative number.',
|
|
},
|
|
],
|
|
};
|
|
return undefined;
|
|
}
|
|
|
|
type PlannerWorkerResponse =
|
|
| { type: 'heartbeat' }
|
|
| { type: 'progress'; stage: string }
|
|
| { type: 'trace-overhead'; totalMs: number }
|
|
| { type: 'checkpoint'; plan: PlanOutcome; state?: PlanningTraceState }
|
|
| { type: 'result'; outcome: PlanningOutcome }
|
|
| { type: 'artifacts'; artifacts: PlanningArtifacts }
|
|
| {
|
|
type: 'explanation';
|
|
explanation?: PlanningExplanation;
|
|
explanationError?: PlanningReason;
|
|
finalState?: PlanningTraceState;
|
|
}
|
|
| {
|
|
type: 'artifacts-start';
|
|
artifacts: Omit<
|
|
PlanningArtifacts,
|
|
'trace' | 'explanation' | 'explanationError' | 'finalState'
|
|
>;
|
|
trace?: Omit<PlanningTrace, 'phases'>;
|
|
}
|
|
| {
|
|
type: 'trace-update';
|
|
totalMs: number;
|
|
update: PlanningTraceUpdate;
|
|
acknowledge?: boolean;
|
|
}
|
|
| {
|
|
type: 'trace-status-index';
|
|
statuses: ReadonlyMap<string, TraceCandidateStatus>;
|
|
nonWorkingDates: readonly string[];
|
|
}
|
|
| { type: 'artifacts-complete'; traceTransferMs?: number }
|
|
| {
|
|
type: 'historical-eligibility-result';
|
|
entries: readonly HistoricalEligibilityReason[];
|
|
}
|
|
| { type: 'error'; code?: string; message: string };
|
|
|
|
export type HistoricalEligibilityReason = HistoricalCounterfactualReason;
|
|
|
|
interface PlanningRun {
|
|
outcome: Promise<PlanningOutcome>;
|
|
explanation: Promise<PlanningExplanation | undefined>;
|
|
explanationError: Promise<PlanningReason | undefined>;
|
|
artifacts: Promise<PlanningArtifacts>;
|
|
timings: Promise<PlanningCalculationTimings>;
|
|
cancel?: () => void;
|
|
}
|
|
|
|
export type PlanningCalculationTimings = Partial<PlanningArtifactTimings> & {
|
|
traceTransferMs?: number;
|
|
};
|
|
|
|
type MutableTraceDecision = Omit<PlanningTraceDecision, 'candidates' | 'deltas'> & {
|
|
candidates: PlanningTraceCandidate[];
|
|
deltas: PlanningTraceDelta[];
|
|
};
|
|
type MutableTracePhase = Omit<PlanningTracePhase, 'decisions'> & {
|
|
decisions: MutableTraceDecision[];
|
|
};
|
|
type MutableTrace = Omit<PlanningTrace, 'phases'> & { phases: MutableTracePhase[] };
|
|
|
|
function traceStateFor(snapshot: TransportSnapshot): PlanningTraceState {
|
|
const horizon = snapshot.horizon ?? {};
|
|
const balances = Array.isArray(snapshot.balances) ? snapshot.balances : [];
|
|
return {
|
|
calculationDate: snapshot.calculationDate,
|
|
horizon: {
|
|
start: String(horizon.start ?? ''),
|
|
end: String(horizon.end ?? ''),
|
|
},
|
|
balances: balances.map((item) => {
|
|
const balance = asRecord(item);
|
|
return {
|
|
id: String(balance?.id ?? ''),
|
|
type: String(balance?.type ?? 'other') as LeaveType,
|
|
quantity: typeof balance?.quantity === 'number' ? balance.quantity : 0,
|
|
remaining: typeof balance?.quantity === 'number' ? balance.quantity : 0,
|
|
availableFrom: String(balance?.availableFrom ?? horizon.start ?? ''),
|
|
expiryDate: String(balance?.expiryDate ?? ''),
|
|
earned: false,
|
|
};
|
|
}),
|
|
...(Array.isArray(snapshot.declaredDays)
|
|
? { declaredDays: snapshot.declaredDays as DeclaredNonWorkingDay[] }
|
|
: {}),
|
|
placements: [],
|
|
earnedActivations: [],
|
|
};
|
|
}
|
|
|
|
function fallbackTrace(
|
|
snapshot: TransportSnapshot,
|
|
status: PlanningTrace['status'],
|
|
error?: PlanningReason,
|
|
): PlanningTrace {
|
|
return {
|
|
status,
|
|
initialState: traceStateFor(snapshot),
|
|
phases: [],
|
|
completedStages: [],
|
|
error,
|
|
};
|
|
}
|
|
|
|
function interruptedOutcome(
|
|
checkpoint: PlanOutcome | undefined,
|
|
reasonCode: string,
|
|
message: string,
|
|
): PlanningIncompleteOutcome {
|
|
return {
|
|
kind: 'PlanningIncomplete',
|
|
incumbent: checkpoint,
|
|
completedStages: checkpoint?.completedStages ?? [],
|
|
lastCompletedStage: checkpoint?.lastCompletedStage,
|
|
reasons: [{ code: reasonCode, message }],
|
|
};
|
|
}
|
|
|
|
const latestWorkerCancels = new Set<() => void>();
|
|
let latestCounterfactualCancel: (() => void) | undefined;
|
|
let latestHistoricalEligibilityCancel: (() => void) | undefined;
|
|
|
|
function cancelLatestWorkers(): void {
|
|
for (const cancel of [...latestWorkerCancels]) cancel();
|
|
latestWorkerCancels.clear();
|
|
latestCounterfactualCancel = undefined;
|
|
latestHistoricalEligibilityCancel = undefined;
|
|
}
|
|
|
|
function planInWorker(snapshot: TransportSnapshot): PlanningRun {
|
|
let resolveOutcome!: (outcome: PlanningOutcome) => void;
|
|
let resolveExplanation!: (explanation: PlanningExplanation | undefined) => void;
|
|
let resolveExplanationError!: (error: PlanningReason | undefined) => void;
|
|
let resolveArtifacts!: (artifacts: PlanningArtifacts) => void;
|
|
let resolveTimings!: (timings: PlanningCalculationTimings) => void;
|
|
const outcome = new Promise<PlanningOutcome>((resolve) => {
|
|
resolveOutcome = resolve;
|
|
});
|
|
const explanation = new Promise<PlanningExplanation | undefined>((resolve) => {
|
|
resolveExplanation = resolve;
|
|
});
|
|
const explanationError = new Promise<PlanningReason | undefined>((resolve) => {
|
|
resolveExplanationError = resolve;
|
|
});
|
|
const artifacts = new Promise<PlanningArtifacts>((resolve) => {
|
|
resolveArtifacts = resolve;
|
|
});
|
|
const timings = new Promise<PlanningCalculationTimings>((resolve) => {
|
|
resolveTimings = resolve;
|
|
});
|
|
let supersede: (() => void) | undefined;
|
|
let cancel: (() => void) | undefined;
|
|
try {
|
|
const worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
|
|
type: 'module',
|
|
});
|
|
|
|
let outcomeSettled = false;
|
|
let cancellationRequested = false;
|
|
let cancelledRace: PlanningIncompleteOutcome | undefined;
|
|
let explanationSettled = false;
|
|
let artifactsSettled = false;
|
|
let checkpoint: PlanOutcome | undefined;
|
|
let checkpointState: PlanningTraceState | undefined;
|
|
let receivedArtifacts: Omit<
|
|
PlanningArtifacts,
|
|
'trace' | 'explanation' | 'explanationError' | 'finalState'
|
|
> = {};
|
|
let receivedExplanation: PlanningExplanation | undefined;
|
|
let receivedExplanationError: PlanningReason | undefined;
|
|
let receivedFinalState: PlanningTraceState | undefined;
|
|
let receivedTrace: MutableTrace | undefined;
|
|
const phasesById = new Map<string, MutableTracePhase>();
|
|
const decisionsById = new Map<string, MutableTraceDecision>();
|
|
let traceUpdates: (QueuedTraceUpdate | undefined)[] = [];
|
|
let traceUpdateIndex = 0;
|
|
let traceDrainTimer: ReturnType<typeof setTimeout> | undefined;
|
|
let traceTransferMs = 0;
|
|
let terminalTraceTransferMs: number | undefined;
|
|
let artifactsCompleteReceived = false;
|
|
let pendingFailure: { code: string; message: string } | undefined;
|
|
let receivedStatuses: ReadonlyMap<string, TraceCandidateStatus> | undefined;
|
|
let receivedNonWorkingDates: readonly string[] = [];
|
|
let livenessWatchdog: ReturnType<typeof setTimeout>;
|
|
let workerStopped = false;
|
|
const stopWorker = () => {
|
|
if (workerStopped) return;
|
|
workerStopped = true;
|
|
worker.onmessage = null;
|
|
worker.onerror = null;
|
|
worker.terminate();
|
|
};
|
|
const terminate = () => {
|
|
stopWorker();
|
|
clearTimeout(livenessWatchdog);
|
|
if (traceDrainTimer !== undefined) clearTimeout(traceDrainTimer);
|
|
traceDrainTimer = undefined;
|
|
phasesById.clear();
|
|
decisionsById.clear();
|
|
};
|
|
const finishOutcome = (result: PlanningOutcome) => {
|
|
if (!outcomeSettled) {
|
|
if (
|
|
cancellationRequested &&
|
|
(result.kind === 'Plan' || result.kind === 'PlanningConflict')
|
|
) {
|
|
cancelledRace = interruptedOutcome(
|
|
result.kind === 'Plan' ? result : checkpoint,
|
|
'user-cancelled',
|
|
'The calculation was cancelled by the user.',
|
|
);
|
|
result = cancelledRace;
|
|
}
|
|
outcomeSettled = true;
|
|
clearTimeout(livenessWatchdog);
|
|
resolveOutcome(result);
|
|
}
|
|
};
|
|
const cancelledExplanation = (
|
|
result: PlanningExplanation | undefined,
|
|
): PlanningExplanation | undefined => {
|
|
if (!cancelledRace) return result;
|
|
const incomplete = {
|
|
kind: 'IncompleteExplanation' as const,
|
|
reason: cancelledRace.reasons[0],
|
|
completedStages: cancelledRace.completedStages,
|
|
uncompletedStages: [],
|
|
};
|
|
return cancelledRace.incumbent && result?.kind === 'PlanExplanation'
|
|
? { ...result, incomplete }
|
|
: incomplete;
|
|
};
|
|
const finishExplanation = (
|
|
result: PlanningExplanation | undefined,
|
|
error: PlanningReason | undefined,
|
|
finalState?: PlanningTraceState,
|
|
) => {
|
|
if (!explanationSettled) {
|
|
if (!error) result = cancelledExplanation(result);
|
|
explanationSettled = true;
|
|
receivedExplanation = result;
|
|
receivedExplanationError = error;
|
|
receivedFinalState = finalState;
|
|
resolveExplanation(result);
|
|
resolveExplanationError(error);
|
|
}
|
|
};
|
|
const finishArtifacts = (result: PlanningArtifacts, finishExplanationPromise = true) => {
|
|
if (!artifactsSettled) {
|
|
artifactsSettled = true;
|
|
const complete = {
|
|
...result,
|
|
explanation: result.explanation ?? receivedExplanation,
|
|
explanationError: result.explanationError ?? receivedExplanationError,
|
|
finalState: result.finalState ?? receivedFinalState,
|
|
};
|
|
if (cancelledRace) {
|
|
if (!complete.explanationError)
|
|
complete.explanation = cancelledExplanation(complete.explanation);
|
|
if (complete.trace && complete.trace.status !== 'failed')
|
|
complete.trace = {
|
|
...complete.trace,
|
|
status: 'incomplete',
|
|
error: cancelledRace.reasons[0],
|
|
};
|
|
}
|
|
if (finishExplanationPromise)
|
|
finishExplanation(
|
|
complete.explanation,
|
|
complete.explanationError,
|
|
complete.finalState,
|
|
);
|
|
resolveArtifacts(complete);
|
|
if (complete.trace && receivedStatuses) {
|
|
historicalStatusCache.set(complete.trace, new Map(receivedStatuses));
|
|
historicalNonWorkingCache.set(complete.trace, new Set(receivedNonWorkingDates));
|
|
}
|
|
resolveTimings({
|
|
...complete.timings,
|
|
traceTransferMs:
|
|
(terminalTraceTransferMs ?? complete.timings?.traceTransferMs ?? 0) +
|
|
traceTransferMs,
|
|
});
|
|
terminate();
|
|
if (supersede) latestWorkerCancels.delete(supersede);
|
|
}
|
|
};
|
|
const partialArtifacts = (
|
|
status: PlanningTrace['status'],
|
|
error: PlanningReason,
|
|
includeError = true,
|
|
) => {
|
|
const trace = receivedTrace ?? fallbackTrace(snapshot, status, error);
|
|
return {
|
|
...receivedArtifacts,
|
|
trace: { ...trace, status, error },
|
|
explanation: receivedExplanation,
|
|
finalState: receivedFinalState,
|
|
explanationError: includeError
|
|
? (receivedExplanationError ?? error)
|
|
: receivedExplanationError,
|
|
};
|
|
};
|
|
const applyTraceUpdate = (update: PlanningTraceUpdate): void => {
|
|
if (!receivedTrace && update.initialState)
|
|
receivedTrace = {
|
|
status: 'incomplete',
|
|
initialState: update.initialState,
|
|
phases: [],
|
|
completedStages: [],
|
|
nonWorkingDates: update.nonWorkingDates,
|
|
};
|
|
if (!receivedTrace) throw new Error('Trace update arrived without its initial state.');
|
|
if (update.nonWorkingDates) receivedTrace.nonWorkingDates = update.nonWorkingDates;
|
|
const phases = receivedTrace.phases;
|
|
for (const operation of update.operations) {
|
|
if (operation.kind === 'phase') {
|
|
const phase = phasesById.get(operation.phase.id);
|
|
if (phase) Object.assign(phase, operation.phase);
|
|
else {
|
|
const created = { ...operation.phase, decisions: [] };
|
|
phases.push(created);
|
|
phasesById.set(created.id, created);
|
|
}
|
|
continue;
|
|
}
|
|
const phase = phasesById.get(operation.phaseId);
|
|
if (!phase) throw new Error('Trace update referenced a missing phase.');
|
|
const decisions = phase.decisions;
|
|
if (operation.kind === 'decision') {
|
|
const decision = decisionsById.get(operation.decision.id);
|
|
if (decision) Object.assign(decision, operation.decision);
|
|
else {
|
|
const created = { ...operation.decision, candidates: [], deltas: [] };
|
|
decisions.push(created);
|
|
decisionsById.set(created.id, created);
|
|
}
|
|
continue;
|
|
}
|
|
const decision = decisionsById.get(operation.decisionId);
|
|
if (!decision) throw new Error('Trace update referenced a missing decision.');
|
|
if (operation.kind === 'candidate') {
|
|
const candidates = decision.candidates;
|
|
if (operation.mode === 'append' && operation.index === candidates.length)
|
|
candidates.push(operation.candidate);
|
|
else if (operation.mode === 'replace' && operation.index < candidates.length)
|
|
candidates[operation.index] = operation.candidate;
|
|
else throw new Error('Trace candidate update arrived out of order.');
|
|
continue;
|
|
}
|
|
const deltas = decision.deltas;
|
|
if (operation.index !== deltas.length)
|
|
throw new Error('Trace delta update arrived out of order.');
|
|
deltas.push(operation.delta);
|
|
}
|
|
};
|
|
supersede = () => {
|
|
if (artifactsSettled) return;
|
|
const hadOutcome = outcomeSettled;
|
|
const reason = {
|
|
code: 'stale-calculation',
|
|
message: 'Planning stopped because a newer calculation started.',
|
|
};
|
|
if (!hadOutcome)
|
|
finishOutcome(interruptedOutcome(checkpoint, reason.code, reason.message));
|
|
finishExplanation(
|
|
receivedExplanation,
|
|
hadOutcome ? receivedExplanationError : reason,
|
|
receivedFinalState ?? checkpointState,
|
|
);
|
|
traceUpdates = [];
|
|
traceUpdateIndex = 0;
|
|
finishArtifacts(
|
|
hadOutcome
|
|
? partialArtifacts('failed', reason, false)
|
|
: {
|
|
trace: fallbackTrace(snapshot, 'failed', reason),
|
|
explanationError: reason,
|
|
},
|
|
);
|
|
};
|
|
if (supersede) latestWorkerCancels.add(supersede);
|
|
const finalizeFailure = (reason: { code: string; message: string }) => {
|
|
if (!explanationSettled && checkpoint && checkpointState) {
|
|
receivedFinalState ??= checkpointState;
|
|
finishArtifacts(partialArtifacts('failed', reason), false);
|
|
void explainCheckpointInWorker(
|
|
publicSnapshot(snapshot),
|
|
checkpoint,
|
|
checkpointState,
|
|
reason,
|
|
)
|
|
.then((result) =>
|
|
finishExplanation(
|
|
result.explanation,
|
|
result.explanationError ?? reason,
|
|
result.finalState ?? checkpointState,
|
|
),
|
|
)
|
|
.catch((error: unknown) =>
|
|
finishExplanation(
|
|
undefined,
|
|
{
|
|
code: 'explanation-error',
|
|
message: error instanceof Error ? error.message : String(error),
|
|
},
|
|
checkpointState,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
finishExplanation(
|
|
receivedExplanation,
|
|
receivedExplanationError ?? reason,
|
|
receivedFinalState ?? checkpointState,
|
|
);
|
|
finishArtifacts(partialArtifacts('failed', reason));
|
|
};
|
|
let scheduleTraceDrain: () => void = () => {};
|
|
const fail = (code: string, message: string) => {
|
|
if (!outcomeSettled) finishOutcome(interruptedOutcome(checkpoint, code, message));
|
|
pendingFailure ??= { code, message };
|
|
if (traceUpdateIndex < traceUpdates.length) scheduleTraceDrain();
|
|
else finalizeFailure(pendingFailure);
|
|
};
|
|
const scheduleLivenessWatchdog = () => {
|
|
if (outcomeSettled || artifactsSettled) return;
|
|
clearTimeout(livenessWatchdog);
|
|
livenessWatchdog = setTimeout(
|
|
() =>
|
|
fail(
|
|
'worker-error',
|
|
'Planning worker stopped responding before returning its outcome.',
|
|
),
|
|
WORKER_LIVENESS_TIMEOUT_MS,
|
|
);
|
|
};
|
|
const finishDeliveredTrace = () =>
|
|
finishArtifacts({
|
|
...receivedArtifacts,
|
|
trace: receivedTrace,
|
|
explanation: receivedExplanation,
|
|
explanationError: receivedExplanationError,
|
|
finalState: receivedFinalState,
|
|
});
|
|
scheduleTraceDrain = () => {
|
|
if (
|
|
traceDrainTimer !== undefined ||
|
|
artifactsSettled ||
|
|
traceUpdateIndex >= traceUpdates.length
|
|
)
|
|
return;
|
|
traceDrainTimer = setTimeout(() => {
|
|
traceDrainTimer = undefined;
|
|
const drainStarted = performance.now();
|
|
let drained = 0;
|
|
let acknowledged = 0;
|
|
while (
|
|
traceUpdateIndex < traceUpdates.length &&
|
|
drained < MAX_TRACE_UPDATES_PER_DRAIN
|
|
) {
|
|
if (
|
|
drained > 0 &&
|
|
performance.now() - drainStarted >= TRACE_DRAIN_TIME_BUDGET_MS
|
|
)
|
|
break;
|
|
const updateIndex = traceUpdateIndex++;
|
|
const queued = traceUpdates[updateIndex];
|
|
traceUpdates[updateIndex] = undefined;
|
|
const started = performance.now();
|
|
let applied = false;
|
|
try {
|
|
if (queued) {
|
|
applyTraceUpdate(queued.update);
|
|
applied = true;
|
|
}
|
|
} catch (error) {
|
|
traceUpdates = [];
|
|
traceUpdateIndex = 0;
|
|
pendingFailure = {
|
|
code: 'trace-error',
|
|
message: error instanceof Error ? error.message : String(error),
|
|
};
|
|
} finally {
|
|
const duration = Math.max(0, performance.now() - started);
|
|
traceTransferMs += duration;
|
|
}
|
|
if (applied && queued?.acknowledge) acknowledged++;
|
|
drained++;
|
|
}
|
|
if (traceUpdateIndex >= traceUpdates.length) {
|
|
traceUpdates = [];
|
|
traceUpdateIndex = 0;
|
|
}
|
|
if (acknowledged && !workerStopped && !pendingFailure && !artifactsSettled) {
|
|
try {
|
|
worker.postMessage({ type: 'trace-ack', count: acknowledged });
|
|
} catch {
|
|
fail('worker-error', 'Planning trace acknowledgements could not be sent.');
|
|
}
|
|
}
|
|
if (artifactsSettled) return;
|
|
if (traceUpdateIndex < traceUpdates.length) scheduleTraceDrain();
|
|
else if (pendingFailure) finalizeFailure(pendingFailure);
|
|
else if (artifactsCompleteReceived) finishDeliveredTrace();
|
|
}, 0);
|
|
};
|
|
scheduleLivenessWatchdog();
|
|
|
|
cancel = () => {
|
|
if (outcomeSettled || cancellationRequested) return;
|
|
cancellationRequested = true;
|
|
try {
|
|
worker.postMessage({ type: 'cancel' });
|
|
} catch {
|
|
fail('worker-error', 'The cancellation request could not reach the worker.');
|
|
}
|
|
};
|
|
worker.onmessage = (event: MessageEvent<PlannerWorkerResponse>) => {
|
|
const message = event.data;
|
|
if (!message) return;
|
|
if (message.type === 'heartbeat') {
|
|
scheduleLivenessWatchdog();
|
|
return;
|
|
}
|
|
if (message.type === 'progress') {
|
|
scheduleLivenessWatchdog();
|
|
if (typeof snapshot.progress === 'function') snapshot.progress(message.stage);
|
|
return;
|
|
}
|
|
if (message.type === 'trace-overhead') {
|
|
scheduleLivenessWatchdog();
|
|
return;
|
|
}
|
|
if (message.type === 'checkpoint') {
|
|
scheduleLivenessWatchdog();
|
|
checkpoint = message.plan;
|
|
checkpointState = message.state;
|
|
return;
|
|
}
|
|
if (message.type === 'result') {
|
|
finishOutcome(message.outcome);
|
|
if (message.outcome.kind === 'InvalidInput') {
|
|
finishExplanation(undefined, undefined);
|
|
finishArtifacts({});
|
|
}
|
|
return;
|
|
}
|
|
if (message.type === 'explanation') {
|
|
finishExplanation(
|
|
message.explanation,
|
|
message.explanationError,
|
|
message.finalState,
|
|
);
|
|
return;
|
|
}
|
|
if (message.type === 'artifacts') {
|
|
finishArtifacts(message.artifacts);
|
|
return;
|
|
}
|
|
if (message.type === 'trace-status-index') {
|
|
const started = performance.now();
|
|
receivedStatuses = message.statuses;
|
|
receivedNonWorkingDates = message.nonWorkingDates;
|
|
traceTransferMs += Math.max(0, performance.now() - started);
|
|
return;
|
|
}
|
|
if (message.type === 'artifacts-start') {
|
|
const started = performance.now();
|
|
receivedArtifacts = message.artifacts;
|
|
if (message.trace) {
|
|
receivedTrace = {
|
|
...message.trace,
|
|
initialState: receivedTrace?.initialState ?? message.trace.initialState,
|
|
nonWorkingDates:
|
|
receivedTrace?.nonWorkingDates ?? message.trace.nonWorkingDates,
|
|
phases: receivedTrace?.phases ?? [],
|
|
};
|
|
}
|
|
traceTransferMs += Math.max(0, performance.now() - started);
|
|
return;
|
|
}
|
|
if (message.type === 'trace-update') {
|
|
if (pendingFailure) return;
|
|
traceUpdates.push({
|
|
update: message.update,
|
|
acknowledge: message.acknowledge === true,
|
|
});
|
|
scheduleTraceDrain();
|
|
return;
|
|
}
|
|
if (message.type === 'artifacts-complete') {
|
|
terminalTraceTransferMs = message.traceTransferMs;
|
|
artifactsCompleteReceived = true;
|
|
stopWorker();
|
|
if (traceUpdateIndex < traceUpdates.length) scheduleTraceDrain();
|
|
else finishDeliveredTrace();
|
|
return;
|
|
}
|
|
if (message.type === 'error') fail(message.code ?? 'worker-error', message.message);
|
|
else fail('worker-error', 'Planning worker returned an unexpected response.');
|
|
};
|
|
worker.onerror = () =>
|
|
fail('worker-error', 'Planning worker failed before returning explanation data.');
|
|
try {
|
|
worker.postMessage({
|
|
type: 'plan',
|
|
request: { ...snapshot, progress: undefined },
|
|
});
|
|
} catch {
|
|
fail('worker-error', 'Planning input could not be sent to the isolated worker.');
|
|
}
|
|
} catch {
|
|
const result = interruptedOutcome(
|
|
undefined,
|
|
'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],
|
|
});
|
|
resolveExplanation(undefined);
|
|
resolveExplanationError(result.reasons[0]);
|
|
resolveTimings({});
|
|
}
|
|
return { outcome, explanation, explanationError, artifacts, timings, cancel };
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
const traceStatusPriority = new Map<TraceCandidateStatus, number>([
|
|
['ineligible', 0],
|
|
['not-examined', 1],
|
|
['pruned', 2],
|
|
['evaluated-rejected', 3],
|
|
['chosen', 4],
|
|
]);
|
|
const historicalStatusCache = new WeakMap<PlanningTrace, Map<string, TraceCandidateStatus>>();
|
|
const historicalNonWorkingCache = new WeakMap<PlanningTrace, Set<string>>();
|
|
const historicalDecisionCache = new WeakMap<
|
|
PlanningTrace,
|
|
{ id: string; statuses: Map<string, TraceCandidateStatus> }
|
|
>();
|
|
|
|
async function historicalStatuses(
|
|
trace: PlanningTrace,
|
|
decisionId?: string,
|
|
): Promise<Map<string, TraceCandidateStatus>> {
|
|
if (!decisionId) {
|
|
const cached = historicalStatusCache.get(trace);
|
|
if (cached) return cached;
|
|
} else {
|
|
const cached = historicalDecisionCache.get(trace);
|
|
if (cached?.id === decisionId) return cached.statuses;
|
|
}
|
|
const statuses = new Map<string, TraceCandidateStatus>();
|
|
let count = 0;
|
|
for (const phase of trace.phases) {
|
|
for (const decision of phase.decisions) {
|
|
if (decisionId && decision.id !== decisionId) continue;
|
|
for (const candidate of decision.candidates) {
|
|
for (const date of candidate.dates ?? (candidate.date ? [candidate.date] : [])) {
|
|
const previous = statuses.get(date);
|
|
if (
|
|
!previous ||
|
|
traceStatusPriority.get(candidate.status)! >
|
|
traceStatusPriority.get(previous)!
|
|
)
|
|
statuses.set(date, candidate.status);
|
|
}
|
|
if (++count % 256 === 0)
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
}
|
|
}
|
|
if (!decisionId) {
|
|
historicalStatusCache.set(trace, statuses);
|
|
historicalNonWorkingCache.set(
|
|
trace,
|
|
new Set(trace.nonWorkingDates?.map(({ date }) => date) ?? []),
|
|
);
|
|
} else historicalDecisionCache.set(trace, { id: decisionId, statuses });
|
|
return statuses;
|
|
}
|
|
|
|
function historicalNonWorkingDates(trace: PlanningTrace): ReadonlySet<string> {
|
|
const cached = historicalNonWorkingCache.get(trace);
|
|
if (cached) return cached;
|
|
const dates = new Set(trace.nonWorkingDates?.map(({ date }) => date) ?? []);
|
|
historicalNonWorkingCache.set(trace, dates);
|
|
return dates;
|
|
}
|
|
|
|
interface ReplayState {
|
|
balances: PlanningTraceState['balances'][number][];
|
|
placements: Map<string, LeavePlacement>;
|
|
earnedActivations: PlanningTraceState['earnedActivations'][number][];
|
|
}
|
|
|
|
interface QueuedTraceUpdate {
|
|
update: PlanningTraceUpdate;
|
|
acknowledge: boolean;
|
|
}
|
|
|
|
function replayState(trace: PlanningTrace): ReplayState {
|
|
return {
|
|
balances: trace.initialState.balances.map((balance) => ({ ...balance })),
|
|
placements: new Map(
|
|
trace.initialState.placements.map((placement) => [placement.date, { ...placement }]),
|
|
),
|
|
earnedActivations: trace.initialState.earnedActivations.map((activation) => ({
|
|
...activation,
|
|
dates: [...activation.dates],
|
|
})),
|
|
};
|
|
}
|
|
|
|
export function applyReplayDeltas(
|
|
state: ReplayState,
|
|
deltas: readonly PlanningTraceDelta[],
|
|
finalState?: PlanningTraceState,
|
|
): void {
|
|
for (const delta of deltas) {
|
|
if (delta.previousPlacement) state.placements.delete(delta.previousPlacement.date);
|
|
if (delta.placement)
|
|
state.placements.set(delta.placement.date, {
|
|
...delta.placement,
|
|
ruleIds: [...delta.placement.ruleIds],
|
|
});
|
|
const balanceId = delta.balanceId ?? delta.balance?.id;
|
|
if (delta.kind === 'balance-removed') {
|
|
state.balances = state.balances.filter((balance) => balance.id !== balanceId);
|
|
continue;
|
|
}
|
|
if (balanceId) {
|
|
let index = state.balances.findIndex((candidate) => candidate.id === balanceId);
|
|
const changedBalance = delta.balance?.id === balanceId ? delta.balance : undefined;
|
|
if (index < 0 && changedBalance) {
|
|
state.balances.push({ ...changedBalance });
|
|
index = state.balances.length - 1;
|
|
}
|
|
if (index < 0 && delta.kind !== 'earned-activated') {
|
|
const source = finalState?.balances.find((candidate) => candidate.id === balanceId);
|
|
if (source) {
|
|
state.balances.push({ ...source });
|
|
index = state.balances.length - 1;
|
|
}
|
|
}
|
|
if (index >= 0) {
|
|
const current = state.balances[index]!;
|
|
state.balances[index] = {
|
|
...current,
|
|
...changedBalance,
|
|
...(delta.remainingAfter === undefined
|
|
? {}
|
|
: { remaining: delta.remainingAfter }),
|
|
};
|
|
}
|
|
}
|
|
if (
|
|
delta.kind === 'earned-activated' &&
|
|
delta.activation &&
|
|
!state.earnedActivations.some((activation) => activation.id === delta.activation!.id)
|
|
)
|
|
state.earnedActivations.push({
|
|
...delta.activation,
|
|
dates: [...delta.activation.dates],
|
|
});
|
|
}
|
|
}
|
|
|
|
function replayedState(trace: PlanningTrace, state: ReplayState): PlanningTraceState {
|
|
return {
|
|
calculationDate: trace.initialState.calculationDate,
|
|
horizon: { ...trace.initialState.horizon },
|
|
...(trace.initialState.declaredDays
|
|
? { declaredDays: trace.initialState.declaredDays }
|
|
: {}),
|
|
balances: state.balances,
|
|
placements: [...state.placements.values()],
|
|
earnedActivations: state.earnedActivations,
|
|
};
|
|
}
|
|
|
|
function stateBeforeDecision(
|
|
trace: PlanningTrace,
|
|
decisionId: string,
|
|
finalState?: PlanningTraceState,
|
|
): PlanningTraceState | undefined {
|
|
const decisions = trace.phases.flatMap((phase) => phase.decisions);
|
|
const targetIndex = decisions.findIndex((decision) => decision.id === decisionId);
|
|
if (targetIndex < 0) return undefined;
|
|
const target = decisions[targetIndex];
|
|
if (!target) return undefined;
|
|
const decisionsById = new Map(decisions.map((decision) => [decision.id, decision]));
|
|
let replayThrough = targetIndex - 1;
|
|
const branchAncestors: PlanningTraceDecision[] = [];
|
|
if (target.branch) {
|
|
let current = target;
|
|
const branchSeen = new Set<string>();
|
|
let committedParent: PlanningTraceDecision | undefined;
|
|
while (current.branch) {
|
|
if (branchSeen.has(current.id)) return undefined;
|
|
branchSeen.add(current.id);
|
|
branchAncestors.unshift(current);
|
|
const parentId = current.branch.parentDecisionId;
|
|
if (!parentId) break;
|
|
const parent = decisionsById.get(parentId);
|
|
if (!parent) return undefined;
|
|
if (!parent.branch) {
|
|
committedParent = parent;
|
|
break;
|
|
}
|
|
current = parent;
|
|
}
|
|
const branchRootIndex = decisions.findIndex(
|
|
(decision) => decision.id === branchAncestors[0]?.id,
|
|
);
|
|
if (branchRootIndex < 0) return undefined;
|
|
replayThrough = committedParent
|
|
? decisions.findIndex((decision) => decision.id === committedParent!.id)
|
|
: branchRootIndex - 1;
|
|
if (replayThrough >= branchRootIndex) return undefined;
|
|
}
|
|
const state = replayState(trace);
|
|
for (let index = 0; index <= replayThrough; index++) {
|
|
const decision = decisions[index];
|
|
if (decision) applyReplayDeltas(state, decision.deltas, finalState);
|
|
}
|
|
for (const ancestor of branchAncestors.slice(0, -1))
|
|
if (ancestor.branch) applyReplayDeltas(state, ancestor.branch.deltas, finalState);
|
|
return replayedState(trace, state);
|
|
}
|
|
|
|
function decisionBaseline(
|
|
trace: PlanningTrace,
|
|
selection: TraceCounterfactualSelection,
|
|
finalState?: PlanningTraceState,
|
|
): { state: PlanningTraceState; options: CounterfactualBaselineOptions } | undefined {
|
|
let decision: PlanningTraceDecision | undefined;
|
|
for (const phase of trace.phases) {
|
|
decision = phase.decisions.find((candidate) => candidate.id === selection.decisionId);
|
|
if (decision) break;
|
|
}
|
|
const state = stateBeforeDecision(trace, selection.decisionId, finalState);
|
|
if (!decision || !state) return undefined;
|
|
const transition = decision.branch?.deltas ?? decision.deltas;
|
|
const replacement = transition.find(
|
|
(delta) =>
|
|
(delta.kind === 'placement-moved' || delta.kind === 'placement-updated') &&
|
|
delta.placement?.date === selection.placement.date &&
|
|
delta.placement.balanceId === selection.placement.balanceId,
|
|
);
|
|
return {
|
|
state,
|
|
options: {
|
|
removeDate:
|
|
replacement?.kind === 'placement-moved'
|
|
? (replacement.fromDate ?? null)
|
|
: (replacement?.date ?? null),
|
|
compareOriginalState: true,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function withHistoricalStatuses(
|
|
analysis: CounterfactualAnalysis,
|
|
trace?: PlanningTrace,
|
|
decisionId?: string,
|
|
): Promise<CounterfactualAnalysis> {
|
|
if (!trace) return analysis;
|
|
const statuses = await historicalStatuses(trace, decisionId);
|
|
const nonWorkingDates = historicalNonWorkingDates(trace);
|
|
return {
|
|
...analysis,
|
|
results: analysis.results.map((result) => {
|
|
const observedStatus = statuses.get(result.date);
|
|
const historicalReason = result.historicalReason;
|
|
const nonWorking = nonWorkingDates.has(result.date);
|
|
return {
|
|
...result,
|
|
historicalStatus:
|
|
observedStatus ??
|
|
(trace.status === 'failed'
|
|
? undefined
|
|
: historicalReason || nonWorking
|
|
? 'ineligible'
|
|
: 'not-examined'),
|
|
historicalReason: observedStatus === undefined ? historicalReason : undefined,
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
|
|
function addHistoricalReasons(
|
|
analysis: CounterfactualAnalysis,
|
|
entries: readonly HistoricalEligibilityReason[],
|
|
): CounterfactualAnalysis {
|
|
const reasons = new Map(entries.map(({ date, reason }) => [date, reason]));
|
|
return {
|
|
...analysis,
|
|
results: analysis.results.map((result) => {
|
|
const historicalReason = reasons.get(result.date);
|
|
return historicalReason ? { ...result, historicalReason } : result;
|
|
}),
|
|
};
|
|
}
|
|
|
|
async function runHistoricalEligibility(
|
|
context: PlanningCalculationContext,
|
|
selection: TraceCounterfactualSelection,
|
|
artifacts: Promise<PlanningArtifacts>,
|
|
isCurrent: () => boolean,
|
|
): Promise<readonly HistoricalEligibilityReason[]> {
|
|
if (context.id !== latestCalculationId || !isCurrent()) return [];
|
|
const completed = await artifacts;
|
|
if (context.id !== latestCalculationId || !isCurrent()) return [];
|
|
const trace = completed.trace;
|
|
const baseline = trace && decisionBaseline(trace, selection, completed.finalState);
|
|
if (!baseline) throw new Error('The selected trace decision is unavailable.');
|
|
const entries =
|
|
typeof window !== 'undefined' && typeof Worker !== 'undefined'
|
|
? await historicalEligibilityInWorker(
|
|
context.snapshot,
|
|
baseline.state,
|
|
selection.placement,
|
|
baseline.options,
|
|
)
|
|
: await (async () => {
|
|
const core = await import('./leave-planner.core');
|
|
return core.historicalCounterfactualReasons(
|
|
context.snapshot,
|
|
baseline.state,
|
|
selection.placement,
|
|
baseline.options,
|
|
);
|
|
})();
|
|
return context.id === latestCalculationId && isCurrent() ? entries : [];
|
|
}
|
|
|
|
async function runCounterfactual(
|
|
context: PlanningCalculationContext,
|
|
selection: CounterfactualSelection,
|
|
artifacts: Promise<PlanningArtifacts>,
|
|
isCurrent: () => boolean,
|
|
): Promise<CounterfactualAnalysis | StaleCounterfactualAnalysis> {
|
|
const stale = (placement: LeavePlacement): StaleCounterfactualAnalysis => ({
|
|
kind: 'CounterfactualStale',
|
|
calculationId: context.id,
|
|
placement,
|
|
hypothetical: true,
|
|
results: [],
|
|
});
|
|
if (context.id !== latestCalculationId) {
|
|
const plan = await context.outcome;
|
|
const finalPlan =
|
|
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 stale(placement);
|
|
}
|
|
const outcome = await context.outcome;
|
|
if (context.id !== latestCalculationId || !isCurrent()) {
|
|
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 stale(placement);
|
|
}
|
|
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;
|
|
if (context.id !== latestCalculationId || !isCurrent()) return stale(placement);
|
|
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 || !isCurrent()) return stale(placement);
|
|
if (typeof window !== 'undefined' && typeof Worker !== 'undefined') {
|
|
let result: CounterfactualAnalysis;
|
|
try {
|
|
result = await counterfactualInWorker(
|
|
context.snapshot,
|
|
plan,
|
|
placement,
|
|
analysisState,
|
|
options,
|
|
);
|
|
} catch (error) {
|
|
if (context.id !== latestCalculationId || !isCurrent()) return stale(placement);
|
|
throw error;
|
|
}
|
|
if (context.id !== latestCalculationId || !isCurrent()) return stale(placement);
|
|
return withHistoricalStatuses(
|
|
{ ...result, calculationId: context.id },
|
|
completedArtifacts.trace,
|
|
traceSelection(selection) ? selection.decisionId : undefined,
|
|
);
|
|
}
|
|
const core = await import('./leave-planner.core');
|
|
let result = core.analyzeCounterfactualInternal(
|
|
context.snapshot,
|
|
plan,
|
|
placement,
|
|
analysisState,
|
|
options,
|
|
);
|
|
if (selectedDecision)
|
|
result = addHistoricalReasons(
|
|
result,
|
|
core.historicalCounterfactualReasons(
|
|
context.snapshot,
|
|
selectedDecision.state,
|
|
placement,
|
|
selectedDecision.options,
|
|
),
|
|
);
|
|
return context.id === latestCalculationId && isCurrent()
|
|
? withHistoricalStatuses(
|
|
{ ...result, calculationId: context.id },
|
|
completedArtifacts.trace,
|
|
traceSelection(selection) ? selection.decisionId : undefined,
|
|
)
|
|
: stale(placement);
|
|
}
|
|
|
|
function counterfactualInWorker(
|
|
snapshot: PlanningSnapshot,
|
|
plan: PlanOutcome,
|
|
placement: LeavePlacement,
|
|
state?: PlanningTraceState,
|
|
options?: CounterfactualBaselineOptions,
|
|
): Promise<CounterfactualAnalysis> {
|
|
return new Promise((resolve, reject) => {
|
|
latestCounterfactualCancel?.();
|
|
let worker: Worker;
|
|
try {
|
|
worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
|
|
type: 'module',
|
|
});
|
|
} catch (error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
let settled = false;
|
|
const finish = (callback: () => void) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
worker.onmessage = null;
|
|
worker.onerror = null;
|
|
worker.terminate();
|
|
if (cancel) latestWorkerCancels.delete(cancel);
|
|
if (latestCounterfactualCancel === cancel) latestCounterfactualCancel = undefined;
|
|
callback();
|
|
};
|
|
const cancel = () =>
|
|
finish(() => reject(new Error('Counterfactual analysis was superseded.')));
|
|
latestCounterfactualCancel = cancel;
|
|
latestWorkerCancels.add(cancel);
|
|
worker.onmessage = (
|
|
event: MessageEvent<{
|
|
type: string;
|
|
analysis?: CounterfactualAnalysis;
|
|
code?: string;
|
|
message?: string;
|
|
}>,
|
|
) => {
|
|
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 (${event.data.code ?? 'worker-error'}).`,
|
|
),
|
|
),
|
|
);
|
|
};
|
|
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));
|
|
}
|
|
});
|
|
}
|
|
|
|
function historicalEligibilityInWorker(
|
|
snapshot: PlanningSnapshot,
|
|
state: PlanningTraceState,
|
|
placement: LeavePlacement,
|
|
options?: CounterfactualBaselineOptions,
|
|
): Promise<readonly HistoricalEligibilityReason[]> {
|
|
return new Promise((resolve, reject) => {
|
|
latestHistoricalEligibilityCancel?.();
|
|
let worker: Worker;
|
|
try {
|
|
worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
|
|
type: 'module',
|
|
});
|
|
} catch (error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
let settled = false;
|
|
const finish = (callback: () => void) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
worker.onmessage = null;
|
|
worker.onerror = null;
|
|
worker.terminate();
|
|
if (cancel) latestWorkerCancels.delete(cancel);
|
|
if (latestHistoricalEligibilityCancel === cancel)
|
|
latestHistoricalEligibilityCancel = undefined;
|
|
callback();
|
|
};
|
|
const cancel = () =>
|
|
finish(() => reject(new Error('Historical eligibility analysis was superseded.')));
|
|
latestHistoricalEligibilityCancel = cancel;
|
|
latestWorkerCancels.add(cancel);
|
|
worker.onmessage = (event: MessageEvent<PlannerWorkerResponse>) => {
|
|
const response = event.data;
|
|
if (response?.type === 'historical-eligibility-result')
|
|
finish(() => resolve(response.entries));
|
|
else if (response?.type === 'error')
|
|
finish(() =>
|
|
reject(
|
|
new Error(
|
|
response.message ??
|
|
`Historical eligibility analysis failed (${response.code ?? 'worker-error'}).`,
|
|
),
|
|
),
|
|
);
|
|
};
|
|
worker.onerror = () =>
|
|
finish(() => reject(new Error('Historical eligibility worker failed.')));
|
|
try {
|
|
worker.postMessage({
|
|
type: 'historical-eligibility',
|
|
request: snapshot,
|
|
state,
|
|
placement,
|
|
options,
|
|
});
|
|
} catch (error) {
|
|
finish(() => reject(error));
|
|
}
|
|
});
|
|
}
|
|
|
|
function explainCheckpointInWorker(
|
|
snapshot: PlanningSnapshot,
|
|
plan: PlanOutcome,
|
|
state: PlanningTraceState,
|
|
interruption: PlanningReason,
|
|
): Promise<Pick<PlanningArtifacts, 'explanation' | 'explanationError' | 'finalState'>> {
|
|
return new Promise((resolve, reject) => {
|
|
let worker: Worker;
|
|
try {
|
|
worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
|
|
type: 'module',
|
|
});
|
|
} catch (error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
let settled = false;
|
|
const finish = (callback: () => void) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
worker.onmessage = null;
|
|
worker.onerror = null;
|
|
worker.terminate();
|
|
if (cancel) latestWorkerCancels.delete(cancel);
|
|
callback();
|
|
};
|
|
const cancel = () =>
|
|
finish(() => reject(new Error('Checkpoint explanation was superseded.')));
|
|
latestWorkerCancels.add(cancel);
|
|
worker.onmessage = (event: MessageEvent<PlannerWorkerResponse>) => {
|
|
const response = event.data;
|
|
if (response?.type === 'explanation')
|
|
finish(() =>
|
|
resolve({
|
|
explanation: response.explanation,
|
|
explanationError: response.explanationError,
|
|
finalState: response.finalState,
|
|
}),
|
|
);
|
|
else if (response?.type === 'error') finish(() => reject(new Error(response.message)));
|
|
};
|
|
worker.onerror = () =>
|
|
finish(() => reject(new Error('Checkpoint explanation worker failed.')));
|
|
try {
|
|
worker.postMessage({
|
|
type: 'explain-checkpoint',
|
|
request: snapshot,
|
|
plan,
|
|
state,
|
|
interruption,
|
|
});
|
|
} catch (error) {
|
|
finish(() => reject(error));
|
|
}
|
|
});
|
|
}
|
|
|
|
export interface PlanningCalculationContext {
|
|
readonly cancel: () => void;
|
|
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 timings?: Promise<PlanningCalculationTimings>;
|
|
readonly analyzeCounterfactual: (
|
|
selection: CounterfactualSelection,
|
|
) => Promise<CounterfactualAnalysis | StaleCounterfactualAnalysis>;
|
|
readonly historicalEligibility: (
|
|
selection: TraceCounterfactualSelection,
|
|
) => Promise<readonly HistoricalEligibilityReason[]>;
|
|
}
|
|
|
|
export function createPlanningCalculation(request: PlanningRequest): PlanningCalculationContext {
|
|
const calculationDate = planningDateInParis(new Date());
|
|
cancelLatestWorkers();
|
|
const transport = { ...snapshotRequest(request), calculationDate };
|
|
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))),
|
|
explanation: Promise.resolve(undefined),
|
|
explanationError: Promise.resolve(undefined),
|
|
artifacts: Promise.resolve({}),
|
|
timings: Promise.resolve({}),
|
|
};
|
|
} else {
|
|
if (typeof window !== 'undefined') {
|
|
if (typeof Worker !== 'undefined') {
|
|
run = planInWorker(transport);
|
|
} else {
|
|
let resolveArtifacts!: (artifacts: PlanningArtifacts) => void;
|
|
let resolveExplanation!: (explanation: PlanningExplanation | undefined) => void;
|
|
let resolveExplanationError!: (error: PlanningReason | undefined) => void;
|
|
const artifacts = new Promise<PlanningArtifacts>(
|
|
(resolve) => (resolveArtifacts = resolve),
|
|
);
|
|
const explanation = new Promise<PlanningExplanation | undefined>(
|
|
(resolve) => (resolveExplanation = resolve),
|
|
);
|
|
const explanationError = new Promise<PlanningReason | undefined>(
|
|
(resolve) => (resolveExplanationError = 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);
|
|
resolveExplanation(undefined);
|
|
resolveExplanationError(undefined);
|
|
resolveArtifacts({});
|
|
return;
|
|
}
|
|
const result = interruptedOutcome(
|
|
undefined,
|
|
'worker-unavailable',
|
|
'Planning could not start because isolated execution is unavailable.',
|
|
);
|
|
resolve(result);
|
|
const incompleteExplanation: PlanningExplanation = {
|
|
kind: 'IncompleteExplanation',
|
|
reason: result.reasons[0],
|
|
completedStages: [],
|
|
uncompletedStages: [],
|
|
};
|
|
resolveExplanation(incompleteExplanation);
|
|
resolveExplanationError(undefined);
|
|
resolveArtifacts({
|
|
explanation: incompleteExplanation,
|
|
trace: fallbackTrace(transport, 'failed', result.reasons[0]),
|
|
});
|
|
});
|
|
});
|
|
run = {
|
|
outcome,
|
|
explanation,
|
|
explanationError,
|
|
artifacts,
|
|
timings: Promise.resolve({}),
|
|
};
|
|
}
|
|
} else {
|
|
let cancellationRequested = false;
|
|
let outcomeSettled = false;
|
|
let superseded = false;
|
|
const replacementReason: PlanningReason = {
|
|
code: 'stale-calculation',
|
|
message: 'Planning stopped because a newer calculation started.',
|
|
};
|
|
const explanationForRun = (value: PlanningExplanation | undefined) => {
|
|
if (!superseded) return value;
|
|
if (value?.kind === 'IncompleteExplanation')
|
|
return { ...value, reason: replacementReason };
|
|
if (value?.kind === 'PlanExplanation' && value.incomplete)
|
|
return {
|
|
...value,
|
|
incomplete: { ...value.incomplete, reason: replacementReason },
|
|
};
|
|
return value;
|
|
};
|
|
const supersede = () => {
|
|
if (!outcomeSettled) {
|
|
superseded = true;
|
|
cancellationRequested = true;
|
|
}
|
|
};
|
|
latestWorkerCancels.add(supersede);
|
|
let resolveArtifacts!: (artifacts: PlanningArtifacts) => void;
|
|
let resolveExplanation!: (explanation: PlanningExplanation | undefined) => void;
|
|
let resolveExplanationError!: (error: PlanningReason | undefined) => void;
|
|
let resolveTimings!: (timings: PlanningCalculationTimings) => void;
|
|
const artifacts = new Promise<PlanningArtifacts>(
|
|
(resolve) => (resolveArtifacts = resolve),
|
|
);
|
|
const explanation = new Promise<PlanningExplanation | undefined>(
|
|
(resolve) => (resolveExplanation = resolve),
|
|
);
|
|
const explanationError = new Promise<PlanningReason | undefined>(
|
|
(resolve) => (resolveExplanationError = resolve),
|
|
);
|
|
const timings = new Promise<PlanningCalculationTimings>(
|
|
(resolve) => (resolveTimings = resolve),
|
|
);
|
|
const outcome = new Promise<PlanningOutcome>((resolve) => {
|
|
queueMicrotask(async () => {
|
|
try {
|
|
const core = await import('./leave-planner.core');
|
|
let completedArtifacts: PlanningArtifacts = {};
|
|
const result = await core.calculatePlanningAsyncInternal(
|
|
transport,
|
|
undefined,
|
|
(value) => (completedArtifacts = value),
|
|
(value) => {
|
|
outcomeSettled = true;
|
|
resolve(
|
|
superseded && value.kind === 'PlanningIncomplete'
|
|
? {
|
|
...value,
|
|
reasons: [replacementReason],
|
|
}
|
|
: value,
|
|
);
|
|
},
|
|
(value, error) => {
|
|
resolveExplanation(explanationForRun(value));
|
|
resolveExplanationError(error);
|
|
},
|
|
() => cancellationRequested,
|
|
);
|
|
if (result.kind === 'InvalidInput') {
|
|
resolveExplanation(undefined);
|
|
resolveExplanationError(undefined);
|
|
}
|
|
resolve(result);
|
|
queueMicrotask(() => {
|
|
resolveArtifacts({
|
|
...completedArtifacts,
|
|
explanation: explanationForRun(completedArtifacts.explanation),
|
|
});
|
|
resolveTimings(completedArtifacts.timings ?? {});
|
|
});
|
|
} catch (error) {
|
|
const reason: PlanningReason = {
|
|
code: 'planner-error',
|
|
message: error instanceof Error ? error.message : String(error),
|
|
};
|
|
resolve({
|
|
kind: 'PlanningIncomplete',
|
|
completedStages: [],
|
|
reasons: [reason],
|
|
});
|
|
resolveExplanation(undefined);
|
|
resolveExplanationError(reason);
|
|
resolveArtifacts({
|
|
trace: fallbackTrace(transport, 'failed', reason),
|
|
explanationError: reason,
|
|
});
|
|
resolveTimings({});
|
|
} finally {
|
|
outcomeSettled = true;
|
|
latestWorkerCancels.delete(supersede);
|
|
}
|
|
});
|
|
});
|
|
run = {
|
|
outcome,
|
|
explanation,
|
|
explanationError,
|
|
artifacts,
|
|
timings,
|
|
cancel: () => {
|
|
if (!outcomeSettled) cancellationRequested = true;
|
|
},
|
|
};
|
|
}
|
|
}
|
|
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.',
|
|
}),
|
|
}));
|
|
let counterfactualSequence = 0;
|
|
let historicalEligibilitySequence = 0;
|
|
const context: PlanningCalculationContext = {
|
|
id,
|
|
snapshot,
|
|
cancel: () => run.cancel?.(),
|
|
outcome: run.outcome.then((result) => ({
|
|
...result,
|
|
calculationDate,
|
|
...(result.kind === 'PlanningIncomplete' && result.incumbent
|
|
? { incumbent: { ...result.incumbent, calculationDate } }
|
|
: {}),
|
|
})),
|
|
explanation: run.explanation.then((result) =>
|
|
result ? { ...result, calculationDate } : undefined,
|
|
),
|
|
explanationError: run.explanationError,
|
|
trace: retainedArtifacts.then((artifacts) => artifacts.trace),
|
|
timings: run.timings,
|
|
analyzeCounterfactual: (selection) => {
|
|
const sequence = ++counterfactualSequence;
|
|
if (id === latestCalculationId) latestCounterfactualCancel?.();
|
|
return runCounterfactual(
|
|
context,
|
|
selection,
|
|
retainedArtifacts,
|
|
() => sequence === counterfactualSequence,
|
|
);
|
|
},
|
|
historicalEligibility: (selection) => {
|
|
const sequence = ++historicalEligibilitySequence;
|
|
return runHistoricalEligibility(
|
|
context,
|
|
selection,
|
|
retainedArtifacts,
|
|
() => sequence === historicalEligibilitySequence,
|
|
);
|
|
},
|
|
};
|
|
return context;
|
|
}
|
|
|
|
export async function planLeave(request: PlanningRequest): Promise<PlanningOutcome> {
|
|
return createPlanningCalculation(request).outcome;
|
|
}
|