Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions packages/gittensory-engine/src/governor/budget-cap.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
1 change: 1 addition & 0 deletions packages/gittensory-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
66 changes: 66 additions & 0 deletions test/unit/governor-budget-cap.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});