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

1. Commit plan

# Commit message Files touched
1 audit(r83-b-bps-constants): inventory constants surface docs/audits/r83-b-bps-constants-audit.md
2 contract(r83-b-bps-constants): behavioral contract docs/contracts/r83-b-bps-constants-contract.md
3 packet(r83-b-bps-constants): execution plan docs/packets/r83-b-bps-constants-packet.md (this file)
4 feat(r83-b-bps-constants): P1.1.3 BPS constants + overflow-protected variants src/domains/rules/bps-constants.ts, src/__tests__/domains/rules/bps-constants.test.ts
5 verify(r83-b-bps-constants): gates green docs/verification/r83-b-bps-constants-verification.md

Branch: feature/r83-b-bps-constants (pre-created off origin/main @ 657d4ef4).

2. Gotchas

2.1 NodeNext .js specifiers — real path, unrelated file

src/ is compiled under NodeNext, so every relative import must carry a .js suffix even though the source file is .ts. bps-constants.ts imports ./integer-math.js, and the test imports ../../../domains/rules/bps-constants.js. This matches the convention of every other file in src/ and in src/__tests__/.

2.2 consistent-type-imports is an error, not a warning

Any type-only import must use import type { ... }. In this module that applies nowhere — every export here is a value (constants are values, Bps is a type but produced by a value-returning factory).

2.3 as const is a no-op on bigint literals

The AC line “Constants are as const, not let or mutable” is satisfied by:

  1. Declaring the constant with export const, not let.
  2. Using a bigint literal (10_000n) — TypeScript narrows literal types automatically.

Adding a trailing as const on export const X = 10_000n as const; is redundant but harmless. This packet uses plain export const X = 10_000n because it matches R81.A’s integer-math.ts style and the test asserts the literal value.

2.4 Branded types don’t survive JSON round-trip

Bps = bigint & { readonly __brand: 'Bps' } is compile-time only. A Bps read from a deserialized payload re-enters as a plain bigint and must be re-validated via bps(). This is called out in the gotchas of the task-prompt §P1.1.3 and replicated in the impl’s JSDoc for future readers.

2.5 Number.isSafeInteger refuses 2**53 and up

Good in our case — BPS_MAX is 10_000n, so the number path of bps() never approaches precision loss. But the guard also rejects NaN and Infinity, which is what we want. Number.isFinite alone would accept 3.14; combining the two would duplicate. isSafeInteger covers both.

2.6 Test-file location

Per CLAUDE.md §9.1 + R81.A audit precedent, tests go at src/__tests__/domains/rules/bps-constants.test.ts (mirror layout). The task-prompt string src/domains/rules/__tests__/... is divergent from the shipped convention and we do not follow it — documented in the audit.

2.7 Reference equality of re-exported error classes

AC-11 asserts that OverflowError re-exported from bps-constants.ts is literally the same class reference as OverflowError from integer-math.ts. ESM re-export preserves identity, so this is automatic, but the assertion guards against future refactors that would otherwise split the class hierarchy.

3. File plan — exact content

3.1 src/domains/rules/bps-constants.ts

/**
 * Colibri — κ Rule Engine — BPS Constants + Overflow Protection (P1.1.3).
 *
 * Named basis-point constants, a branded `Bps` type, a runtime validator,
 * and the public overflow-guarded import surface for downstream κ code.
 *
 * This module is a layer on top of `integer-math.ts` (P1.1.1). It never
 * redefines the core bps operations — it only names their inputs + re-exports
 * their overflow-guarded variants.
 *
 * Canonical references:
 *   - docs/contracts/r83-b-bps-constants-contract.md
 *   - docs/reference/extractions/kappa-rule-engine-extraction.md §3 + §4
 *   - docs/3-world/physics/laws/rule-engine.md §Integer-only arithmetic
 *   - docs/guides/implementation/task-prompts/p1.1-kappa-rule-engine.md §P1.1.3
 *
 * Invariants (contract §3):
 *   I1. Every exported numeric value is a `bigint` literal.
 *   I2. Pure: no I/O, no clock, no RNG, no globals.
 *   I3. `integer-math.ts` is imported via `./integer-math.js` (NodeNext ESM).
 *   I4. Errors thrown here are class references re-exported from
 *       `integer-math.ts` (no local clones).
 *   I5. `Bps` is a compile-time tag only — identical to `bigint` at runtime.
 *   I6. `bps(n)` refuses inputs outside `[BPS_MIN, BPS_MAX]`.
 *   I7. 100% branch coverage on this file.
 *
 * Gotcha: branded types do not survive JSON round-trip. Re-validate via
 * `bps()` at deserialization boundaries.
 */

