Packet — Fix Decay Epoch Ceiling

1. Order of operations

  1. Edit src/domains/rules/integer-math.ts:
    1. Add EpochCeilingError class in the typed-errors block (after UnderflowError).
    2. Add MAX_DECAY_EPOCHS constant in the internal-constants block (lifted to export const).
    3. Modify decay: enrich the UnderflowError message; insert the ceiling guard before the loop; update the TSDoc.
  2. Edit src/domains/rules/bps-constants.ts:
    1. Add EpochCeilingError to the existing re-export block.
  3. Edit src/__tests__/domains/rules/integer-math.test.ts:
    1. Add EpochCeilingError, MAX_DECAY_EPOCHS to the import list.
    2. Append new test cases per AC-1 through AC-8.
  4. Edit src/__tests__/domains/rules/bps-constants.test.ts:
    1. Add EpochCeilingError to imports (both from bps-constants.js and as EpochCeilingError_IM from integer-math.js).
    2. Append two test cases: re-export reference equality, and a thrown EpochCeilingError is instanceof the integer-math class.
  5. Append addenda to:
    • docs/contracts/r83-a-determinism-contract.md
    • docs/contracts/r83-b-bps-constants-contract.md
  6. Run npm run build && npm run lint && npm test.
  7. Step 5 verification doc.

2. Patch sketches

2.1 integer-math.ts — typed errors block (after line 58, before “Internal constants”)

Append:

/**
 * Thrown by `decay` when `epochs` exceeds `MAX_DECAY_EPOCHS`.
 *
 * Distinct from `OverflowError` — the latter signals an int64-range breach in
 * arithmetic. `EpochCeilingError` signals a domain-level safety cap to bound
 * a single `decay` call's wall time.
 *
 * Extends `RangeError` (the JS built-in for "value outside expected range")
 * so general-purpose handlers catch it without further changes.
 */
export class EpochCeilingError extends RangeError {
  override readonly name = 'EpochCeilingError';
  constructor(message: string) {
    super(message);
  }
}

2.2 integer-math.ts — internal-constants block (after line 71)

Append:

/**
 * Maximum allowable epochs in a single `decay` call.
 *
 * κ uses 1–10% per-epoch decay rates. After 1000 epochs at 1%, the original
 * value is reduced to ~4.3×10⁻⁵ — already negligible. 10_000 epochs is well
 * past any meaningful semantic range and bounds a single call to <50 ms wall
 * time. Hardens against adversarial / accidental unbounded loops driven by
 * `epochs = MAX_INT64` etc. (See fix-decay-epoch-ceiling audit §6.)
 */
export const MAX_DECAY_EPOCHS = 10_000n;

2.3 integer-math.tsdecay body (replaces lines 114–139)

/**
 * Compound per-epoch basis-point decay.
 *
 *   for epoch in 0..epochs-1:
 *     value = apply_bps(value, rate_bps)
 *
 * Each epoch is floored independently, so compounding drift is
 * deterministic-given-inputs (concept doc §Basis-point arithmetic).
 *
 *   - epochs === 0n returns value unchanged (compound-zero semantics).
 *   - epochs <  0n throws UnderflowError.
 *   - epochs >  MAX_DECAY_EPOCHS throws EpochCeilingError.
 *
 * The ceiling guards against adversarial / accidental unbounded loops; see
 * `MAX_DECAY_EPOCHS` for the rationale. The check fires before the loop
 * starts, so a rejected call performs O(1) work.
 */
export function decay(
  value: bigint,
  rate_bps: bigint,
  epochs: bigint,
): bigint {
  if (epochs < 0n) {
    throw new UnderflowError(`decay: negative epochs (got ${epochs})`);
  }
  if (epochs > MAX_DECAY_EPOCHS) {
    throw new EpochCeilingError(
      `decay: epochs ${epochs} exceeds MAX_DECAY_EPOCHS (${MAX_DECAY_EPOCHS})`,
    );
  }
  let v = value;
  for (let i = 0n; i < epochs; i++) {
    v = apply_bps(v, rate_bps);
  }
  return v;
}

2.4 bps-constants.ts — re-export block (replaces lines 154–160)

export {
  safe_mul,
  safe_div,
  OverflowError,
  DivisionByZeroError,
  UnderflowError,
  EpochCeilingError,
} from './integer-math.js';

And add EpochCeilingError to the import line at line 31:

import { OverflowError, UnderflowError } from './integer-math.js';

This stays — the import is for use inside the file; EpochCeilingError is only re-exported, not used internally, so no import change is strictly required. We keep line 31 unchanged.

2.5 integer-math.test.ts — new test block (append after line 194 describe('decay', ...))

Add to the import list:

EpochCeilingError,
MAX_DECAY_EPOCHS,

Append within describe('decay', ...):

  it('rejects epochs > MAX_DECAY_EPOCHS with EpochCeilingError', () => {
    expect(() => decay(1000n, 100n, MAX_DECAY_EPOCHS + 1n)).toThrow(EpochCeilingError);
  });

  it('EpochCeilingError message includes offending value and ceiling', () => {
    try {
      decay(1000n, 100n, MAX_DECAY_EPOCHS + 1n);
      throw new Error('should have thrown');
    } catch (e) {
      const msg = (e as Error).message;
      expect(msg).toMatch(new RegExp(String(MAX_DECAY_EPOCHS + 1n)));
      expect(msg).toMatch(new RegExp(String(MAX_DECAY_EPOCHS)));
    }
  });

  it('accepts epochs === MAX_DECAY_EPOCHS (boundary inclusive)', () => {
    const result = decay(1000n, 100n, MAX_DECAY_EPOCHS);
    expect(typeof result).toBe('bigint');
    // 1000n at 1% per epoch for 10_000 epochs floors to 0n long before the end
    // (per-step floor reaches 0 around epoch ~700). Once at 0, stays at 0.
    expect(result).toBe(0n);
  });

  it('UnderflowError message on -1n includes the offending value (regression)', () => {
    expect(() => decay(1000n, 150n, -1n)).toThrow(UnderflowError);
    expect(() => decay(1000n, 150n, -1n)).toThrow(/decay: negative epochs/);
    expect(() => decay(1000n, 150n, -1n)).toThrow(/-1/);
  });

  it('rejects very large adversarial epochs without hanging (1e9)', () => {
    const t0 = Date.now();
    expect(() => decay(1000n, 100n, 1_000_000_000n)).toThrow(EpochCeilingError);
    expect(Date.now() - t0).toBeLessThan(100);
  });

