Verification — P2.2.1 Exponential Decay
Task ID: 7ab3f352-7ba5-4132-9db4-aeda2affb2a0
Branch: feature/p2-2-1-decay
Step: 5 of 5 (audit ✓ → contract ✓ → packet ✓ → implement ✓ → verify)
Audit: docs/audits/p2-2-1-decay-audit.md
Contract: docs/contracts/p2-2-1-decay-contract.md
Packet: docs/packets/p2-2-1-decay-packet.md
§1. Shipped surface
src/domains/reputation/decay.ts — 156 LOC source (rate_for, apply_decay, apply_decay_batch)
src/__tests__/domains/reputation/decay.test.ts — 278 LOC tests (T1–T20)
No edits to existing files. No new npm dependency. No schema migration.
§2. Gate evidence
§2.1 npm run build
> colibri@0.0.1 build
> tsc
> colibri@0.0.1 postbuild
> node scripts/copy-migrations.mjs
copy-migrations: copied 7 migration(s) ... -> .../dist/db/migrations
Status: PASS — TypeScript clean, no diagnostics, never-narrowed
exhaustiveness check satisfied at compile time.
§2.2 npm run lint
> colibri@0.0.1 lint
> eslint src
Status: PASS — ESLint clean (no output = no warnings/errors).
§2.3 npm test
Test Suites: 49 passed, 49 total
Tests: 2464 passed, 2464 total
Snapshots: 0 total
Time: 54.077 s
Status: PASS — 2464/2464 across 49 suites.
- Baseline at origin/main (994db1e4): 2444 tests.
- Delta: +20 tests added by P2.2.1 (T1–T20).
- Regression: zero — every prior suite still passes.
§2.4 Coverage for decay.ts
src/domains/reputation/decay.ts | 88.23% statements | 88.88% branches | 100% functions | 87.5% lines
Uncovered: lines 83–84
Lines 83–84 are the never-narrowed default branch in rate_for:
const _exhaustive: never = domain;
throw new Error(`rate_for: unhandled domain ${String(_exhaustive)}`);
This branch is unreachable by design — TS rejects any caller that could hit it. The 88% coverage is the maximum achievable without a test that deliberately defeats the type system (which would itself be a defect).
§3. Acceptance criteria — checked against the contract
| AC (contract §7) | Test | Status |
|---|---|---|
| T1: execution → DECAY_EXECUTION (500n) | rate_for · T1 |
PASS |
| T2: commissioning → 300n | rate_for · T2 |
PASS |
| T3: arbitration → 1_000n | rate_for · T3 |
PASS |
| T4: governance → 200n | rate_for · T4 |
PASS |
| T5: social → 100n | rate_for · T5 |
PASS |
| T6: zero-inactive identity short-circuit | apply_decay · T6 |
PASS |
| T7: clock-skew identity short-circuit | apply_decay · T7 |
PASS |
T8: 10-epoch execution decay matches decay() |
apply_decay · T8 |
PASS |
| T9: score = 0 stays 0 (floor) | apply_decay · T9 |
PASS |
| T10: last_activity_epoch unchanged | apply_decay · T10 |
PASS |
| T11: Object.freeze input — fresh row returned | apply_decay · T11 |
PASS |
| T12: each domain decays at its rate | apply_decay · T12 |
PASS |
| T13: 10k rows perf smoke (<500ms) | apply_decay_batch · T13 |
PASS |
| T14: empty array → empty array | apply_decay_batch · T14 |
PASS |
| T15: mixed-domain batch | apply_decay_batch · T15 |
PASS |
| T16: length + order preserved | apply_decay_batch · T16 |
PASS |
| T17: compound ≠ linear (9025 vs 9000) | properties · T17 |
PASS |
| T18: determinism — deep-equal | properties · T18 |
PASS |
| T19: EpochCeilingError propagates | apply_decay · T19 |
PASS |
| T20: rate_for accepts every DOMAINS member | properties · T20 |
PASS |
§4. Invariant audit (contract §4)
| ID | Invariant | Verification |
|---|---|---|
| AX-01 | Pure functions; no I/O. | grep confirms: no better-sqlite3, no fs, no path, no http imports. |
| AX-02 | No Math.*, Date.*, Math.random. |
grep -nE 'Math\.|Date\.' returns no hits in decay.ts. |
| AX-03 | decay() is imported, never re-implemented. |
Single import statement; no local function decay definition. |
| AX-04 | No mutation of input. | T11 (Object.freeze) proves it at runtime. |
| AX-05 | last_activity_epoch preserved. |
T10 asserts equality across input/output. |
| AX-06 | rate_for exhaustive. |
TS compile-time check via never branch; tests T1–T5 + T20 cover all five domains at runtime. |
| AX-07 | NodeNext ESM .js suffix. |
All imports use .js suffix. |
| AX-08 | bigint ↔ number conversion bounded. | The exported function signatures expose bigint only for current_epoch (which is bigint to match decay() input type); all ReputationRow.score reads/writes use Number() boundary conversion. |
§5. Anomalies and follow-ups
§5.1 T17 — corrected during the packet step
The original prompt suggested testing
decay(decay(x, r, e1), r, e2) !== decay(x, r, e1+e2) as a “compound effect”
property. We verified empirically during the packet step that this is in fact
equal — same-rate composition is associative because per-step flooring is
idempotent across cuts. The genuine non-linearity of compound decay is that
it diverges from the linear projection: 5% × 2 epochs reduces value by
9.75%, not 10%. T17 tests that real property and is documented in
contract §7 and packet §6 R1.
This is a correctness improvement on the prompt, not a deviation from the underlying intent (which was to show that compounding matters).
§5.2 88% line coverage on decay.ts
Lines 83–84 (the never-narrowed default branch in rate_for) are
intentionally unreachable. 100% line coverage would require a test that
casts away the type system to construct an invalid Domain, which would
itself be a defect. The never check provides stronger guarantees than
runtime coverage could.
§5.3 No blockers
decay()primitive signature matches:decay(value: bigint, rate_bps: bigint, epochs: bigint): bigint.- All five
DECAY_*constants present and correct inbps-constants.ts. ReputationRowshape from P2.1.1 is the source of truth.- All three gates green; zero regressions; +20 tests.
§6. Commit timeline
| Step | SHA | Subject |
|---|---|---|
| 1 — audit | ba86a120 |
audit(p2-2-1-decay): inventory surface |
| 2 — contract | 6fc6e044 |
contract(p2-2-1-decay): behavioral contract |
| 3 — packet | c7a82fbb |
packet(p2-2-1-decay): execution plan |
| 3.1 — fixup | 706ac660 |
contract+packet(p2-2-1-decay): correct T17 — same-rate decay composition is associative, real non-linearity is vs linear projection |
| 4 — implement | 351f1368 |
feat(p2-2-1-decay): per-domain decay using κ integer-math primitive |
| 5 — verify | (this commit) | verify(p2-2-1-decay): test evidence |
§7. Writeback (for PM)
task_id: 7ab3f352-7ba5-4132-9db4-aeda2affb2a0
branch: feature/p2-2-1-decay
worktree: .worktrees/claude/p2-2-1-decay
commits: ba86a120, 6fc6e044, c7a82fbb, 706ac660, 351f1368, <verify SHA>
tests: npm run build && npm run lint && npm test → 49 suites, 2464/2464 PASS (+20 from 2444 baseline)
summary: Per-domain reputation decay using κ integer-math decay() primitive
and DECAY_* constants from bps-constants. apply_decay (single-row, pure)
and apply_decay_batch (10k+ rows pure map). Exhaustive rate_for over the
5 canonical Domain values with `never`-narrowed default for compile-time
safety. Score floor at 0 delegated to the κ primitive; last_activity_epoch
preserved byte-for-byte (B3, AX-05); zero-inactive short-circuit returns
input reference; 10k-row perf smoke completed well under spec target.
T17 corrected during packet step: same-rate decay composition is associative,
so the meaningful non-linearity property is compound vs linear (9025 vs 9000),
not split vs combined. Documented in contract §7 + packet §6 R1.
blockers: none
Verification complete. Ready for PR open.