Contract — P1.1.3 BPS Constants + Overflow Protection (R83.B)
1. Scope
Add a single file — src/domains/rules/bps-constants.ts — that layers named
basis-point constants, a branded Bps type, a range-validating bps()
helper, and overflow-guarded re-exports on top of the R81.A surface at
src/domains/rules/integer-math.ts. Plus one net-new test file.
This contract does not authorize edits to integer-math.ts.
2. Modules touched
| Path | Direction | Net LOC | Source of truth |
|---|---|---|---|
src/domains/rules/bps-constants.ts |
create | ≈ 180 | this contract |
src/__tests__/domains/rules/bps-constants.test.ts |
create | ≈ 300 | this contract |
src/domains/rules/integer-math.ts |
must not change | 0 | R81.A PR #173 (sealed) |
3. Invariants (must all hold at merge)
- I1 All exported numeric values are
bigintliterals (100n, notBigInt(100), not100).as constnarrows their types so no caller can widen them. - I2 No function in this module performs I/O, throws synchronously on purpose-only-for-logging, or depends on a clock, RNG, or global mutable state.
- I3
integer-math.tsis imported via the./integer-math.jsextensioned specifier (NodeNext ESM convention already used throughoutsrc/). - I4 Typed errors thrown from this module are instances of the classes
re-exported from
integer-math.ts. Do not define local clones. - I5 Branded
Bpsis a compile-time tag only; at runtimeBps === bigint. - I6
bps(n)refuses every input outside[0n, 10_000n]with a typed error;NaN/Infinity/ non-integernumberraiseTypeError. - I7 100% branch coverage on
bps-constants.ts. - I8 1123 existing tests still pass (R81.A baseline, see MEMORY.md “R81 Wave 1 SEALED PARTIAL”).
- I9 Tool-surface count stays at 14 — no new MCP tool, no registration
in
src/server.ts.
4. Public surface
4.1 Named bigint constants
// 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 (spec AC-1 per task-breakdown.md §P1.1.3)
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 (spec AC-2)
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%
// Int64 boundaries (public mirrors of integer-math internals)
export const MAX_INT64 = 9_223_372_036_854_775_807n; // 2^63 - 1
export const MIN_INT64 = -9_223_372_036_854_775_808n; // -(2^63)
// Bps admissible range (kept separate so tooling can diff against BPS_100_PERCENT)
export const BPS_MIN = 0n;
export const BPS_MAX = 10_000n;
Every value is frozen by const + bigint literal — no callsite can reassign.
4.2 Branded type + validator
export type Bps = bigint & { readonly __brand: 'Bps' };
export function bps(n: bigint | number): Bps;
Behaviour of bps(n):
| Input | Result |
|---|---|
bps(0n) |
returns 0n (tagged Bps) |
bps(10_000n) |
returns 10_000n |
bps(2_500n) |
returns 2_500n |
bps(0) (number) |
returns 0n |
bps(10_000) |
returns 10_000n |
bps(-1n) |
throws UnderflowError("bps: -1 below BPS_MIN (0)") |
bps(-1) |
throws UnderflowError("bps: -1 below BPS_MIN (0)") |
bps(10_001n) |
throws OverflowError("bps: 10001 above BPS_MAX (10000)") |
bps(10_001) |
throws OverflowError("bps: 10001 above BPS_MAX (10000)") |
bps(3.14) |
throws TypeError("bps: 3.14 is not an integer") |
bps(NaN) |
throws TypeError("bps: NaN is not an integer") |
bps(Infinity) |
throws TypeError("bps: Infinity is not an integer") |
bps(-Infinity) |
throws TypeError("bps: -Infinity is not an integer") |
TypeError is the Node built-in (no new class). Error subtypes for the bound
violations are the ones re-exported from integer-math.ts so there is exactly
one OverflowError class in the κ surface.
4.3 Safe-op re-exports
export {
safe_mul,
safe_div,
OverflowError,
DivisionByZeroError,
UnderflowError,
} from './integer-math.js';
Purpose: downstream κ modules (P1.3.2 built-ins, P1.4.3 budget) import
only from bps-constants.ts. They never reach into integer-math.ts
directly. That keeps the surface one hop deep and makes the safe-variant
discovery path trivial.
5. Error contract
Errors thrown by bps():
UnderflowErrorwhen the numeric value is strictly less thanBPS_MIN.OverflowErrorwhen the numeric value is strictly greater thanBPS_MAX.TypeErrorwhen anumberinput is non-finite or non-integer (Number.isSafeInteger(n) === false).
Errors thrown by re-exported ops — identical to R81.A semantics:
safe_mul(a, b)—OverflowErrorwhen the product is outside[MIN_INT64, MAX_INT64].safe_div(a, b)—DivisionByZeroErrorwhenb === 0n.
6. Test acceptance criteria
| # | Criterion | How verified |
|---|---|---|
| AC-1 | Every BPS / DECAY / DAMAGE constant equals the literal in §4.1 | Equality + typeof === 'bigint' |
| AC-2 | MAX_INT64 === 2n ** 63n - 1n and MIN_INT64 === -(2n ** 63n) |
Computed comparison |
| AC-3 | BPS_MIN === 0n and BPS_MAX === BPS_100_PERCENT |
Cross-check |
| AC-4 | bps() accepts every valid bigint in {0n, 2500n, 10_000n} |
Equality on returned value |
| AC-5 | bps() accepts every valid number in {0, 2500, 10000} |
Returned value is bigint |
| AC-6 | bps(-1n) and bps(-1) throw UnderflowError with message matching /below BPS_MIN/ |
toThrow |
| AC-7 | bps(10_001n) and bps(10_001) throw OverflowError with /above BPS_MAX/ |
toThrow |
| AC-8 | bps(3.14), bps(NaN), bps(Infinity), bps(-Infinity) all throw TypeError with /not an integer/ |
toThrow |
| AC-9 | safe_mul re-exported from bps-constants.ts still throws on (2^62, 2^62) |
toThrow(OverflowError) |
| AC-10 | safe_div re-exported still throws on (7n, 0n) |
toThrow(DivisionByZeroError) |
| AC-11 | OverflowError / DivisionByZeroError / UnderflowError re-exported from bps-constants.ts are identical to their integer-math.ts originals (reference equality of class reference) |
const { OverflowError: A } = await import('./bps-constants.js'); const { OverflowError: B } = await import('./integer-math.js'); expect(A).toBe(B); |
| AC-12 | bps_mul(1000n, BPS_50_PERCENT) === 500n — end-to-end wire-up |
Equality |
| AC-13 | 100% branch coverage on bps-constants.ts in the Jest lcov report |
npm test output |
| AC-14 | 1123 pre-existing tests still pass | Full-suite run |
7. Gates
npm run build— clean.npm run lint— clean (eqeqeq,curly,consistent-type-imports).npm test— 1123 + ≈ 45 new tests all green (except pre-existingstartup — subprocess smokeflake, which is unrelated).
8. Out of scope
- Editing
src/domains/rules/integer-math.ts(R81.A sealed). - Registering an MCP tool for any of these constants.
- Wiring the constants into a rule engine (P1.3.2 will).
- Graduating the κ concept doc’s
colibri_codefrontmatter (staysnoneuntil the evaluator ships). - Generics / template-typed constants —
Bpsis the only branded type here.
9. Definition of done
- Both files land on
feature/r83-b-bps-constantsvia commits on the 5-step chain (audit → contract → packet → impl → verify). - All three gates green.
- PR opened against
mainwith writeback block referencing R83 manifest PR #187.