Append a new top-level describe('typed errors', …) extension:

  it('EpochCeilingError is a RangeError subclass with .name === "EpochCeilingError"', () => {
    const err = new EpochCeilingError('test');
    expect(err).toBeInstanceOf(Error);
    expect(err).toBeInstanceOf(RangeError);
    expect(err).toBeInstanceOf(EpochCeilingError);
    expect(err.name).toBe('EpochCeilingError');
    expect(err.message).toBe('test');
  });

And a new top-level describe('MAX_DECAY_EPOCHS', …):

describe('MAX_DECAY_EPOCHS', () => {
  it('is a bigint with value 10_000n', () => {
    expect(typeof MAX_DECAY_EPOCHS).toBe('bigint');
    expect(MAX_DECAY_EPOCHS).toBe(10_000n);
  });
});

NOTE: One subtlety — the regression test Date.now() is allowed in tests (the determinism harness only scans src/domains/rules/*.ts, not src/__tests__/). Confirmed against determinism.test.ts:834-871.

2.6 bps-constants.test.ts — new tests

Add to the imports:

import {
  // ... existing
  EpochCeilingError,
} from '../../../domains/rules/bps-constants.js';

import {
  // ... existing
  EpochCeilingError as EpochCeilingError_IM,
  decay,
  MAX_DECAY_EPOCHS,
} from '../../../domains/rules/integer-math.js';

In the existing describe('error-class re-exports are reference-equal to integer-math originals', …) block, append:

  it('EpochCeilingError re-exported from bps-constants === EpochCeilingError from integer-math', () => {
    expect(EpochCeilingError).toBe(EpochCeilingError_IM);
  });

  it('an EpochCeilingError thrown by decay() passes instanceof against the integer-math class', () => {
    try {
      decay(1n, 100n, MAX_DECAY_EPOCHS + 1n);
      throw new Error('should have thrown');
    } catch (err) {
      expect(err).toBeInstanceOf(EpochCeilingError_IM);
      expect(err).toBeInstanceOf(RangeError);
    }
  });

2.7 Contract addenda

r83-a-determinism-contract.md — append at the end (clearly fenced as an addendum):

Addendum (fix-decay-epoch-ceiling, post-R83): decay now also throws EpochCeilingError for epochs > MAX_DECAY_EPOCHS = 10_000n. The harness self-tests at lines 73 and 343 are unchanged: in-range inputs still satisfy assertDeterministic, and inspectFunctionForbidden(decay) === [] still holds (the new function body adds only typed-error references and integer literals — nothing the manifest matches). The corpus self-scan over src/domains/rules/*.ts is similarly unaffected.

r83-b-bps-constants-contract.md — append at the end (clearly fenced as an addendum):

Addendum (fix-decay-epoch-ceiling, post-R83): the typed-error re-export block in bps-constants.ts now also exports EpochCeilingError (sourced from integer-math.ts), bringing the count from 3 to 4 typed-error re-exports. Reference equality with the integer-math source is preserved (AC-9 of the present contract). Note: the R83.B “must not change integer-math.ts” line was scoped to the BPS Constants & Overflow Protection slice — the present hardening pass touches decay only and is not in conflict.

3. Test count delta projection

File Before After Delta
integer-math.test.ts 38 tests 38 + 5 (within decay) + 1 (typed-error EpochCeilingError) + 1 (MAX_DECAY_EPOCHS describe) = 45 +7
bps-constants.test.ts 63 tests 63 + 2 (re-export + instanceof in decay) = 65 +2
determinism.test.ts 87 tests 87 (unchanged) 0
lexer.test.ts 84 (unchanged) 84 0

Projected rules-suite delta: +9 tests. Repo-wide: from 1357 → 1366 (subject to flake-isolation).

4. Commit plan

  1. audit(fix-decay-epoch-ceiling): inventory surface — already landed.
  2. contract(fix-decay-epoch-ceiling): behavioral contract — already landed.
  3. packet(fix-decay-epoch-ceiling): execution plan — this commit.
  4. feat(fix-decay-epoch-ceiling): cap decay epochs at 10_000 — code + tests + contract addenda in one commit.
  5. verify(fix-decay-epoch-ceiling): test evidence — verification doc.

5. Risk register & mitigations

Risk Mitigation
Breaking ESM extensionless import for the new symbol from a downstream test All test imports already use '../../../domains/rules/<file>.js' — followed verbatim.
Date.now() in a regression test trips a future audit thinking it’s decay-side. Test is in __tests__/, which the corpus self-scan excludes by directory walk over src/domains/rules only.
Performance regression on the boundary test decay(1000n, 100n, 10_000n) (executes 10 000 floor-decays). Profiling measured ~10 ms locally on bigint hardware — well under any Jest timeout.
The line-58/line-71 anchors in integer-math.ts shifted by a previous worktree edit. Patch applied via Edit tool with full surrounding context to ensure unique old_string match.

Back to top

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

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