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:
- Declaring the constant with
export const, notlet. - 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:
- File content in §3 compiling under
tsc --noEmit(part ofnpm run build). - Lint clean under the
.eslintrc.jsonconfigured at the worktree root. - 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).