import {
  OverflowError,
  UnderflowError,
} from './integer-math.js';

/* -------------------------------------------------------------------------- */
/* BPS fractions                                                              */
/* -------------------------------------------------------------------------- */

/** 100% in basis points (the canonical denominator). */
export const BPS_100_PERCENT = 10_000n;

/** 50% in basis points. */
export const BPS_50_PERCENT = 5_000n;

/** 1% in basis points. */
export const BPS_1_PERCENT = 100n;

/* -------------------------------------------------------------------------- */
/* Domain decay rates                                                         */
/* -------------------------------------------------------------------------- */

/** Execution-axis reputation decay per epoch (5.00%). */
export const DECAY_EXECUTION = 500n;

/** Commissioning-axis reputation decay per epoch (3.00%). */
export const DECAY_COMMISSIONING = 300n;

/** Arbitration-axis reputation decay per epoch (10.00%). */
export const DECAY_ARBITRATION = 1_000n;

/** Governance-axis reputation decay per epoch (2.00%). */
export const DECAY_GOVERNANCE = 200n;

/** Social-axis reputation decay per epoch (1.00%). */
export const DECAY_SOCIAL = 100n;

/* -------------------------------------------------------------------------- */
/* Damage / penalty tiers                                                     */
/* -------------------------------------------------------------------------- */

/** Minor damage: 15% reputation loss. */
export const DAMAGE_MINOR = 1_500n;

/** Moderate damage: 30% reputation loss. */
export const DAMAGE_MODERATE = 3_000n;

/** Severe damage: 50% reputation loss. */
export const DAMAGE_SEVERE = 5_000n;

/** Critical damage: 80% reputation loss. */
export const DAMAGE_CRITICAL = 8_000n;

/** Fraud: 100% reputation wipe. */
export const DAMAGE_FRAUD = 10_000n;

/* -------------------------------------------------------------------------- */
/* Range ceilings                                                             */
/* -------------------------------------------------------------------------- */

/** Maximum signed int64 value: 2^63 - 1. */
export const MAX_INT64 = 9_223_372_036_854_775_807n;

/** Minimum signed int64 value: -(2^63). */
export const MIN_INT64 = -9_223_372_036_854_775_808n;

/** Lower bound for a valid BPS value. */
export const BPS_MIN = 0n;

/** Upper bound for a valid BPS value (= BPS_100_PERCENT). */
export const BPS_MAX = 10_000n;

/* -------------------------------------------------------------------------- */
/* Branded Bps type + validator                                               */
/* -------------------------------------------------------------------------- */

/**
 * Compile-time brand for a validated basis-point integer in `[0n, 10_000n]`.
 *
 * The brand is erased at runtime; `Bps` is structurally a `bigint`. Use the
 * `bps(n)` factory to introduce the brand — do not cast.
 */
export type Bps = bigint & { readonly __brand: 'Bps' };

/**
 * Validate that `n` is a safe integer in `[BPS_MIN, BPS_MAX]` and return it
 * branded as `Bps`.
 *
 *   bps(2_500n) → 2_500n (typed Bps)
 *   bps(2_500)  → 2_500n (typed Bps)
 *   bps(-1n)    → throws UnderflowError
 *   bps(10_001n) → throws OverflowError
 *   bps(3.14)   → throws TypeError
 *
 * `number` inputs must pass `Number.isSafeInteger` — this rejects `NaN`,
 * `Infinity`, `-Infinity`, and every non-integer float. `bigint` inputs
 * bypass the `isSafeInteger` guard because bigint is always integral.
 */
