Packet — P1.1.1 Basis Point Arithmetic (R81.A)

1. Plan summary

Two files, single implementation commit, single verification commit.

# Path Commit
1 src/domains/rules/integer-math.ts feat(r81-a-p1-1-1-integer-math): bigint bps arithmetic helpers + 100% branch coverage
2 src/__tests__/domains/rules/integer-math.test.ts (same commit)

No mid-implementation refactor; no schema change; no server wiring.

2. Implementation sketch — integer-math.ts

/**
 * κ Rule Engine — Basis Point Arithmetic (P1.1.1)
 *
 * Pure, deterministic, integer-only basis-point primitives. Zero outbound
 * imports — this is the lowest-level κ helper. See:
 *   - docs/contracts/r81-a-p1-1-1-integer-math-contract.md
 *   - docs/reference/extractions/kappa-rule-engine-extraction.md §3–§4
 *   - docs/3-world/physics/laws/rule-engine.md §Integer-only arithmetic
 */

// ---------- Typed errors ----------

export class OverflowError extends Error {
  override readonly name = 'OverflowError';
  constructor(message: string) { super(message); }
}
export class DivisionByZeroError extends Error {
  override readonly name = 'DivisionByZeroError';
  constructor(message: string) { super(message); }
}
export class UnderflowError extends Error {
  override readonly name = 'UnderflowError';
  constructor(message: string) { super(message); }
}

// ---------- Constants (internal) ----------

const BPS_DENOMINATOR = 10_000n;
const INT64_MAX =  9_223_372_036_854_775_807n;  //  2^63 - 1
const INT64_MIN = -9_223_372_036_854_775_808n;  // -2^63

// ---------- Core BPS ops ----------

export function bps_mul(value: bigint, bps: bigint): bigint {
  return (value * bps) / BPS_DENOMINATOR;
}

export function bps_div(value: bigint, bps: bigint): bigint {
  if (bps === 0n) {
    throw new DivisionByZeroError('bps_div: bps === 0n');
  }
  return (value * BPS_DENOMINATOR) / bps;
}

export function apply_bps(value: bigint, bps: bigint): bigint {
  return value - bps_mul(value, bps);
}

export function decay(
  value: bigint,
  rate_bps: bigint,
  epochs: bigint,
): bigint {
  if (epochs < 0n) {
    throw new UnderflowError('decay: negative epochs');
  }
  let v = value;
  // bigint comparison in a while guard — no Math, no clock
  for (let i = 0n; i < epochs; i++) {
    v = apply_bps(v, rate_bps);
  }
  return v;
}

// ---------- Safe arithmetic primitives ----------

export function safe_mul(a: bigint, b: bigint): bigint {
  const product = a * b;
  if (product > INT64_MAX || product < INT64_MIN) {
    throw new OverflowError(
      `safe_mul: |${a} * ${b}| = ${product} outside int64 range`,
    );
  }
  return product;
}

export function safe_div(a: bigint, b: bigint): bigint {
  if (b === 0n) {
    throw new DivisionByZeroError('safe_div: b === 0n');
  }
  return a / b;   // native bigint truncate-toward-zero
}

Design notes

Why override readonly name on each error class: ESLint’s eqeqeq + TypeScript strict mode flag shadowed-name hazards. Setting override readonly name = '...' both pins the name for instanceof + .name routing (contract invariant I7 surface) and silences strictNullChecks.

Why internal const BPS_DENOMINATOR = 10_000n: extraction §3 uses the magic number 10000 in pseudocode. Hoisting to a named const makes the floor-division semantic explicit; numeric underscore separator is ES2021, supported under target: ES2022.

Why for (let i = 0n; i < epochs; i++) in decay: no dependency on while (epochs > 0n) epochs-- mutation. Keeps epochs read-only; matches I8 (deterministic, no hidden state).

Why no bps_mul overflow guard: per contract §3 I6, P1.1.1 intentionally does not pre-check int64 overflow on BPS ops. safe_mul is the escape hatch; P1.1.3 composes bps_mul over safe_mul behind a branded Bps type. Catching overflow in bps_mul would either (a) require pre-computing the product (defeating the point) or (b) require range-checking the inputs (P1.1.3 scope).

3. Test case matrix — integer-math.test.ts

Twenty-eight it blocks across eight describe groups. Each describe covers one function or cross-cutting concern.

3.1. describe('bps_mul')

  1. (1000n, 500n) === 50n — canonical 5% example.
  2. (10000n, 10000n) === 10000n — identity (100% of 100%).
  3. (0n, 10000n) === 0n — zero value short-circuit (floor = 0 regardless of bps).
  4. (10000n, 0n) === 0n — zero bps short-circuit.
  5. (1000n, 1n) === 0n — floor rounds 0.01% of 1000 down to zero.
  6. (-1000n, 500n) === -50n — negative value (bigint truncate-toward-zero).
  7. (1000n, -500n) === -50n — negative bps (same reason).
  8. (9_000_000_000_000_000n, 10_000n) === 9_000_000_000_000_000n — large safe boundary; confirms no implicit int64 guard.

