Audit — P1.1.3 BPS Constants + Overflow Protection (R83.B)

Surface inventory

Target directory: src/domains/rules/ Parent module (shipped): src/domains/rules/integer-math.ts (R81.A, PR #173, landed on main at b88d7ca0).

$ ls src/domains/rules/
integer-math.ts

Test-suite counterpart on main: src/__tests__/domains/rules/integer-math.test.ts (100% branch coverage; +38 tests at R81.A, baseline now 1123 per MEMORY.md).

Source of authority

  1. docs/guides/implementation/task-prompts/p1.1-kappa-rule-engine.md §P1.1.3 (lines 320–449) — authoritative prompt. Lists files, constants, acceptance criteria, writeback, gotchas.
  2. docs/guides/implementation/task-breakdown.md §P1.1.3 (lines 494–505).
  3. docs/reference/extractions/kappa-rule-engine-extraction.md §3 (BPS Constants) + §4 (Overflow Protection).
  4. docs/3-world/physics/laws/rule-engine.md §Integer-only arithmetic + §Basis-point arithmetic.
  5. src/domains/rules/integer-math.ts — R81.A shipped file; must not be edited (CLAUDE.md §3 forbids re-touching shipped code outside the authorizing task).
  6. src/__tests__/domains/rules/integer-math.test.ts — style reference for the new test file.
  7. CLAUDE.md §3, §5, §6, §7, §8; docs/agents/executor-contract.md.

What is already in integer-math.ts (R81.A surface)

Exports

// classes
export class OverflowError extends Error { /* name = 'OverflowError' */ }
export class DivisionByZeroError extends Error { /* name = 'DivisionByZeroError' */ }
export class UnderflowError extends Error { /* name = 'UnderflowError' */ }

// functions
export function bps_mul(value: bigint, bps: bigint): bigint
export function bps_div(value: bigint, bps: bigint): bigint         // throws DivisionByZeroError on bps === 0n
export function apply_bps(value: bigint, bps: bigint): bigint
export function decay(value: bigint, rate_bps: bigint, epochs: bigint): bigint  // throws UnderflowError on epochs < 0n
export function safe_mul(a: bigint, b: bigint): bigint              // throws OverflowError outside INT64 range
export function safe_div(a: bigint, b: bigint): bigint              // throws DivisionByZeroError on b === 0n

Not exported (module-private constants)

const BPS_DENOMINATOR = 10_000n;
const INT64_MAX       = 9_223_372_036_854_775_807n;   // 2^63 - 1
const INT64_MIN       = -9_223_372_036_854_775_808n;  // -(2^63)

What is missing (P1.1.3 scope)

  1. Named basis-point constants as public exports (BPS_100_PERCENT, BPS_50_PERCENT, BPS_1_PERCENT).
  2. Five domain decay rates (DECAY_EXECUTION, DECAY_COMMISSIONING, DECAY_ARBITRATION, DECAY_GOVERNANCE, DECAY_SOCIAL).
  3. Five penalty constants (DAMAGE_MINOR, DAMAGE_MODERATE, DAMAGE_SEVERE, DAMAGE_CRITICAL, DAMAGE_FRAUD).
  4. Public MAX_INT64 / MIN_INT64 re-exports so downstream modules can compute ceilings without recomputing 2n ** 63n.
  5. Branded Bps type plus a bps() runtime validator that refuses out-of-range inputs.
  6. Re-exports / thin wrappers for safe_mul and safe_div so consumers have a single import surface for “I want the overflow-guarded variant.”

File plan

Two files. Both net-new.

Path Role Est. LOC
src/domains/rules/bps-constants.ts Named bigint constants + branded Bps type + bps() validator + safe-op re-exports ~180
src/__tests__/domains/rules/bps-constants.test.ts 100% branch coverage suite ~300

Test-file location: follows CLAUDE.md §9.1 + the R81.A audit precedent. The P1.1.1 audit (docs/audits/r81-a-p1-1-1-integer-math-audit.md lines 60-71) documented that the pre-authored prompt writes src/domains/rules/__tests__/..., but every one of the 1123 shipped tests lives at src/__tests__/<mirror>/.... This audit carries forward the R81.A decision — tests land at src/__tests__/domains/rules/bps-constants.test.ts.

API surface (to ship)

Named bigint constants (12 total — matching §P1.1.3 AC line-by-line)

// BPS fractions
export const BPS_100_PERCENT = 10_000n;
export const BPS_50_PERCENT  = 5_000n;
export const BPS_1_PERCENT   = 100n;

// Domain decay rates
export const DECAY_EXECUTION     = 500n;   // 5.00%
export const DECAY_COMMISSIONING = 300n;   // 3.00%
export const DECAY_ARBITRATION   = 1_000n; // 10.00%
export const DECAY_GOVERNANCE    = 200n;   // 2.00%
export const DECAY_SOCIAL        = 100n;   // 1.00%

// Damage / penalty tiers
export const DAMAGE_MINOR    = 1_500n;   // 15%
export const DAMAGE_MODERATE = 3_000n;   // 30%
export const DAMAGE_SEVERE   = 5_000n;   // 50%
export const DAMAGE_CRITICAL = 8_000n;   // 80%
export const DAMAGE_FRAUD    = 10_000n;  // 100%

// Range ceilings (public mirrors of integer-math internal constants)
export const MAX_INT64 = 9_223_372_036_854_775_807n;
export const MIN_INT64 = -9_223_372_036_854_775_808n;

// BPS range for validator / branded type
export const BPS_MIN = 0n;
export const BPS_MAX = 10_000n;

Branded Bps type + validator

export type Bps = bigint & { readonly __brand: 'Bps' };

export function bps(n: bigint | number): Bps;  // throws UnderflowError when <0, OverflowError when >BPS_MAX,
                                                 // TypeError when non-finite number

Safe-op re-exports

export { safe_mul, safe_div, OverflowError, DivisionByZeroError, UnderflowError }
  from './integer-math.js';

This turns bps-constants.ts into the single import surface that P1.3.2 (built-ins) and P1.4.3 (budget module) will consume — no downstream task has to touch integer-math.ts directly to get typed errors.

Test strategy

Harness: Jest 29 (ts-jest ESM). No new dev deps.

Coverage target: 100% branches on bps-constants.ts. jest.config.ts already runs collectCoverage: true; the verification step reads the lcov report.

Test-case matrix:

Group Cases Purpose
BPS fractions 3 constants × (value + bigint typeof) Pin values to spec
Domain decay rates 5 constants × (value + bigint typeof) Pin values to spec
Damage tiers 5 constants × (value + bigint typeof) Pin values to spec
Range ceilings MAX_INT64 === 2n**63n - 1n, MIN_INT64 === -(2n**63n), BPS_MIN, BPS_MAX Cross-check arithmetic
bps() accepts bigint in range bps(0n), bps(10_000n), bps(5_000n) Happy path
bps() accepts number in range bps(0), bps(10000), bps(2500) Number coercion
bps() rejects negative bigint bps(-1n) → UnderflowError Lower bound
bps() rejects bigint > 10000 bps(10_001n) → OverflowError Upper bound
bps() rejects negative number bps(-1) → UnderflowError Lower bound (number path)
bps() rejects number > 10000 bps(10_001) → OverflowError Upper bound (number path)
bps() rejects NaN bps(NaN) → TypeError Non-finite guard
bps() rejects Infinity bps(Infinity) → TypeError Non-finite guard
bps() rejects non-integer number bps(3.14) → TypeError Integer discipline
bps() brand flow bps(5000n) type-asserts to Bps; returns the same value Brand preserved
Re-exports safe_mul, safe_div, 3 error classes re-exportable from new module Import surface
Re-exported safe_mul still throws safe_mul(TWO_POW_62, TWO_POW_62) throws OverflowError Behaviour preserved
Re-exported safe_div still throws safe_div(7n, 0n) throws DivisionByZeroError Behaviour preserved
Constant immutability Re-assignment in a module-level test does not compile (documented via // @ts-expect-error) Narrative proof
Cross-product sanity bps_mul(1000n, BPS_50_PERCENT) === 500n via re-imported bps_mul End-to-end wire-up

Expected test count: ~45 it blocks → +45 tests. Brings suite from 1123 → ~1168.

Integration / side-effects

  • No DB writes. Pure library.
  • No MCP tool registration. src/server.ts untouched. Tool-surface stays at 14 (MEMORY.md frozen).
  • No new deps. No package.json change.
  • integer-math.ts is not edited. Writeback-hard-block is non-issue — no β task work; this is a pure κ library drop.
  • No ADR change. ADR-006 (DSL grammar) already covers the κ arithmetic discipline.
  • No concept-doc graduation. κ concept stays colibri_code: none until the evaluator loop ships (P1.3.1 at earliest).

Gate expectations

  • npm run build — clean. target: ES2022 + NodeNext; bigint literals and branded types compile natively.
  • npm run lint — clean. eqeqeq: error, curly: all, consistent-type-imports: error. Use type keyword on type-only imports.
  • npm test — 1123 existing + ~45 new ≈ 1168. One known flake: startup — subprocess smoke (pre-existing, unrelated).

Rollback

Single-commit revert is trivial — every file is net-new and nothing yet imports bps-constants.ts. git revert <impl-sha> leaves integer-math.ts and its 38 R81.A tests untouched.

Unblocks

  • P1.3.2 (Built-in functions) — min, max, cap, decay, bps_mul, bps_div wrappers will import named constants from here.
  • P1.4.3 (Budget module) — consumes DAMAGE_* tiers directly.

Done-criteria

  • Parent module integer-math.ts inventoried; exported + private surface mapped.
  • Missing surface list established (12 constants + Bps type + bps() + safe re-exports).
  • File plan fixed: 2 files, ~480 LOC total; tests follow CLAUDE.md §9.1 layout.
  • Test-case matrix drafted (18 groups, ~45 tests).
  • Integration surface confirmed (zero wiring; pure lib).
  • Rollback plan defined.
  • No forbidden edits planned (integer-math.ts stays read-only).

Back to top

Colibri — documentation-first MCP runtime. Apache 2.0 + Commons Clause.

This site uses Just the Docs, a documentation theme for Jekyll.