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')
(1000n, 500n) === 50n— canonical 5% example.(10000n, 10000n) === 10000n— identity (100% of 100%).(0n, 10000n) === 0n— zero value short-circuit (floor = 0 regardless of bps).(10000n, 0n) === 0n— zero bps short-circuit.(1000n, 1n) === 0n— floor rounds 0.01% of 1000 down to zero.(-1000n, 500n) === -50n— negative value (bigint truncate-toward-zero).(1000n, -500n) === -50n— negative bps (same reason).(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')
(5000n, 2500n) === 20000n— divide by 25% → 4× (extraction §4 example).(1000n, 2000n) === 5000n— divide by 20% → 5× (extraction §4 example).(1000n, 0n)throwsDivisionByZeroError— contract I7.(-1000n, 2000n) === -5000n— negative numerator sign preservation.(1000n, -2000n) === -5000n— negative divisor sign preservation.
3.3. describe('apply_bps')
(1000n, 150n) === 985n— 1.5% decay example (rule-engine.md table).(1000n, 0n) === 1000n— zero-bps no-op.(1000n, 10000n) === 0n— 100% bps reduces to 0 (full decay).apply_bps(v, bps) === v - bps_mul(v, bps)invariant across a fixed sweep of(v, bps)tuples — contract AC#4 defining identity.- 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')
(1000n, 150n, 1n) === 985n— one epoch equalsapply_bps.(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.(1000n, 150n, 0n) === 1000n— zero-epoch no-op.(1000n, 150n, -1n)throwsUnderflowError("decay: negative epochs").(1000n, 10000n, 100n) === 0n— absorbing state: oncev = 0, further epochs stay at 0.- Monotonicity: for
epochs in [0, 10]withrate_bps = 150n,decay(v, r, i+1) <= decay(v, r, i).
3.5. describe('safe_mul')
(0n, 2n ** 62n) === 0n— zero short-circuit, safely.(INT64_MAX, 1n) === INT64_MAX— upper boundary preservation.(2n ** 62n, 2n ** 62n)throwsOverflowError— canonical overflow scenario from extraction §4.(INT64_MIN, -1n)throwsOverflowError— negation-overflow case (-INT64_MIN > INT64_MAXby one).(-2n ** 62n, -2n ** 62n)throwsOverflowError— two-negative-product overflow.
3.6. describe('safe_div')
(7n, 2n) === 3n— truncate (not floor).(-7n, 2n) === -3n— truncate-toward-zero for negative numerator (not floor to -4).(7n, -2n) === -3n— negative divisor.(7n, 0n)throwsDivisionByZeroError.
3.7. describe('error classes')
OverflowErrorinstanceofErrorand.name === 'OverflowError'.DivisionByZeroErrorinstanceofErrorand.name === 'DivisionByZeroError'.UnderflowErrorinstanceofErrorand.name === 'UnderflowError'.- Each error’s
.messageis present and non-empty.
3.8. describe('determinism smoke')
- 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
- 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. - bigint literal lint rule: ESLint
eqeqeq: errorapplies to==, not===;0n === 0nis fine.@typescript-eslint/no-explicit-any: warnisn’t triggered — noanyused. - 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) sayssrc/__tests__/.... Packet follows §9.1. Flagged in audit §File plan and contract §8 as intentional. decaywith very highepochs: a pathologicalepochs = 1_000_000ntakes ~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.tsdrafted. - 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).