import type { ActiveSubstate, CandidateSet, ConsumedEntry, DeferredProbe, EpisodeState, EpisodeStatus, ProbeKind, ReduceResult, Segment } from "./types"; import { parseModelKey } from "./types"; import { emptyLedger, episodeResetsAt, ledger, type Ledger } from "./ledger"; import { candidateSetViolation, freezeCandidateSets } from "../config/schema"; /** OD-9: the snapshot version this build writes. `decodeSnapshot` also accepts version 1 and upgrades * it; anything higher was written by a newer build and is reported as such, not as corruption. */ export const SNAPSHOT_VERSION = 2; export interface EpisodeSnapshot { readonly version: 2; readonly status: EpisodeStatus; readonly activeModel: string; readonly rootPrimary?: string; readonly segments: readonly Segment[]; readonly consumed: Readonly>; readonly probeCount: number; readonly probeCursor: number; readonly maxProbes: number; readonly maxOutageProbes: number; readonly substate?: ActiveSubstate; readonly consecutiveOutage: number; readonly pendingResume: boolean; readonly generation: number; readonly episodeId: string; readonly fallbacks: Readonly>; readonly resetMarginSec: number; readonly intervalMin: number; readonly outageIntervalMin: number; readonly nextDueAt?: number; readonly busy: boolean; readonly deferredProbe?: DeferredProbe; } export interface RebuildOptions { probeDue?: boolean; } const statuses: readonly EpisodeStatus[] = ["idle", "active", "reverted", "suspended-by-user", "given-up"], substates: readonly ActiveSubstate[] = ["discriminating", "hourly-probing", "awaiting-resets_at", "outage-pacing"], kinds: readonly ProbeKind[] = ["early", "scheduled"]; const DEFAULT_RESET_MARGIN_SEC = 60, DEFAULT_INTERVAL_MIN = 60, DEFAULT_OUTAGE_INTERVAL_MIN = 10; const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value); const model = (value: unknown): value is string => typeof value === "string" && !!parseModelKey(value); const record = (value: unknown): value is Record => !!value && typeof value === "object" && !Array.isArray(value); const oneOf = (value: unknown, values: readonly T[]): value is T => typeof value === "string" && values.includes(value as T); // Strict CandidateSet-shape check for the persisted snapshot: no sugar here (that only exists on the // config-load path). The one tolerance is the v1 upgrade below, which accepts a scalar model key. const candidateSet = (value: unknown): value is CandidateSet => record(value) && Array.isArray(value.substitutes) && value.substitutes.every(model) && (value.assistant === undefined || model(value.assistant)); // The `confirmedUsable` clause is R9's snapshot tolerance (R17, no version bump): the marker is // optional in both directions, but a PRESENT value must be a boolean — any other shape is damage and // fails the decode loudly instead of rebuilding an entry the reducer reads as a confirmation. const consumedEntry = (value: unknown): value is ConsumedEntry => record(value) && (value.state === "entered" || value.state === "skipped") && (value.resetsAt === undefined || finite(value.resetsAt)) && (value.confirmedUsable === undefined || typeof value.confirmedUsable === "boolean"); /** Carried finding `R3-version-mismatch-classified-as-corruption`: a snapshot written by a NEWER build * is a different fact from a corrupt one, with a different user action, so callers can tell them apart * instead of reporting both as unreadable. */ export const snapshotVersionAhead = (input: unknown): boolean => record(input) && typeof input.version === "number" && Number.isInteger(input.version) && input.version > SNAPSHOT_VERSION; /** OD-9 v1 → v2 upgrade for the candidate map: a v1 value is the scalar successor key. A set-shaped * value is accepted too, because the interim build between the config-shape change and this version * bump wrote one under `version: 1`. */ const upgradeFallbacks = (input: Record): Record | undefined => { const upgraded: Record = {}; for (const [key, value] of Object.entries(input)) { if (!model(key)) return undefined; if (candidateSet(value)) upgraded[key] = value; // D4: a v1 snapshot cannot carry a within-set duplicate (a scalar value is one key), but its scalar // successor CAN name the primary itself — rejected here on the upgrade path, exactly as the set // branch is rejected by the decode guard below. else if (model(value) && value !== key) upgraded[key] = { substitutes: [value] }; else return undefined; } return upgraded; }; /** OD-9 v1 → v2 ledger upgrade. DERIVED from `rootPrimary` + `segments`, not read off `visited`: an * idle `user_model_select` replaced `activeModel` without touching `visited`, so a v1 `visited` can * carry a key the episode never ran on. Mapping such a key to `entered` would promote it into a probe * target and hand R8 a verdict about a model that never exhausted. Under v1 a residual key served only * the re-entry guard and was never probed, so `skipped` preserves exactly its v1 behaviour. */ const upgradeConsumed = (rootPrimary: unknown, segments: readonly Segment[], visited: readonly string[]): Ledger => { const consumed = emptyLedger(); if (model(rootPrimary)) consumed[rootPrimary] = { state: "entered" }; for (const segment of segments) { consumed[segment.active] = { state: "entered" }; consumed[segment.primary] = { state: "entered", ...(finite(segment.resetsAt) ? { resetsAt: segment.resetsAt } : {}) }; } for (const key of visited) if (!consumed[key]) consumed[key] = { state: "skipped" }; return consumed; }; /** OD-5 form D v1 → v2 segment upgrade. Under v1 `segment.primary` was the model that failed INTO that * segment, not the episode root, so a depth-two v1 episode copied verbatim leaves `segments.at(-1).primary` * pointing at an intermediate substitute — and that is exactly the model `pi-adapters` probes, while * `probe()` restores `rootPrimary` on a usable verdict. The user would be handed back a model that was * never probed and is still exhausted. Every segment is rewritten onto the root primary; the v1 `resetsAt` * is dropped because `upgradeConsumed` has already re-homed it onto the correctly attributed ledger entry, * and keeping it here would reintroduce the mis-attribution the ledger exists to fix. */ const upgradeSegments = (rootPrimary: unknown, segments: readonly Segment[]): readonly Segment[] => { const root = model(rootPrimary) ? rootPrimary : segments[0]?.primary; return root === undefined ? segments : segments.map(({ resetsAt: _reset, ...rest }) => ({ ...rest, primary: root })); }; /** R16: `Segment.tier` is RECORDED, never re-derived. A present value is validated as `1|2` in the * decode guard and reaches here untouched, so an operator who re-roles the assistant as a tier-1 * substitute cannot rewrite what an earlier session already recorded. An ABSENT value is the older * shape — a version-1 episode predates tier 2 entirely, and the interim version-2 build between this * field and the version bump wrote none — so it upgrades to tier 1 rather than failing the decode. */ const upgradeTier = (segments: readonly Segment[]): readonly Segment[] => segments.map((segment) => segment.tier === 1 || segment.tier === 2 ? segment : { ...segment, tier: 1 }); export function encodeSnapshot(state: EpisodeState): EpisodeSnapshot { return { version: SNAPSHOT_VERSION, status: state.status, activeModel: state.activeModel, rootPrimary: state.rootPrimary, segments: state.segments.map((segment) => ({ ...segment })), consumed: { ...state.consumed }, probeCount: state.probeCount, probeCursor: state.probeCursor, maxProbes: state.maxProbes, maxOutageProbes: state.maxOutageProbes, substate: state.substate, consecutiveOutage: state.consecutiveOutage, pendingResume: state.pendingResume, generation: state.generation, episodeId: state.episodeId, fallbacks: { ...state.fallbacks }, resetMarginSec: state.resetMarginSec, intervalMin: state.intervalMin, outageIntervalMin: state.outageIntervalMin, nextDueAt: state.nextDueAt, busy: state.busy, deferredProbe: state.deferredProbe ? { ...state.deferredProbe } : undefined }; } export function decodeSnapshot(input: unknown): EpisodeSnapshot | undefined { try { if (!record(input) || (input.version !== 1 && input.version !== SNAPSHOT_VERSION) || !oneOf(input.status, statuses) || !model(input.activeModel) || !Array.isArray(input.segments) || !record(input.fallbacks)) return undefined; const legacy = input.version === 1; if (legacy ? !Array.isArray(input.visited) || !input.visited.every(model) : !record(input.consumed) || !Object.entries(input.consumed).every(([key, value]) => model(key) && consumedEntry(value))) return undefined; if (!input.segments.every((item) => record(item) && model(item.primary) && model(item.active) && finite(item.enteredAt) && (item.resetsAt === undefined || finite(item.resetsAt)) && (item.tier === undefined || item.tier === 1 || item.tier === 2)) || !["probeCount", "maxProbes", "maxOutageProbes", "consecutiveOutage", "generation"].every((key) => finite(input[key])) || typeof input.pendingResume !== "boolean" || typeof input.episodeId !== "string" || typeof input.busy !== "boolean") return undefined; if ((input.rootPrimary !== undefined && !model(input.rootPrimary)) || (input.status === "active" && (!model(input.rootPrimary) || !oneOf(input.substate, substates))) || (input.substate !== undefined && !oneOf(input.substate, substates)) || (input.nextDueAt !== undefined && !finite(input.nextDueAt))) return undefined; const rawSegments = input.segments as readonly Segment[]; // D4 decode-boundary guard: the SAME within-set invariants config validation enforces (shared // `candidateSetViolation`) — a snapshot naming one key as both substitute and assistant, or carrying // a duplicate entry, fails the decode (`undefined` → loud rebuild failure), instead of decoding // cleanly into an episode whose `beginSegment` tier read is ambiguous. const fallbacks = legacy ? upgradeFallbacks(input.fallbacks) : (Object.entries(input.fallbacks).every(([key, value]) => model(key) && candidateSet(value) && candidateSetViolation(key, value) === undefined) ? input.fallbacks as Record : undefined); if (!fallbacks) return undefined; // Order matters: `upgradeConsumed` reads the ORIGINAL v1 segments, where `primary` still means the // model that failed into that segment and its `resetsAt` is therefore correctly attributed. const consumed = legacy ? upgradeConsumed(input.rootPrimary, rawSegments, input.visited as readonly string[]) : ledger(input.consumed as Ledger); const segments = upgradeTier(legacy ? upgradeSegments(input.rootPrimary, rawSegments) : rawSegments); if (input.deferredProbe !== undefined && (!record(input.deferredProbe) || !oneOf(input.deferredProbe.kind, kinds) || !finite(input.deferredProbe.generation))) return undefined; // A version-1 snapshot, and any v2 snapshot written before the rotation existed, simply starts the // rotation rather than carrying `undefined` into `probeCursor % targets.length`. const probeCursor = finite(input.probeCursor) && input.probeCursor >= 0 ? Math.floor(input.probeCursor) : 0; const resetMarginSec = input.resetMarginSec === undefined ? DEFAULT_RESET_MARGIN_SEC : input.resetMarginSec; const intervalMin = input.intervalMin === undefined ? DEFAULT_INTERVAL_MIN : input.intervalMin; const outageIntervalMin = input.outageIntervalMin === undefined ? DEFAULT_OUTAGE_INTERVAL_MIN : input.outageIntervalMin; if (!finite(resetMarginSec) || resetMarginSec < 0 || !finite(intervalMin) || intervalMin <= 0 || !finite(outageIntervalMin) || outageIntervalMin <= 0) return undefined; const { visited: _visited, ...rest } = input; return { ...rest, version: SNAPSHOT_VERSION, fallbacks, consumed, segments, probeCursor, resetMarginSec, intervalMin, outageIntervalMin } as unknown as EpisodeSnapshot; } catch { return undefined; } } const terminal = (state: EpisodeState, status: "reverted" | "suspended-by-user", modelKey: string): ReduceResult => ({ state: { ...state, status, activeModel: modelKey, segments: status === "reverted" ? [] : state.segments, substate: undefined, pendingResume: false, busy: false, deferredProbe: undefined, nextDueAt: undefined }, effects: [{ type: "cancelProbes", generation: state.generation }, { type: "clearStatus" }, { type: "stopReviewCapture", episodeId: state.episodeId }] }); export function rebuild(input: unknown, currentModel: string, options: RebuildOptions = {}): ReduceResult | undefined { const snapshot = decodeSnapshot(input); if (!snapshot || !model(currentModel)) return undefined; // The `freezeCandidateSets` call replaces a shallow `Object.freeze({ ...snapshot.fallbacks })`, which // left each set's nested `substitutes` array mutable through the rebuilt state (slice 2's TRAP 2, at // the third site slice 2 left untouched because it was out of that slice's scope). const state: EpisodeState = { status: snapshot.status, activeModel: snapshot.activeModel, rootPrimary: snapshot.rootPrimary, segments: snapshot.segments.map((segment) => ({ ...segment })), consumed: ledger(snapshot.consumed), probeCount: snapshot.probeCount, probeCursor: snapshot.probeCursor, maxProbes: snapshot.maxProbes, maxOutageProbes: snapshot.maxOutageProbes, substate: snapshot.substate, consecutiveOutage: snapshot.consecutiveOutage, pendingResume: snapshot.pendingResume, generation: snapshot.generation + 1, episodeId: snapshot.episodeId, fallbacks: freezeCandidateSets(snapshot.fallbacks), resetMarginSec: snapshot.resetMarginSec, intervalMin: snapshot.intervalMin, outageIntervalMin: snapshot.outageIntervalMin, nextDueAt: snapshot.nextDueAt, busy: snapshot.busy, deferredProbe: snapshot.deferredProbe ? { ...snapshot.deferredProbe } : undefined }; if (snapshot.status !== "active") return { state: { ...state, activeModel: currentModel }, effects: [] }; if (currentModel !== snapshot.activeModel) return currentModel === snapshot.rootPrimary ? terminal(state, "reverted", currentModel) : terminal(state, "suspended-by-user", currentModel); const next = { ...state, activeModel: currentModel, deferredProbe: undefined }, kind: ProbeKind = next.substate === "discriminating" ? "early" : "scheduled"; if (options.probeDue) { if (next.busy) return { state: { ...next, deferredProbe: { kind, generation: next.generation } }, effects: [] }; return { state: { ...next, nextDueAt: undefined }, effects: [{ type: "startProbe", kind, generation: next.generation, substate: next.substate! }] }; } // C1: the effect must carry the episode's own reset, because `upgradeSegments` has (correctly) moved // every version-1 segment reset onto the ledger. Without it a v1 snapshot with no persisted // `nextDueAt` re-armed at now + intervalMin and forgot a reset the episode already knew. const resetsAt = episodeResetsAt(next); return { state: next, effects: [{ type: "scheduleProbe", kind, generation: next.generation, substate: next.substate!, ...(next.nextDueAt === undefined ? {} : { dueAt: next.nextDueAt }), ...(finite(resetsAt) ? { resetsAt } : {}) }] }; }