import type { ModelKey } from "../episode/types"; import { MAX_EPISODE_PROBES } from "./limits"; export type RecoverySubstate = "discriminating" | "hourly-probing" | "awaiting-resets_at" | "outage-pacing"; export type ProbeKind = "early" | "scheduled"; export type ProbeOutcome = "usable" | "quota" | "outage" | "unknown"; export type DecisionAction = "wait" | "defer" | "probe" | "schedule" | "revert" | "give-up"; export const EARLY_PROBE_DELAY_MS = 30_000; export const MAX_TIMER_SLICE_MS = 15 * 60 * 1_000; const DEFAULT_INTERVAL_MIN = 60; const DEFAULT_OUTAGE_INTERVAL_MIN = 10; const DEFAULT_RESET_MARGIN_SEC = 60; const DEFAULT_MAX_OUTAGE_PROBES = 6; export interface NextDueInput { now: number; substate: RecoverySubstate; resetsAt?: number; resetMarginSec?: number; intervalMin?: number; outageIntervalMin?: number; } export interface DecisionInput extends NextDueInput { /** OD-4: the candidate this schedule is for. OPAQUE CARGO — `decision()` never reads it, so a probe's * due time can never depend on which model it is aimed at (that would turn OD-4's one shared * per-episode timer into a per-target one). It exists only so the verdict stays attributable (R7). */ target?: ModelKey; kind?: ProbeKind; outcome?: ProbeOutcome; probeCount?: number; maxProbes?: number; consecutiveOutage?: number; maxOutageProbes?: number; dueAt?: number; busy?: boolean; deferred?: boolean; } export interface ScheduleDecision { action: DecisionAction; /** Echoed back from `DecisionInput.target`, unchanged, on every action. */ target?: ModelKey; substate?: RecoverySubstate; nextDueAt?: number; probeCount: number; consecutiveOutage: number; counted: boolean; deferred: boolean; } const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value); const positive = (value: unknown, fallback: number): number => finite(value) && value > 0 ? value : fallback; const nonNegative = (value: unknown, fallback: number): number => finite(value) && value >= 0 ? value : fallback; const integerAtMost = (value: unknown, fallback: number, cap: number): number => Math.min(cap, Math.max(1, Math.floor(positive(value, fallback)))); function time(value: number, name: string): number { if (!finite(value)) throw new RangeError(`${name} must be a finite epoch millisecond value`); return value; } function resetDue(input: NextDueInput): number | undefined { if (!finite(input.resetsAt)) return undefined; const margin = nonNegative(input.resetMarginSec, DEFAULT_RESET_MARGIN_SEC) * 1_000; const due = input.resetsAt + margin; return finite(due) ? Math.max(input.now, due) : undefined; } export function nextDue(input: NextDueInput): number { const now = time(input.now, "now"); if (input.substate === "discriminating") return now + EARLY_PROBE_DELAY_MS; if (input.substate === "awaiting-resets_at" || input.substate === "hourly-probing") { const reset = resetDue({ ...input, now }); if (reset !== undefined) return reset; } if (input.substate === "outage-pacing") return now + positive(input.outageIntervalMin, DEFAULT_OUTAGE_INTERVAL_MIN) * 60_000; return now + positive(input.intervalMin, DEFAULT_INTERVAL_MIN) * 60_000; } /** The one place the opaque target is re-attached, so no construction site below can forget it. */ const carry = (input: DecisionInput): Pick => input.target === undefined ? {} : { target: input.target }; const scheduled = (input: DecisionInput, substate: RecoverySubstate, probeCount: number, consecutiveOutage: number, counted: boolean, resetsAt?: number): ScheduleDecision => ({ action: "schedule", ...carry(input), substate, nextDueAt: nextDue({ ...input, substate, resetsAt }), probeCount, consecutiveOutage, counted, deferred: false, }); export function decision(input: DecisionInput): ScheduleDecision { const now = time(input.now, "now"); const probeCount = Math.max(0, Math.floor(positive(input.probeCount, 0))); const consecutiveOutage = Math.max(0, Math.floor(positive(input.consecutiveOutage, 0))); if (input.outcome === undefined) { const dueAt = input.dueAt === undefined ? nextDue({ ...input, now }) : time(input.dueAt, "dueAt"); if (now < dueAt) return { action: "wait", ...carry(input), substate: input.substate, nextDueAt: dueAt, probeCount, consecutiveOutage, counted: false, deferred: input.deferred === true }; if (input.busy) return { action: "defer", ...carry(input), substate: input.substate, probeCount, consecutiveOutage, counted: false, deferred: true }; return { action: "probe", ...carry(input), substate: input.substate, probeCount, consecutiveOutage, counted: false, deferred: false }; } const early = input.kind === "early" || (input.kind === undefined && input.substate === "discriminating"); const outagePacing = input.substate === "outage-pacing"; const counted = !early && !outagePacing; const nextCount = probeCount + (counted ? 1 : 0); if (input.outcome === "usable") return { action: "revert", ...carry(input), probeCount: nextCount, consecutiveOutage: 0, counted, deferred: false }; if (!early && !outagePacing && nextCount >= integerAtMost(input.maxProbes, MAX_EPISODE_PROBES, MAX_EPISODE_PROBES)) return { action: "give-up", ...carry(input), probeCount: nextCount, consecutiveOutage: 0, counted, deferred: false }; if (early) { if (input.outcome === "outage") return scheduled(input, "outage-pacing", nextCount, 1, counted); if (input.outcome === "unknown") return scheduled(input, "hourly-probing", nextCount, 0, counted); return scheduled(input, finite(input.resetsAt) ? "awaiting-resets_at" : "hourly-probing", nextCount, 0, counted, input.resetsAt); } if (outagePacing && input.outcome === "outage") { const max = integerAtMost(input.maxOutageProbes, DEFAULT_MAX_OUTAGE_PROBES, Number.MAX_SAFE_INTEGER); return consecutiveOutage >= max ? scheduled(input, "hourly-probing", nextCount, 0, counted) : scheduled(input, "outage-pacing", nextCount, consecutiveOutage + 1, counted); } if (input.outcome === "outage") return scheduled(input, "outage-pacing", nextCount, 1, counted); if (input.outcome === "unknown") return scheduled(input, "hourly-probing", nextCount, 0, counted); return scheduled(input, finite(input.resetsAt) ? "awaiting-resets_at" : "hourly-probing", nextCount, 0, counted, input.resetsAt); } export function timerSlice(now: number, dueAt: number, maxSliceMs = MAX_TIMER_SLICE_MS): number { const current = time(now, "now"), due = time(dueAt, "dueAt"), maximum = Math.min(MAX_TIMER_SLICE_MS, positive(maxSliceMs, MAX_TIMER_SLICE_MS)); return due <= current ? 0 : Math.min(maximum, due - current); } export const timerDelay = timerSlice;