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 from src/domains/rules/** and eventually src/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, no fetch, no process.*.
  • 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) — introduces BPS_100_PERCENT etc. and a branded Bps = 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-insfs, 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.

  1. No branded Bps typeBps = bigint & {__brand: 'Bps'} is P1.1.3 scope. P1.1.1 accepts raw bigint everywhere.
  2. No BPS_100_PERCENT / BPS_1_PERCENT / BPS_50_PERCENT constant exports — also P1.1.3. The value 10_000n appears as a literal inside bps_mul / bps_div / apply_bps only.
  3. 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.
  4. No DSL parser / lexer / AST — P1.2.x scope (κ DSL, per ADR-006 Chevrotain).
  5. No version-hash computation — P1.5.1 scope.
  6. No determinism harness — P1.1.2 scope. P1.1.1 is internally deterministic; property-level proof lives in P1.1.2.
  7. No MCP tool registration — κ has no MCP surface in Phase 1. Tool count stays 14.
  8. No colibri_code: partial frontmatter 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.

Back to top

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

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