From 11a42e8b23f280f8f1ffd9072cead3838688359a Mon Sep 17 00:00:00 2001 From: real-venus Date: Wed, 8 Jul 2026 16:08:08 -0700 Subject: [PATCH] feat(governor): add budget/turn/termination cap calculator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a pure evaluateGovernorCaps(usage, limits) to the engine's governor module, a sibling to rate-limit.ts. It evaluates three independent cumulative ceilings — budget/cost, turns, and elapsed session time (termination) — and combines them into one verdict drawn from GOVERNOR_LEDGER_EVENT_TYPES (allowed / denied / kill_switch), never a parallel vocabulary. Pure and side-effect-free: no IO, no clock read (elapsed time and usage are caller-supplied), no state, no enforcement. Every numeric input is normalized so a non-finite, negative, or fractional value can never yield a NaN verdict or a negative remaining value. Re-exported from the package entrypoint alongside rate-limit. The fail-closed enforcement chokepoint that composes this with rate-limit and the non-convergence detector is separate, maintainer-owned work. Closes #4288 --- .../src/governor/budget-cap.ts | 93 +++++++++++++++++++ packages/gittensory-engine/src/index.ts | 1 + test/unit/governor-budget-cap.test.ts | 66 +++++++++++++ 3 files changed, 160 insertions(+) create mode 100644 packages/gittensory-engine/src/governor/budget-cap.ts create mode 100644 test/unit/governor-budget-cap.test.ts diff --git a/packages/gittensory-engine/src/governor/budget-cap.ts b/packages/gittensory-engine/src/governor/budget-cap.ts new file mode 100644 index 0000000000..8b1160d581 --- /dev/null +++ b/packages/gittensory-engine/src/governor/budget-cap.ts @@ -0,0 +1,93 @@ +// Governor budget/turn/termination cap calculator (pure). +// Deterministic, side-effect-free math for the local Governor. Given a run's cumulative usage snapshot and a +// set of ceilings it decides, per dimension, whether a cap has been reached and combines the three into one +// verdict. A SIBLING to ./rate-limit.ts, not built on top of it: rate-limit measures a rolling-WINDOW request +// rate that resets, whereas these caps are cumulative, monotonic counters across a whole run (total budget +// spent, total turns taken, elapsed session time vs. a termination ceiling) — a different shape of math with +// no window to reset. Like rate-limit.ts, this module computes numbers only: it does NOT store state, read a +// clock (elapsed time and usage are caller-supplied, exactly like rate-limit's injected `nowMs`), schedule +// anything, or gate any write action. The actual fail-closed enforcement chokepoint that composes this +// calculator with rate-limit and the non-convergence detector is separate, maintainer-owned work (#2340); this +// module only produces one of the verdicts that chokepoint (and the governor-ledger) will later consume. +import type { GovernorLedgerEventType } from "../governor-ledger.js"; + +/** The three independent ceilings for a whole run. A dimension with a ceiling of 0 permits no usage at all + * (any usage reaches it), mirroring rate-limit.ts treating `limit: 0` as "nothing allowed". */ +export type GovernorCapLimits = { + /** Maximum cumulative budget/cost units permitted for the run (may be fractional, e.g. a dollar cost). */ + budget: number; + /** Maximum cumulative turns/iterations permitted for the run (whole counts). */ + turns: number; + /** Termination ceiling: maximum elapsed session time in milliseconds. */ + elapsedMs: number; +}; + +/** A run's cumulative usage so far. Caller-supplied — this module never reads a clock or a meter itself. */ +export type GovernorCapUsage = { + /** Budget/cost already spent this run. */ + budgetSpent: number; + /** Turns/iterations already taken this run. */ + turnsTaken: number; + /** Elapsed session time so far in milliseconds. */ + elapsedMs: number; +}; + +/** One dimension's evaluation. `remaining` is headroom before the ceiling and is never negative. */ +export type GovernorCapDimension = { + /** The normalized ceiling for this dimension. */ + limit: number; + /** The normalized usage measured against that ceiling. */ + used: number; + /** Headroom left before the ceiling (0 once reached; never negative). */ + remaining: number; + /** True once usage has reached OR passed the ceiling. */ + exceeded: boolean; +}; + +/** The combined report. `verdict` is drawn from GOVERNOR_LEDGER_EVENT_TYPES (not a parallel vocabulary) so it + * aligns with the events the governor-ledger records: `allowed` (all caps clear), `denied` (a budget/turn cap + * reached), or `kill_switch` (the termination ceiling reached — a hard wall-clock stop). */ +export type GovernorCapReport = { + verdict: GovernorLedgerEventType; + budget: GovernorCapDimension; + turns: GovernorCapDimension; + termination: GovernorCapDimension; +}; + +// Normalize any numeric input to a finite, non-negative value (a non-finite or negative value becomes 0), so no +// input can make a verdict NaN or a remaining value negative. Mirrors rate-limit.ts's finiteNonNegativeInt but +// keeps fractional precision for continuous dimensions (budget cost, elapsed milliseconds). +function finiteNonNegative(value: number): number { + return Number.isFinite(value) ? Math.max(0, value) : 0; +} + +// Integer variant for the turn-count dimension (turns are whole iterations), matching rate-limit.ts exactly. +function finiteNonNegativeInt(value: number): number { + return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0; +} + +// Evaluate one dimension: usage reaching or passing the ceiling is `exceeded`, and headroom is clamped at 0 so +// it can never go negative. Both operands are already normalized by the caller. +function evaluateDimension(used: number, limit: number): GovernorCapDimension { + return { limit, used, remaining: Math.max(0, limit - used), exceeded: used >= limit }; +} + +/** + * Evaluate a run's cumulative usage against its budget/turn/termination ceilings. Pure: it reads the two typed + * inputs and returns a report without mutating anything or reading a clock. Each dimension is normalized and + * evaluated independently, then combined into one verdict — termination (a hard wall-clock ceiling) is the most + * severe (`kill_switch`), a reached budget or turn ceiling is `denied`, and everything clear is `allowed`. + * Every numeric input is normalized first, so a non-finite, negative, or fractional value can never produce a + * NaN verdict or a negative remaining-budget/turns value. + */ +export function evaluateGovernorCaps(usage: GovernorCapUsage, limits: GovernorCapLimits): GovernorCapReport { + const budget = evaluateDimension(finiteNonNegative(usage.budgetSpent), finiteNonNegative(limits.budget)); + const turns = evaluateDimension(finiteNonNegativeInt(usage.turnsTaken), finiteNonNegativeInt(limits.turns)); + const termination = evaluateDimension(finiteNonNegative(usage.elapsedMs), finiteNonNegative(limits.elapsedMs)); + const verdict: GovernorLedgerEventType = termination.exceeded + ? "kill_switch" + : budget.exceeded || turns.exceeded + ? "denied" + : "allowed"; + return { verdict, budget, turns, termination }; +} diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 617d471a1c..dcd0998617 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -129,6 +129,7 @@ export { type TrackRecordTenure, } from "./track-record-summary.js"; export * from "./governor/rate-limit.js"; +export * from "./governor/budget-cap.js"; export { GOVERNOR_LEDGER_EVENT_TYPES, normalizeGovernorLedgerEvent, diff --git a/test/unit/governor-budget-cap.test.ts b/test/unit/governor-budget-cap.test.ts new file mode 100644 index 0000000000..811a61d71a --- /dev/null +++ b/test/unit/governor-budget-cap.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { + evaluateGovernorCaps, + type GovernorCapLimits, + type GovernorCapUsage, +} from "../../packages/gittensory-engine/src/governor/budget-cap"; + +// A generous set of ceilings; individual tests push one dimension past its cap. +const LIMITS: GovernorCapLimits = { budget: 100, turns: 20, elapsedMs: 60_000 }; +const UNDER: GovernorCapUsage = { budgetSpent: 10, turnsTaken: 2, elapsedMs: 5_000 }; + +describe("evaluateGovernorCaps", () => { + it("allows a run that is under every cap", () => { + const r = evaluateGovernorCaps(UNDER, LIMITS); + expect(r.verdict).toBe("allowed"); + expect(r.budget).toEqual({ limit: 100, used: 10, remaining: 90, exceeded: false }); + expect(r.turns).toEqual({ limit: 20, used: 2, remaining: 18, exceeded: false }); + expect(r.termination).toEqual({ limit: 60_000, used: 5_000, remaining: 55_000, exceeded: false }); + }); + + it("denies when the budget cap is reached (at cap counts as exceeded, remaining clamps to 0)", () => { + const r = evaluateGovernorCaps({ ...UNDER, budgetSpent: 100 }, LIMITS); + expect(r.verdict).toBe("denied"); + expect(r.budget).toMatchObject({ used: 100, remaining: 0, exceeded: true }); + expect(r.turns.exceeded).toBe(false); + expect(r.termination.exceeded).toBe(false); + }); + + it("denies when only the turn cap is exceeded (covers the right side of the budget||turns test)", () => { + const r = evaluateGovernorCaps({ ...UNDER, turnsTaken: 25 }, LIMITS); + expect(r.verdict).toBe("denied"); + expect(r.budget.exceeded).toBe(false); + expect(r.turns).toMatchObject({ used: 25, remaining: 0, exceeded: true }); + }); + + it("returns kill_switch when the termination ceiling is reached, even if budget/turns are also over", () => { + const r = evaluateGovernorCaps({ budgetSpent: 999, turnsTaken: 999, elapsedMs: 60_000 }, LIMITS); + expect(r.verdict).toBe("kill_switch"); + expect(r.termination).toMatchObject({ used: 60_000, remaining: 0, exceeded: true }); + }); + + it("normalizes non-finite and negative inputs to 0 so no verdict is NaN or negative", () => { + // Non-finite usage/limits exercise the non-finite arm of both normalizers; a negative value the clamp arm. + const r = evaluateGovernorCaps( + { budgetSpent: Number.NaN, turnsTaken: -5, elapsedMs: Number.POSITIVE_INFINITY }, + { budget: Number.POSITIVE_INFINITY, turns: Number.NaN, elapsedMs: -1 }, + ); + // budget: used 0 (NaN→0), limit 0 (Infinity→0) ⇒ 0 >= 0 ⇒ exceeded. + expect(r.budget).toEqual({ limit: 0, used: 0, remaining: 0, exceeded: true }); + // turns: used 0 (-5→0), limit 0 (NaN→0) ⇒ exceeded. + expect(r.turns).toEqual({ limit: 0, used: 0, remaining: 0, exceeded: true }); + // termination: used 0 (Infinity→0), limit 0 (-1→0) ⇒ exceeded ⇒ kill_switch wins. + expect(r.termination).toEqual({ limit: 0, used: 0, remaining: 0, exceeded: true }); + expect(r.verdict).toBe("kill_switch"); + expect(Number.isFinite(r.budget.remaining)).toBe(true); + expect(r.turns.remaining).toBeGreaterThanOrEqual(0); + }); + + it("floors a fractional turn count while keeping fractional budget/elapsed precision", () => { + const r = evaluateGovernorCaps({ budgetSpent: 12.5, turnsTaken: 3.9, elapsedMs: 1_500.25 }, LIMITS); + expect(r.turns.used).toBe(3); // floored + expect(r.budget.used).toBe(12.5); // continuous precision retained + expect(r.termination.used).toBe(1_500.25); + expect(r.verdict).toBe("allowed"); + }); +});