/** * Escalation chooser (P5c) -- the READER of {@link PolicyNode}. * * P2 landed `PolicyNode` as written-data; this module is its reader: given a * policy and the runtime site a node will be admitted on, it chooses the * MINIMAL capability rung ({@link CapTier}, lowest `Cap.ordinal`) that * satisfies the policy's `requires`, fits its `budgets`, lies inside its * `grants`, and still admits the projection targets the rung gates. * * Determinism: minimal-rung by `Cap.ordinal` ascending; ties (which the total * `CapTier` order makes impossible, but we keep the rule explicit so the * contract survives future lattice changes) break by the Astro directive * escalation order `satellite < stream < llm < worker < gpu < wasm`. `@czap/core` * cannot import `@czap/astro`, so that order is encoded locally below. * * Cycle discipline: the CapTier-to-target admissibility table PROJECTS from the * shared {@link LADDER_TARGETS} datum (`cap-ladder.ts`). We deliberately do NOT * import `TIER_TARGETS` from `@czap/quantizer`: the quantizer depends on core, * so core importing the quantizer would close a dependency cycle. Instead both * `RUNG_TARGETS` here and the quantizer's `TIER_TARGETS` are projections of the * SAME index-keyed ladder — one source, no drift (see `cap-ladder.ts`). * * @module */ import type { PolicyNode, RuntimeSite } from './document-graph.js'; import type { CapTier } from './caps.js'; import { Cap } from './caps.js'; import { projectLadder } from './cap-ladder.js'; import type { LadderTarget } from './cap-ladder.js'; /** A projection target the escalation gate may admit (subset of `ProjectionNode.target`). */ type ProjectionTarget = LadderTarget; /** * CapTier to admissible projection targets — a PROJECTION of the shared * {@link LADDER_TARGETS} ladder onto the `CapTier` rung order (`cap-ladder.ts`). * The quantizer's `TIER_TARGETS` projects the same ladder onto the `MotionTier` * order; a congruence guard pins them congruent. Each rung is a non-strict * superset of the one below (`styled` == `reactive` admit the same targets). */ const RUNG_TARGETS: Record> = projectLadder([ 'static', 'styled', 'reactive', 'animated', 'gpu', ]); /** * Immutable view of a rung's admissible targets. The raw `RUNG_TARGETS` table is * module-PRIVATE on purpose: it holds mutable `Set`s, and `@czap/core` publishes * wildcard subpaths (`./*`), so exporting it would let any consumer reach * `@czap/core/escalation` and `.clear()`/`.add()` the escalation lattice * process-wide. This returns a fresh copy each call. */ export function rungTargets(rung: CapTier): ReadonlySet { return new Set(RUNG_TARGETS[rung]); } /** * The Astro directive escalation order, encoded locally (core cannot import * `@czap/astro`). Used ONLY as the deterministic tiebreak after `Cap.ordinal`. * Each `CapTier` is mapped to the directive whose capability ceiling it * matches, so the tiebreak stays a single total order. */ const DIRECTIVE_ORDER: readonly string[] = ['satellite', 'stream', 'llm', 'worker', 'gpu', 'wasm']; const RUNG_DIRECTIVE: Record = { static: 'satellite', styled: 'stream', reactive: 'llm', animated: 'worker', gpu: 'gpu', }; const directiveRank = (rung: CapTier): number => DIRECTIVE_ORDER.indexOf(RUNG_DIRECTIVE[rung]); /** * Minimal p95 latency budget (ms) each rung needs to run. A policy `budgets.p95Ms` * below a rung's floor forces a downgrade to a cheaper rung. Higher rungs cost * strictly more, so the floors are monotone in `Cap.ordinal`. */ const RUNG_P95_FLOOR_MS: Record = { static: 0, styled: 1, reactive: 4, animated: 8, gpu: 16, }; /** * Minimal working-set budget (MB) each rung needs. A policy `budgets.memoryMb` * below a rung's floor forces a downgrade. Monotone in `Cap.ordinal`. */ const RUNG_MEMORY_FLOOR_MB: Record = { static: 0, styled: 1, reactive: 2, animated: 8, gpu: 64, }; /** All rungs, ascending by `Cap.ordinal` then directive order -- the canonical search axis. */ const RUNGS_ASCENDING: readonly CapTier[] = (['static', 'styled', 'reactive', 'animated', 'gpu'] as const) .slice() .sort((a, b) => Cap.ordinal(a) - Cap.ordinal(b) || directiveRank(a) - directiveRank(b)); /** A budget candidate rung fits if it clears every declared budget floor. */ function budgetAdmits(rung: CapTier, budgets: PolicyNode['budgets']): boolean { if (budgets === undefined) return true; if (budgets.p95Ms !== undefined && budgets.p95Ms < RUNG_P95_FLOOR_MS[rung]) return false; if (budgets.memoryMb !== undefined && budgets.memoryMb < RUNG_MEMORY_FLOOR_MB[rung]) return false; // `allocClass: 'zero'` forbids the heap-hungry GPU rung; 'bounded'/'unbounded' admit all. if (budgets.allocClass === 'zero' && rung === 'gpu') return false; return true; } /** The successful chooser verdict. */ export interface RungChoice { /** The minimal {@link CapTier} satisfying site, budget, grants, and admissibility. */ readonly rung: CapTier; /** The projection targets that rung admits, intersected with the rung's table. */ readonly admittedTargets: ReadonlySet; } /** The chooser result: a verdict or an unsatisfiability reason. */ export type EscalationResult = RungChoice | { readonly error: string }; const memo = new Map(); /** * Choose the minimal capability rung a {@link PolicyNode} admits on a runtime site. * * Returns `{ rung, admittedTargets }` on success, or `{ error }` if the site is * not in `policy.sites` or no rung at or below `policy.requires` clears the * budgets/grants. Memoized by `policy.id + runtimeSite` (a policy id is its * `fnv1a` content address, so equal inputs return a stable reference). * * @param policy - The capability/constraint gate to read. * @param runtimeSite - The site the gated node will be admitted on. */ export function chooseRung(policy: PolicyNode, runtimeSite: RuntimeSite): EscalationResult { // `|` cannot appear in a `fnv1a:`-prefixed ContentAddress, so it is an // unambiguous separator between the policy id and the runtime site. const key = `${policy.id}|${runtimeSite}`; const cached = memo.get(key); if (cached !== undefined) return isolate(cached); const result = compute(policy, runtimeSite); memo.set(key, result); return isolate(result); } /** * Return an ISOLATED copy of a memoized verdict — a FRESH `admittedTargets` Set — * so a caller mutating the returned result can never pollute the process-global * memo (a later memo hit would otherwise hand back the mutated Set). The `{error}` * branch is an immutable string payload, returned as-is. */ function isolate(result: EscalationResult): EscalationResult { return 'error' in result ? result : { rung: result.rung, admittedTargets: new Set(result.admittedTargets) }; } function compute(policy: PolicyNode, runtimeSite: RuntimeSite): EscalationResult { // (1) Site gate -- a policy that does not list this site is unsatisfiable here. if (!policy.sites.includes(runtimeSite)) { return { error: `policy ${policy.id} does not admit runtime site '${runtimeSite}' (admits: ${policy.sites.join(', ') || 'none'})`, }; } // (2) Start AT `policy.requires` -- the required rung is the candidate, not a // ceiling to search far below. The chooser only DOWNGRADES from here, and only // as far as the budgets/grants force; it never escalates above `requires`. // "Minimal CapTier satisfying all" is therefore the highest rung at or below // `requires` that every gate admits -- equivalently, `requires` downgraded the // least. We walk rungs DESCENDING from `requires` and take the FIRST that // clears budgets, grants, and admissibility. const ceiling = Cap.ordinal(policy.requires); // Candidates: at or below `requires`, descending (closest-to-requires first), // tiebroken by the directive order (a no-op under the total CapTier order, // kept explicit so the contract survives future lattice changes). const candidates = RUNGS_ASCENDING.filter((rung) => Cap.ordinal(rung) <= ceiling) .slice() .reverse(); for (const rung of candidates) { // (3) Budget gate -- skip (downgrade past) rungs the budget cannot afford. if (!budgetAdmits(rung, policy.budgets)) continue; // (4) Grants gate -- the policy must have granted the rung. if (!Cap.has(policy.grants, rung)) continue; // (5) Admissibility -- confirm the rung admits at least one projection target // (an empty admissible set gates nothing, so it is not a real verdict). // `RUNG_TARGETS` is the locally-encoded CapTier<->target map (no quantizer // import -- see module note). const rungTargets = RUNG_TARGETS[rung]; if (rungTargets.size === 0) continue; // (6) Minimal downgrade wins: first satisfying rung walking down from requires. // Return a COPY, never the shared RUNG_TARGETS Set by reference — the result // is memoized, so a caller mutating it would corrupt the const + the cache. const admittedTargets: ReadonlySet = new Set(rungTargets); return { rung, admittedTargets }; } return { error: `policy ${policy.id} admits no rung at or below '${policy.requires}' on '${runtimeSite}' under its grants/budgets`, }; } /** Test-only: clear the chooser memo. Not part of the public `@czap/core` surface. */ export function _resetEscalationMemo(): void { memo.clear(); }