Contract — P1.1.1 Basis Point Arithmetic (R81.A)
1. Module identity
- Path:
src/domains/rules/integer-math.ts - Greek letter: κ (kappa) — Rule Engine
- Role: Pure, deterministic, integer-only basis-point arithmetic primitives. Foundation layer for every downstream κ sub-task.
- Visibility: public module inside
src/— intended for internal import fromsrc/domains/rules/**and eventuallysrc/domains/tasks/**(when β’s admission gate wires into κ in Phase 1.5+).
2. Public API
All functions operate on bigint. Never number. This is a hard API invariant:
TypeScript number is 53-bit float under the hood, and precision silently
degrades above 2^53; Colibri’s κ engine cares about 64-bit integer semantics.
2.1. Core BPS operations
/**
* Multiply `value` by a basis-point fraction.
* result = floor(value * bps / 10_000)
* Overflow-safe only for `|value * bps| <= INT64_MAX`.
*/
export function bps_mul(value: bigint, bps: bigint): bigint;
/**
* Divide `value` by a basis-point fraction.
* result = floor(value * 10_000 / bps)
* Throws `DivisionByZeroError` when `bps === 0n`.
*/
export function bps_div(value: bigint, bps: bigint): bigint;
/**
* Apply a basis-point decay: `value - bps_mul(value, bps)`.
* Safe to call with `bps = 0n` (returns `value`) and `bps = 10_000n`
* (returns `0n` for non-negative `value`).
*/
export function apply_bps(value: bigint, bps: bigint): bigint;
/**
* Compound per-epoch basis-point decay.
* for epoch in 0..epochs-1: value = apply_bps(value, rate_bps)
* Each step is floored independently (per concept doc §Basis-point arithmetic);
* compounding drift is therefore deterministic-given-inputs.
*
* Contract:
* - `epochs === 0n` returns `value` unchanged.
* - `epochs < 0n` throws `UnderflowError("decay: negative epochs")`.
* - `rate_bps` outside `[0n, 10_000n]` is permitted by this layer (P1.1.3
* adds clamping); callers using basis points outside the natural range
* get whatever `apply_bps` produces, including negative values when
* `rate_bps > 10_000n` or `rate_bps < 0n`.
*/
export function decay(
value: bigint,
rate_bps: bigint,
epochs: bigint,
): bigint;
2.2. Safe arithmetic primitives
/**
* Multiply two bigints with explicit int64 overflow detection.
* Throws `OverflowError` if the product falls outside `[INT64_MIN, INT64_MAX]`.
*
* This is the int64-bounded variant; bigint itself is unbounded. κ exposes
* this as the runtime safety net because rule-engine outputs must be
* representable in a 64-bit signed integer (extraction §4).
*/
export function safe_mul(a: bigint, b: bigint): bigint;
/**
* Integer divide with explicit divide-by-zero error.
* Semantics: truncate toward zero (the native bigint `/` behavior).
* safe_div( 7n, 2n) === 3n
* safe_div(-7n, 2n) === -3n // not -4n
* safe_div( 7n, -2n) === -3n
* safe_div(-7n, -2n) === 3n
* Throws `DivisionByZeroError` when `b === 0n`.
*
* Extraction §4 mandates truncate-toward-zero for the low-level primitive.
* The BPS ops above use *floor* for positive inputs, which equals truncation
* whenever the dividend is non-negative — the practical κ case.
*/
export function safe_div(a: bigint, b: bigint): bigint;
2.3. Typed errors
/** Thrown when an arithmetic result overflows int64. */
export class OverflowError extends Error {
readonly name = 'OverflowError';
}
/** Thrown on divide-by-zero in any bps/safe helper. */
export class DivisionByZeroError extends Error {
readonly name = 'DivisionByZeroError';
}
/** Thrown on underflow / negative-input conditions (e.g. decay epochs < 0). */
export class UnderflowError extends Error {
readonly name = 'UnderflowError';
}
Each error class sets a stable .name so downstream instanceof checks and
error-message surfaces (e.g. κ RuleBudgetExceeded reasons) can route on
.name without parsing .message.
3. Invariants
I1. Integer-only arithmetic
The module never introduces a number, Math.*, or Date.* reference. All
literals are bigint. All division is bigint /. Lint-safe.
I2. No side effects
- No
console.*calls (beyond test-file scope). - No I/O, no
fs, nofetch, noprocess.*. - No global mutation.
- No singleton state — all functions are referentially transparent.
I3. No async
Every exported function is synchronous. No Promise. No await.
I4. No RNG / no clock
No Math.random(), crypto.randomBytes, crypto.randomInt, Date.now(),
process.hrtime(), performance.now(), or equivalent.
I5. Floor rounding for BPS ops
bps_mul, bps_div, apply_bps, decay round toward floor for
non-negative inputs. Because native BigInt / truncates toward zero
(identical to floor for non-negative quotients), an explicit Math.floor
equivalent is unnecessary when value and bps are non-negative.
Negative-input handling follows native bigint semantics (truncate toward
zero). The concept-doc example table uses only non-negative values, and the
pre-authored prompt’s gotcha #2 explicitly notes κ rule bodies never produce
negatives from a non-negative value, so truncate ≡ floor in practice. This
contract documents the divergence at the negative edge so P1.1.3 can decide
whether to clamp or reject.
I6. int64 boundary enforcement
safe_mul is the only public function that throws on overflow. Callers that
need post-multiplication int64 guarantees must compose through safe_mul
(P1.1.3 consumers will wrap this). bps_mul / bps_div / apply_bps /
decay do not pre-guard for int64 overflow in P1.1.1 — they rely on the
caller keeping |value * bps| below INT64_MAX, which is true for every
Phase 1 κ rule corpus generated in the bps range [0, 10_000] with values up
to ~9.2 × 10^15 (INT64_MAX / 10_000). P1.1.3 lifts this guarantee into a
branded Bps type.
I7. Divide-by-zero is always explicit
bps_div(v, 0n) and safe_div(a, 0n) both throw DivisionByZeroError.
Never silent NaN, never Infinity, never wrap-around.
I8. Deterministic
For all inputs (x, y, ...): two independent calls to any exported function
with structurally equal arguments produce === or structurally equal
outputs. No call-ordering dependence.
4. Dependency rules
4.1. Inbound (downstream consumers)
Declared but not yet present on origin/main:
src/domains/rules/determinism.ts(P1.1.2) — wraps every export under a fuzz harness.src/domains/rules/constants.ts(P1.1.3) — introducesBPS_100_PERCENTetc. and a brandedBps = bigint & {__brand: 'Bps'}type wrapping these functions.src/domains/rules/builtins.ts(P1.3.2) — re-exports through the κ DSL.
4.2. Outbound (upstream imports)
None. This is the lowest-level κ module. It imports nothing from other
Colibri domains. It imports nothing from Node.js stdlib. It imports
nothing from npm. The only ambient TypeScript features used are
BigInt/bigint, Error, and class.
This is intentional and load-bearing — κ’s guarantee is that its output is implementation-independent, so its lowest primitives must be dependency-independent too.
4.3. Forbidden imports
- No
better-sqlite3— κ has no database side. - No
zod— κ has no external schema surface; type-guarding is internal. - No
merkletreejs— κ feeds θ consensus via rule version hashes, but the hash computation is P1.5.1, not P1.1.1. - No
gray-matter— κ doesn’t read frontmatter. - No
@modelcontextprotocol/sdk— κ has no MCP tool at Phase 1. - No Node built-ins —
fs,path,crypto,os,child_process, etc.
5. Non-goals (explicit scope bounds)
The following are deliberately not delivered by P1.1.1, to preserve the clean dependency graph for follow-up sub-tasks.
- No branded
Bpstype —Bps = bigint & {__brand: 'Bps'}is P1.1.3 scope. P1.1.1 accepts rawbiginteverywhere. - No
BPS_100_PERCENT/BPS_1_PERCENT/BPS_50_PERCENTconstant exports — also P1.1.3. The value10_000nappears as a literal insidebps_mul/bps_div/apply_bpsonly. - No
min,max,abs,clamp,cap,isqrt,ilog2,diminishing,rep— extraction §3 lists these as built-ins, but the κ built-ins registry is P1.3.2 scope. P1.1.1 owns only the BPS + safety primitives that every built-in eventually calls. - No DSL parser / lexer / AST — P1.2.x scope (
κDSL, per ADR-006 Chevrotain). - No version-hash computation — P1.5.1 scope.
- No determinism harness — P1.1.2 scope. P1.1.1 is internally deterministic; property-level proof lives in P1.1.2.
- No MCP tool registration — κ has no MCP surface in Phase 1. Tool count stays 14.
- No
colibri_code: partialfrontmatter graduation — P1.1.1 is a helper library; the κ concept doc only graduates once the evaluator loop (P1.3.1+) ships.
6. Acceptance criteria (from task-prompt §P1.1.1)
| # | Criterion | Verified by |
|---|---|---|
| 1 | All arithmetic uses 64-bit signed integers, no floating point anywhere | Static: no number literals in the quantity path, no Math.*, lint + manual grep in verification |
| 2 | bps_mul(value, bps) = (value * bps) / 10000 (floor) |
Test bps_mul: floor division, cases (1000, 500) = 50, (10000, 10000) = 10000, (0, x) = 0, (x, 0) = 0, (1000, 1) = 0 |
| 3 | bps_div(value, bps) = (value * 10000) / bps (floor) |
Test bps_div: floor division, cases (5000, 2500) = 20000, (1000, 2000) = 5000 |
| 4 | apply_bps(value, bps) = value - bps_mul(value, bps) |
Test apply_bps: relation to bps_mul, cases (1000, 150) = 985, (1000, 0) = 1000, (1000, 10000) = 0 |
| 5 | decay(value, rate_bps, epochs) multi-epoch compounded decay with per-step floor |
Test decay: (1000, 150, 2) = 970 (one-floor drift from geometric 970.225); decay(v, r, 0) = v |
| 6 | Overflow detection: reject value * bps exceeding int64 |
Test safe_mul: INT64_MAX + 1 throws, boundary at 2^62 * 2^62 |
| 7 | Underflow: result never goes below 0 for non-negative inputs under apply_bps |
Test apply_bps: non-negative inputs produce non-negative output for bps <= 10_000n |
| 8 | Division by zero: explicit error, not silent wrap | Test bps_div: throws DivisionByZeroError on bps=0n, safe_div: throws DivisionByZeroError on b=0n |
| 9 | 100% branch coverage in tests | npm test --coverage output in verification doc |
7. Breaking-change policy
Because this module is not yet consumed anywhere on main, there is no
breaking-change policy in effect yet. Once P1.1.2 lands (the determinism
harness imports every export), this contract becomes a stable public API and
subsequent sub-tasks (P1.1.3, P1.3.2) may extend — never rename or retype —
any exported symbol.
8. Done-criteria
- Public API signatures pinned (§2).
- Invariants I1–I8 enumerated.
- Dependency rule: zero outbound imports.
- Non-goals explicit — no scope creep into P1.1.3 / P1.3.2.
- Acceptance criteria from task-prompt mapped to verifiable tests.