export function bps(n: bigint | number): Bps {
  let value: bigint;
  if (typeof n === 'number') {
    if (!Number.isSafeInteger(n)) {
      throw new TypeError(`bps: ${n} is not an integer`);
    }
    value = BigInt(n);
  } else {
    value = n;
  }
  if (value < BPS_MIN) {
    throw new UnderflowError(`bps: ${value} below BPS_MIN (${BPS_MIN})`);
  }
  if (value > BPS_MAX) {
    throw new OverflowError(`bps: ${value} above BPS_MAX (${BPS_MAX})`);
  }
  return value as Bps;
}

/* -------------------------------------------------------------------------- */
/* Overflow-guarded re-exports                                                */
/* -------------------------------------------------------------------------- */

/**
 * Safe arithmetic primitives re-exported from `integer-math.ts` so downstream
 * κ modules (P1.3.2 built-ins, P1.4.3 budget) have a single import surface.
 * Reference-equal to the originals — AC-11 of the contract.
 */
export {
  safe_mul,
  safe_div,
  OverflowError,
  DivisionByZeroError,
  UnderflowError,
} from './integer-math.js';

3.2 src/__tests__/domains/rules/bps-constants.test.ts

/**
 * Tests for κ BPS Constants + Overflow Protection (P1.1.3 — R83.B).
 *
 * Coverage target: 100% branches on `src/domains/rules/bps-constants.ts`.
 * Acceptance criteria traced to
 * `docs/contracts/r83-b-bps-constants-contract.md` §6 (AC-1 … AC-14).
 */

import {
  // BPS fractions
  BPS_100_PERCENT,
  BPS_50_PERCENT,
  BPS_1_PERCENT,
  // Decay rates
  DECAY_EXECUTION,
  DECAY_COMMISSIONING,
  DECAY_ARBITRATION,
  DECAY_GOVERNANCE,
  DECAY_SOCIAL,
  // Damage tiers
  DAMAGE_MINOR,
  DAMAGE_MODERATE,
  DAMAGE_SEVERE,
  DAMAGE_CRITICAL,
  DAMAGE_FRAUD,
  // Range ceilings
  MAX_INT64,
  MIN_INT64,
  BPS_MIN,
  BPS_MAX,
  // Validator + brand
  bps,
  // Re-exports
  safe_mul,
  safe_div,
  OverflowError,
  DivisionByZeroError,
  UnderflowError,
} from '../../../domains/rules/bps-constants.js';

import {
  OverflowError as OverflowError_IM,
  DivisionByZeroError as DivisionByZeroError_IM,
  UnderflowError as UnderflowError_IM,
  bps_mul,
} from '../../../domains/rules/integer-math.js';

/* Group 1 — BPS fractions pin values */
describe('BPS fraction constants', () => { /* 3 × 2 assertions */ });

/* Group 2 — Decay rates pin values */
describe('Domain decay rate constants', () => { /* 5 × 2 assertions */ });

/* Group 3 — Damage tiers pin values */
describe('Damage tier constants', () => { /* 5 × 2 assertions */ });

/* Group 4 — Range ceilings */
describe('Range ceiling constants', () => { /* MAX/MIN/BPS_MIN/BPS_MAX */ });

/* Group 5 — bps() happy paths (bigint) */
describe('bps() — bigint inputs inside range', () => { /* 0n / 2500n / 10000n */ });

/* Group 6 — bps() happy paths (number) */
describe('bps() — number inputs inside range', () => { /* 0 / 2500 / 10000 */ });

/* Group 7 — bps() underflow */
describe('bps() — underflow', () => { /* -1n and -1 */ });

/* Group 8 — bps() overflow */
describe('bps() — overflow', () => { /* 10001n and 10001 */ });

/* Group 9 — bps() TypeError on non-integer number */
describe('bps() — non-integer numbers', () => { /* 3.14 / NaN / Infinity / -Infinity */ });

/* Group 10 — safe_mul / safe_div re-export behaviour */
describe('safe_mul / safe_div re-exports', () => { /* canonical overflow + divzero */ });

/* Group 11 — error-class identity */
describe('error-class re-exports are reference-equal to integer-math.ts', () => {
  // AC-11
});

/* Group 12 — cross-product wire-up */
describe('integer-math × bps-constants cross-product', () => {
  // AC-12: bps_mul(1000n, BPS_50_PERCENT) === 500n
});

