import type { ConsumedEntry, EpisodeState, ModelKey } from "./types"; /** The consumed ledger (OD-14) as a NULL-PROTOTYPE record, and the only sanctioned way to build one. * * Carried finding C3: a plain-object ledger inherits `Object.prototype`, so `consumed[key] = entry` for * the key `__proto__` invoked the inherited setter instead of creating an own property. `Object.hasOwn` * then stayed false, every later object spread dropped the key, and the candidate was announced in the * skipped notice while never being persisted — announced, then silently re-selectable. The same * inheritance also made a bare `consumed["constructor"]` read resolve through the prototype. * * Both holes close at the source with `Object.create(null)`. `Object.assign` onto a null-prototype * target can never hit a setter, and a computed key in an object literal (`{ [key]: value }`) always * defines an own property, so the two composition forms below are safe for every key. Because an object * spread of a null-prototype value produces an ordinary object, EVERY construction site must go through * `ledger()` rather than `{ ...consumed }`. */ export type Ledger = Record; export const emptyLedger = (): Ledger => Object.create(null) as Ledger; export const ledger = (...sources: readonly (Readonly | undefined)[]): Ledger => Object.assign(emptyLedger(), ...sources); /** R7: the recovery target set is EXACTLY `{k | consumed[k].state === "entered" && k !== activeModel}`. * A `skipped` candidate was never run on and never exhausted, so it is not entitled to recovery; the * active model needs no recovery because the episode is already running on it. The root primary is a * target for the whole episode — OD-14 seeds it as `entered` when the episode opens, which is the only * reason OD-13's "root primary first" clause can ever fire. * * The ORDER is OD-13's preference order and is what the rotation cursor (OD-4) walks: the root primary, * then the operator's configured order for that primary, then any remaining entitled key in ledger * insertion order. The tail keeps the function total: a mid-episode configuration edit can drop an * already-entered key out of the candidate set without cancelling the entitlement the ledger records, * and an entered tier-2 assistant lands there too. That tail placement IS its correct position and needs * no special case: the assistant is what comes AFTER the operator's order is exhausted, so it must never * outrank the root primary or a substitute, and it is entered last, so ledger insertion order already * puts it last. Pinned by `episode-ledger.test.ts` rather than left to be assumed. */ export function recoveryTargets(state: EpisodeState): readonly ModelKey[] { const ordered: ModelKey[] = []; const push = (key: ModelKey): void => { if (key === state.activeModel || ordered.includes(key)) return; if (Object.hasOwn(state.consumed, key) && state.consumed[key]?.state === "entered") ordered.push(key); }; const root = state.rootPrimary; if (root !== undefined) { push(root); for (const key of state.fallbacks[root]?.substitutes ?? []) push(key); } for (const key of Object.keys(state.consumed)) push(key); return ordered; } /** OD-13 + R9: when more than one recovery target is confirmed usable before either activation is * applied, the winner is the ROOT PRIMARY, else the earliest in the operator's configured order — never * "first verdict to arrive", which is an artifact of scheduling order and would change silently if OD-4's * single probe slot were revisited. `recoveryTargets` is already in exactly that order, so the rule is * "the first target with a confirmed verdict". Filtering through the target set also keeps R9's other * half: a candidate with no successful verdict, and a candidate that is not entitled to recovery at all, * can never be activated by someone else's verdict. The losers are untouched here — they keep their * `entered` ledger entry and stay targets, which is why R9 needs no new state. */ export const preferRecovery = (state: EpisodeState, confirmed: readonly ModelKey[]): ModelKey | undefined => recoveryTargets(state).find((key) => confirmed.includes(key)); /** R9: the ledger-derived half of a decision point's confirmed-usable set — every key whose consumed * entry is `entered` AND carries a durable usable confirmation written by `probe()`'s usable branch. * The probe event's `confirmedUsable` field, where a caller supplies one, is the OTHER contributor: * `probe()` unions the two, so the helper-fed path keeps working unchanged. Presence-only: only `true` * is ever written, so the read is `=== true`. A `skipped` key can never qualify (R7) — the producer * refuses to mark one, and this filter re-asserts it. */ export const confirmedUsableTargets = (state: EpisodeState): readonly ModelKey[] => Object.keys(state.consumed).filter((key) => state.consumed[key]?.state === "entered" && state.consumed[key]?.confirmedUsable === true); /** The reset time that governs the episode's ONE shared probe schedule (OD-4), and the SINGLE home for * that rule. Always the ROOT primary's: the substate this schedule serves (`awaiting-resets_at`) is * itself chosen from this value, and OD-4's one per-episode timer must be keyed off one episode-level * fact — keying it off the rotation's current target would make the delay depend on cursor position. * The top segment wins when it has a value because `schedule()` is its only writer, and `episodeReset` * in `state-machine.ts` admits only a verdict about the ROOT primary — without that gate the rotation * would let any probed substitute's window land here. The ledger supplies what the opening quota error * taught the episode, before any probe has run. Carried finding C1: the version-1 rebuild path read the * segment alone, and `upgradeSegments` had (correctly) stripped it, so the learned reset was lost. */ export const episodeResetsAt = (state: EpisodeState): number | undefined => { const top = state.segments.at(-1)?.resetsAt; if (typeof top === "number" && Number.isFinite(top)) return top; const root = state.rootPrimary; return root === undefined ? undefined : state.consumed[root]?.resetsAt; };