3.2. describe('bps_div')

  1. (5000n, 2500n) === 20000n — divide by 25% → 4× (extraction §4 example).
  2. (1000n, 2000n) === 5000n — divide by 20% → 5× (extraction §4 example).
  3. (1000n, 0n) throws DivisionByZeroError — contract I7.
  4. (-1000n, 2000n) === -5000n — negative numerator sign preservation.
  5. (1000n, -2000n) === -5000n — negative divisor sign preservation.

3.3. describe('apply_bps')

  1. (1000n, 150n) === 985n — 1.5% decay example (rule-engine.md table).
  2. (1000n, 0n) === 1000n — zero-bps no-op.
  3. (1000n, 10000n) === 0n — 100% bps reduces to 0 (full decay).
  4. apply_bps(v, bps) === v - bps_mul(v, bps) invariant across a fixed sweep of (v, bps) tuples — contract AC#4 defining identity.
  5. Non-negative inputs with bps ∈ [0, 10_000] never produce negative output — contract AC#7 (underflow guarantee for the natural range).

3.4. describe('decay')

  1. (1000n, 150n, 1n) === 985n — one epoch equals apply_bps.
  2. (1000n, 150n, 2n) === 970n — per-step floor drift (geometric answer is ~970.225; per-step floor truncates to 985 then 970). Extraction §5 monotonicity example.
  3. (1000n, 150n, 0n) === 1000n — zero-epoch no-op.
  4. (1000n, 150n, -1n) throws UnderflowError("decay: negative epochs").
  5. (1000n, 10000n, 100n) === 0n — absorbing state: once v = 0, further epochs stay at 0.
  6. Monotonicity: for epochs in [0, 10] with rate_bps = 150n, decay(v, r, i+1) <= decay(v, r, i).

3.5. describe('safe_mul')

  1. (0n, 2n ** 62n) === 0n — zero short-circuit, safely.
  2. (INT64_MAX, 1n) === INT64_MAX — upper boundary preservation.
  3. (2n ** 62n, 2n ** 62n) throws OverflowError — canonical overflow scenario from extraction §4.
  4. (INT64_MIN, -1n) throws OverflowError — negation-overflow case (-INT64_MIN > INT64_MAX by one).
  5. (-2n ** 62n, -2n ** 62n) throws OverflowError — two-negative-product overflow.

3.6. describe('safe_div')

  1. (7n, 2n) === 3n — truncate (not floor).
  2. (-7n, 2n) === -3n — truncate-toward-zero for negative numerator (not floor to -4).
  3. (7n, -2n) === -3n — negative divisor.
  4. (7n, 0n) throws DivisionByZeroError.

3.7. describe('error classes')

  1. OverflowError instanceof Error and .name === 'OverflowError'.
  2. DivisionByZeroError instanceof Error and .name === 'DivisionByZeroError'.
  3. UnderflowError instanceof Error and .name === 'UnderflowError'.
  4. Each error’s .message is present and non-empty.

3.8. describe('determinism smoke')

  1. Call each of the six functions twice with the same args; assert equality. (Full property / fuzz harness is P1.1.2; this is the 10-second smoke.)

Total: 38 it blocks. Updates audit estimate (~30) upward; the delta is from enumerating every (sign × zero × boundary) cell rather than collapsing them into parametrized blocks. All within Jest default timeout.

4. Branch-coverage matrix

Function Branches  
bps_mul none — straight expression trivially 100%
bps_div bps === 0n true / false tests 11 + 9
apply_bps none (delegates) trivially 100%
decay epochs < 0n true / false; for loop enters / skips tests 22 + 19–21
safe_mul product > INT64_MAX, product < INT64_MIN, both false tests 27–29 + 25–26
safe_div b === 0n true / false tests 33 + 30–32
Error classes no branches trivially 100%

All six function bodies hit every branch. Expected output of npm test is a coverage line like integer-math.ts | 100 | 100 | 100 | 100.

5. Rollback plan

Net-new files with zero upstream imports. Rollback is trivial:

git revert <final-sha-of-PR-merge>

No downstream module on origin/main imports src/domains/rules/* — the revert cannot regress any existing test.

If the PR is never merged, git worktree remove .worktrees/claude/r81-a-p1-1-1-integer-math and git branch -D feature/r81-a-p1-1-1-integer-math suffice.

6. Risks / residuals

  1. Jest ESM flake (startup — subprocess smoke): pre-existing; not a P1.1.1 regression. Verification step documents it and confirms it does not trigger on the P1.1.1 suite alone.
  2. bigint literal lint rule: ESLint eqeqeq: error applies to ==, not ===; 0n === 0n is fine. @typescript-eslint/no-explicit-any: warn isn’t triggered — no any used.
  3. Pre-authored-prompt path divergence: the prompt says src/domains/rules/__tests__/integer-math.test.ts, the shipped Phase 0 convention (CLAUDE.md §9.1) says src/__tests__/.... Packet follows §9.1. Flagged in audit §File plan and contract §8 as intentional.
  4. decay with very high epochs: a pathological epochs = 1_000_000n takes ~million iterations. Safe (no allocations per iteration) but Jest’s default 5s timeout covers it fine. Test #24 caps at 10.

7. Done-criteria for this step

  • Line-level sketch of integer-math.ts drafted.
  • 38 test cases enumerated with expected values.
  • Every branch of every function mapped to a test.
  • Rollback and residual risks documented.

Next step: implement (§4 of the 5-step chain).


Back to top

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

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