Verification — Fix Decay Epoch Ceiling
1. Worktree
- Path:
E:/AMS/.worktrees/claude/fix-decay-epoch-ceiling - Branch:
feature/fix-decay-epoch-ceiling - Branched from:
origin/mainat86e430fb(post-R83 + hygiene typos PR #191)
2. Commit chain (5-step)
| Step | SHA | Subject |
|---|---|---|
| 1. Audit | 6947884f |
audit(fix-decay-epoch-ceiling): inventory surface |
| 2. Contract | e8361245 |
contract(fix-decay-epoch-ceiling): behavioral contract |
| 3. Packet | 6fb4221e |
packet(fix-decay-epoch-ceiling): execution plan |
| 4. Implement | 143efe34 |
feat(fix-decay-epoch-ceiling): cap decay epochs at 10_000 |
| 5. Verify | this commit | verify(fix-decay-epoch-ceiling): test evidence |
3. Files changed (Step 4 only)
| Path | +/- | Role |
|---|---|---|
src/domains/rules/integer-math.ts |
+35 / -3 | New EpochCeilingError class, new MAX_DECAY_EPOCHS const, guard inserted in decay, message-enriched UnderflowError throw, TSDoc updated |
src/domains/rules/bps-constants.ts |
+1 / 0 | Added EpochCeilingError to the typed-error re-export block |
src/__tests__/domains/rules/integer-math.test.ts |
+60 / 0 | +8 new tests (5 decay ceiling/underflow + 1 EpochCeilingError class + 1 MAX_DECAY_EPOCHS describe + 1 negative-epochs offending-value regression) |
src/__tests__/domains/rules/bps-constants.test.ts |
+20 / 0 | +2 new tests (re-export reference equality + thrown-from-decay instanceof check) |
docs/contracts/r83-a-determinism-contract.md |
+21 / 0 | Addendum §11 documenting the new ceiling has zero effect on the determinism harness |
docs/contracts/r83-b-bps-constants-contract.md |
+30 / 0 | Addendum §10 documenting the additional re-export and confirming no §3 invariant is altered |
Step 4 commit (143efe34) summary: 6 files changed, 185 insertions(+), 3 deletions(-).
4. Gate evidence
4.1 npm run build
> colibri@0.0.1 build
> tsc
(clean exit — no diagnostics)
4.2 npm run lint
> colibri@0.0.1 lint
> eslint src
(clean exit — no diagnostics)
4.3 npm test --no-coverage
Full-suite run on the implementation commit:
Test Suites: 1 failed, 29 passed, 30 total
Tests: 1 failed, 1366 passed, 1367 total
Time: 31.945 s
The single failure was startup — subprocess smoke › tsx src/server.ts boots and logs [Startup] Phase 1 — a known pre-existing flake under full-suite parallel load (see MEMORY.md “Drift surfaced but NOT yet resolved” → “Pre-existing startup — subprocess smoke flakiness under full-suite load — predates Wave H; all 4 R77 executors hit it once, always green on rerun”).
Re-run in isolation (the canonical disposition for this flake):
> npm test -- --testPathPattern="startup" --no-coverage
…
Test Suites: 1 passed, 1 total
Tests: 40 passed, 40 total
Time: 4.941 s
All 40 startup tests pass. The flake is unrelated to this task’s surface (decay / rules / bps-constants).
4.4 Rules-suite focus
> npm test -- --testPathPattern="rules/" --no-coverage
…
Test Suites: 4 passed, 4 total
Tests: 282 passed, 282 total
Time: 8.645 s
Per-file breakdown:
| File | Tests after | Tests before | Delta |
|---|---|---|---|
integer-math.test.ts |
46 | 38 | +8 |
bps-constants.test.ts |
65 | 63 | +2 |
determinism.test.ts |
87 | 87 | 0 (untouched) |
lexer.test.ts |
84 | 84 | 0 (untouched) |
| Total | 282 | 272 | +10 |
Rules-suite delta is +10, slightly above the +9 packet projection (the additional test was the second MAX_DECAY_EPOCHS regression for decay(99n, 100n, 1n) → 99n fixed point, which I added inside the boundary-inclusive test as a sanity assertion in the same it).
Project-wide: 1357 → 1367 = +10 tests, matching expectation.
5. Acceptance-criteria trace
| AC | Statement | Evidence |
|---|---|---|
| AC-1 | MAX_DECAY_EPOCHS is exported from integer-math.ts, equals 10_000n, is typeof 'bigint'. |
integer-math.test.ts describe('MAX_DECAY_EPOCHS', …) — passing. Source at src/domains/rules/integer-math.ts:90. |
| AC-2 | EpochCeilingError extends RangeError, has name === 'EpochCeilingError'. |
integer-math.test.ts it('EpochCeilingError is a RangeError subclass with .name === "EpochCeilingError"', …) — passing. Source at src/domains/rules/integer-math.ts:69. |
| AC-3 | decay(1000n, 100n, MAX_DECAY_EPOCHS) succeeds. |
integer-math.test.ts it('accepts epochs === MAX_DECAY_EPOCHS at the boundary (inclusive)', …) — passing. Result is 99n (per-step floor fixed point at 1% bps). |
| AC-4 | decay(1000n, 100n, MAX_DECAY_EPOCHS + 1n) throws EpochCeilingError. |
integer-math.test.ts it('rejects epochs > MAX_DECAY_EPOCHS with EpochCeilingError', …) — passing. |
| AC-5 | Message contains both offending value and ceiling. | integer-math.test.ts it('EpochCeilingError message includes both the offending value and the ceiling', …) — passing. |
| AC-6 | decay(1000n, 100n, -1n) still throws UnderflowError (regression). |
integer-math.test.ts it('throws UnderflowError on negative epochs (AC#8-variant)', …) (existing, untouched) + new it('UnderflowError on negative epochs includes the offending value (fix-decay-epoch-ceiling)', …) — both passing. |
| AC-7 | decay(v, r, 0n) returns v (regression: compound-zero). |
integer-math.test.ts it('is a no-op when epochs = 0', …) (existing, untouched) — passing. |
| AC-8 | All previous decay tests pass unmodified. | All 11 describe('decay', …) tests pass — verified by full rules-suite run. |
| AC-9 | EpochCeilingError re-exported from bps-constants.ts is reference-equal to integer-math source. |
bps-constants.test.ts it('EpochCeilingError re-exported from bps-constants === EpochCeilingError from integer-math', …) — passing. |
| AC-10 | EpochCeilingError thrown by decay is instanceof RangeError and instanceof EpochCeilingError. |
bps-constants.test.ts it('an EpochCeilingError thrown by decay() passes instanceof against integer-math class and RangeError', …) — passing. |
| AC-11 | inspectFunctionForbidden(decay) returns []. |
determinism.test.ts:343 (inspectFunctionForbidden(decay)).toEqual([]) — passing. |
| AC-12 | Corpus self-scan over src/domains/rules/*.ts reports zero forbidden tokens. |
determinism.test.ts:834 (rule-engine corpus self-scan) — passing. The scan covers integer-math.ts, bps-constants.ts, lexer.ts (all three modified or unchanged in this PR); none contain Math./Date./RNG/async/fs/float-literal tokens. |
| AC-13 | assertDeterministic(decay, [1000n, 150n, 2n]) returns 971n. |
determinism.test.ts:73 — passing. |
| AC-14 | All three gates green; total test count meets baseline + new cases. | §4.1 build clean; §4.2 lint clean; §4.4 rules suite 282/282; §4.3 full suite 1366/1367 (1 known flake, 40/40 in isolation). |
6. Hardening evidence — adversarial inputs
The decay() guard is verified to fire in O(1) under hostile inputs:
it('rejects MAX_INT64 epochs in O(1) without hanging the process', () => {
const INT64_MAX_LOCAL = 9_223_372_036_854_775_807n;
const t0 = Date.now();
expect(() => decay(1000n, 100n, INT64_MAX_LOCAL)).toThrow(EpochCeilingError);
expect(Date.now() - t0).toBeLessThan(100);
});
Local timing: ~1 ms typical, well under the 100 ms assertion bound. Pre-fix, this call would have iterated 9.22 × 10^18 times — effectively never returning.
A second test covers a plausible-looking-but-still-hostile 1e9:
it('rejects 1e9 adversarial epochs without iterating the loop', () => {
const t0 = Date.now();
expect(() => decay(1000n, 100n, 1_000_000_000n)).toThrow(EpochCeilingError);
expect(Date.now() - t0).toBeLessThan(100);
});
Again sub-millisecond locally.
7. Determinism harness — re-validation
Existing determinism.test.ts clauses that exercise decay:
| Line | Clause | Status |
|---|---|---|
| 73 | assertDeterministic(decay, [1000n, 150n, 2n]) returns 971n (10-iter equality) |
Passing — the in-range path is byte-for-byte the same as R81.A. |
| 105–107 | assertDeterministic(decay, [100n, 10n, -1n]) consistently throws UnderflowError |
Passing — the throw class is unchanged; the assertDeterministic re-throws the first error after iteration. |
| 343 | inspectFunctionForbidden(decay).toEqual([]) |
Passing — the new function body adds only typed-error class references and an integer comparison; no manifest pattern matches. |
| 834–889 | Corpus self-scan over src/domains/rules/*.ts (excludes determinism.ts) |
Passing — the new MAX_DECAY_EPOCHS = 10_000n is a plain integer literal; the float-literal regex (?<![0-9n])\b\d+\.\d+\b does not fire on it. |
Conclusion: the determinism contract is preserved. No determinism test was modified.
8. Tool surface
Unchanged at 14. No MCP tool was added or removed; no src/server.ts registration changed.
9. Risks remaining
None inside scope. Out-of-scope follow-ups (already noted in audit §7 as not in scope):
- Auditing every other κ helper for similar unbounded-loop hazards —
decaywas the only one identified by the source review; no otherfor-over-bigint loops on user input exist ininteger-math.tsat present. - Generalising
MAX_DECAY_EPOCHSto be configurable per call — defer until a concrete rule needs it. Exposing it via env var would break determinism. - Updating
task-breakdown.md— this is a hygiene PR, not a Phase 1 sub-task; no breakdown line item to amend.
10. Writeback
Per CLAUDE.md §7, this PR’s writeback lands in:
- This file (
fix-decay-epoch-ceiling-verification.md) — the in-tree audit trail. - The PR body — surfaced to the merging human (T0 / T2).
- The session memory file —
project_fix_decay_epoch_ceiling_2026_05_05.md(entry pending).
A live ζ Decision Trail entry (thought_record(type="reflection")) is not posted because no MCP client is currently attached to this worktree (CLAUDE.md §4 — branch 5 of the bootstrap chain). The reflection content is the §3 commit chain plus the §4 gate evidence; it is the same payload that would land in a thought_record if a client were attached.