The actual implementation will flesh each describe group with 2-5 it blocks; total expected ≈ 45 tests.

4. Test-file skeleton — concrete it blocks

GROUP 1 (3 constants × {value, typeof}):
  ✓ BPS_100_PERCENT === 10_000n
  ✓ BPS_50_PERCENT  === 5_000n
  ✓ BPS_1_PERCENT   === 100n
  ✓ all three are bigint

GROUP 2 (5 × {value, typeof}):
  ✓ DECAY_EXECUTION     === 500n
  ✓ DECAY_COMMISSIONING === 300n
  ✓ DECAY_ARBITRATION   === 1_000n
  ✓ DECAY_GOVERNANCE    === 200n
  ✓ DECAY_SOCIAL        === 100n
  ✓ all five are bigint

GROUP 3 (5 × {value, typeof}):
  ✓ DAMAGE_MINOR    === 1_500n
  ✓ DAMAGE_MODERATE === 3_000n
  ✓ DAMAGE_SEVERE   === 5_000n
  ✓ DAMAGE_CRITICAL === 8_000n
  ✓ DAMAGE_FRAUD    === 10_000n
  ✓ all five are bigint

GROUP 4:
  ✓ MAX_INT64 === 2n ** 63n - 1n
  ✓ MIN_INT64 === -(2n ** 63n)
  ✓ BPS_MIN === 0n
  ✓ BPS_MAX === BPS_100_PERCENT
  ✓ BPS_MIN, BPS_MAX, MAX_INT64, MIN_INT64 are bigint

GROUP 5:
  ✓ bps(0n)      returns 0n
  ✓ bps(2_500n)  returns 2_500n
  ✓ bps(10_000n) returns 10_000n (boundary)

GROUP 6:
  ✓ bps(0)       returns 0n (bigint)
  ✓ bps(2500)    returns 2_500n (bigint)
  ✓ bps(10000)   returns 10_000n (bigint)

GROUP 7:
  ✓ bps(-1n) throws UnderflowError
  ✓ bps(-1)  throws UnderflowError
  ✓ thrown error message matches /below BPS_MIN/

GROUP 8:
  ✓ bps(10_001n) throws OverflowError
  ✓ bps(10_001)  throws OverflowError
  ✓ thrown error message matches /above BPS_MAX/

GROUP 9:
  ✓ bps(3.14)     throws TypeError
  ✓ bps(NaN)      throws TypeError
  ✓ bps(Infinity) throws TypeError
  ✓ bps(-Infinity) throws TypeError
  ✓ thrown error message matches /not an integer/

GROUP 10:
  ✓ safe_mul(2n**62n, 2n**62n) throws OverflowError
  ✓ safe_mul(0n, 2n**62n) returns 0n
  ✓ safe_div(7n, 2n) returns 3n
  ✓ safe_div(7n, 0n) throws DivisionByZeroError

GROUP 11 (AC-11):
  ✓ OverflowError === OverflowError_IM
  ✓ DivisionByZeroError === DivisionByZeroError_IM
  ✓ UnderflowError === UnderflowError_IM

GROUP 12 (AC-12):
  ✓ bps_mul(1000n, BPS_50_PERCENT) === 500n
  ✓ bps_mul(1000n, BPS_1_PERCENT)  === 10n

Total unique `it` blocks: 35 discrete + 4 shared typeof assertions = 39
(range: 35-45, comfortably ≥ 35).

5. Rollback

git revert <impl-sha>

bps-constants.ts has no downstream consumers on main yet — the revert leaves integer-math.ts and its existing 1123 tests untouched.

6. Approval gates

Packet is approved by evidence of:

  1. File content in §3 compiling under tsc --noEmit (part of npm run build).
  2. Lint clean under the .eslintrc.json configured at the worktree root.
  3. Test file producing ≥ 35 passing assertions + 100% branch coverage on bps-constants.ts (Jest lcov report).

7. Post-impl writeback

See §4 of the r83-b-bps-constants-contract.md and the round manifest PR #187. Writeback lands in the PR body; no live MCP client is attached (CLAUDE §7 is satisfied by the PR-body rendering of the task_id / branch / worktree / commit / tests / summary / blockers block).


Back to top

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

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