Contract — P2.2.1 Exponential Decay
Task ID: 7ab3f352-7ba5-4132-9db4-aeda2affb2a0
Branch: feature/p2-2-1-decay
Step: 2 of 5 (audit ✓ → contract → packet → implement → verify)
Audit: docs/audits/p2-2-1-decay-audit.md
Locked at this step. Any deviation in implementation MUST update this contract first (Step 4 cannot drift from Step 2).
§1. Goal
Provide pure per-domain reputation decay over ReputationRow (P2.1.1), wrapped
around the κ decay() primitive (P1.1.1) with per-domain rates from
bps-constants.ts (P1.1.3). Decay is computed at read-time; it never mutates
storage, never modifies last_activity_epoch, and is composable for batch
reads of up to 10k rows.
§2. Public TypeScript surface
Exported from src/domains/reputation/decay.ts:
import type { Domain, ReputationRow } from './schema.js';
/**
* Return the per-epoch decay rate (basis points) for a given reputation domain.
*
* Exhaustive over `Domain`. Adding a sixth domain becomes a TS compile error
* via the `never` exhaustiveness check.
*/
export function rate_for(domain: Domain): bigint;
/**
* Apply decay to a single reputation row at `current_epoch`.
*
* inactive_epochs = max(0n, current_epoch - BigInt(row.last_activity_epoch))
* new_score = decay(BigInt(row.score), rate_for(row.domain), inactive_epochs)
*
* - Returns a NEW row with `score` updated and every other field preserved.
* - `last_activity_epoch` is NEVER modified — that field is the event-write
* path's responsibility (P2.1.2 / future P2.x).
* - Pure: no I/O, no clock, no RNG, no globals.
* - Throws `EpochCeilingError` from `decay()` if `inactive_epochs > MAX_DECAY_EPOCHS`.
*/
export function apply_decay(row: ReputationRow, current_epoch: bigint): ReputationRow;
/**
* Apply decay to many rows in one pass. Pure map over `apply_decay`.
*
* Designed for read-path fan-out: a single `reputation_get` call may resolve
* dozens of rows. Smoke perf target: 10,000 rows complete under 50ms on
* dev hardware.
*/
export function apply_decay_batch(rows: readonly ReputationRow[], current_epoch: bigint): ReputationRow[];
No additional exports. No re-exports.
§3. Behavioural contract
§3.1 rate_for(domain)
| Input | Output |
|---|---|
'execution' |
DECAY_EXECUTION === 500n |
'commissioning' |
DECAY_COMMISSIONING === 300n |
'arbitration' |
DECAY_ARBITRATION === 1_000n |
'governance' |
DECAY_GOVERNANCE === 200n |
'social' |
DECAY_SOCIAL === 100n |
Implementation MUST be an exhaustive switch (domain) with the unreachable
default branch narrowing to never — so that TS rejects a sixth-domain
addition at compile time.
§3.2 apply_decay(row, current_epoch)
Pseudocode (algorithm only; the implementation may inline these):
const last = BigInt(row.last_activity_epoch);
const delta = current_epoch - last;
const inactive_epochs = delta < 0n ? 0n : delta;
if (inactive_epochs === 0n) return row;
const rate = rate_for(row.domain);
const new_score = decay(BigInt(row.score), rate, inactive_epochs);
return { ...row, score: Number(new_score) };
Invariants:
| ID | Statement |
|---|---|
| B1 | inactive_epochs === 0n → return row (referentially equal — short-circuit). |
| B2 | current_epoch < last_activity_epoch (clock skew) → inactive_epochs = 0n → return row. |
| B3 | The returned object’s last_activity_epoch equals the input’s last_activity_epoch. |
| B4 | The returned object’s node_id, domain, scar_bps, ban_until_epoch are byte-identical to the input. |
| B5 | The input object is not mutated (verified by freezing the input in tests). |
| B6 | score is always an integer (Number(new_score) of a bigint in [0n, 10_000n] is exactly representable). |
| B7 | Score floor at 0 is preserved (delegated to decay() AC#7 + verified at this layer). |
| B8 | When inactive_epochs > MAX_DECAY_EPOCHS, EpochCeilingError propagates unchanged. |
§3.3 apply_decay_batch(rows, current_epoch)
Pseudocode:
return rows.map((row) => apply_decay(row, current_epoch));
Invariants:
| ID | Statement |
|---|---|
| C1 | Pure map — no shared mutable state, no per-row IO. |
| C2 | rows.length === 0 → returns []. |
| C3 | Output array length equals input array length. |
| C4 | Order of output matches order of input. |
| C5 | Each output element is independently a valid apply_decay result (same per-row contract). |
| C6 | Perf smoke: 10,000 mixed-domain rows complete the call under 50ms on dev hardware. |
§4. Invariants and forbidden patterns
| ID | Statement | Verification |
|---|---|---|
| AX-01 | Pure functions; no I/O. | grep absence of import .* (?:better-sqlite3|fs|path|http). |
| AX-02 | No Math.*, Date.*, Math.random. |
grep absence. |
| AX-03 | No re-implementation of decay(). |
grep decay\( resolves only to the imported binding. |
| AX-04 | No mutation of input rows. | Test T8 uses Object.freeze and confirms output is a different object. |
| AX-05 | last_activity_epoch is never modified. |
Test T7 asserts equality across input/output. |
| AX-06 | rate_for is exhaustive (compile-time). |
A never-narrowed default branch. |
| AX-07 | Imports use .js suffix (NodeNext ESM). |
grep from '\..*\.js' style. |
| AX-08 | bigint ↔ number conversion is bounded to the function body. | No public function returns or accepts bigint for the score field. |
§5. Errors
EpochCeilingError(fromdecay()) propagates wheninactive_epochs > 10_000n.UnderflowErrorcannot fire —max(0n, …)is applied before callingdecay().- No other errors are thrown by this module.
§6. Imports surface
import { decay } from '../rules/integer-math.js';
import {
DECAY_ARBITRATION,
DECAY_COMMISSIONING,
DECAY_EXECUTION,
DECAY_GOVERNANCE,
DECAY_SOCIAL,
} from '../rules/bps-constants.js';
import type { Domain, ReputationRow } from './schema.js';
No other imports. The transitive surface stays inside the κ + λ-foundation slices.
§7. Test coverage map
src/__tests__/domains/reputation/decay.test.ts — table follows.
| ID | Group | Statement |
|---|---|---|
| T1 | rate_for | returns DECAY_EXECUTION (500n) for 'execution' |
| T2 | rate_for | returns DECAY_COMMISSIONING (300n) for 'commissioning' |
| T3 | rate_for | returns DECAY_ARBITRATION (1_000n) for 'arbitration' |
| T4 | rate_for | returns DECAY_GOVERNANCE (200n) for 'governance' |
| T5 | rate_for | returns DECAY_SOCIAL (100n) for 'social' |
| T6 | apply_decay | row at epoch 100 / current 100 → identity (=== short-circuit) |
| T7 | apply_decay | row at epoch 100 / current 90 (clock skew) → identity |
| T8 | apply_decay | row at epoch 100 / current 110 / execution → matches decay(score, 500n, 10n) |
| T9 | apply_decay | row with score = 0 stays at 0 (floor) |
| T10 | apply_decay | output last_activity_epoch equals input (B3) |
| T11 | apply_decay | Object.freeze(row) input — call does not throw and returns a fresh object (B5) |
| T12 | apply_decay | each of 5 domains decays at its canonical rate |
| T13 | apply_decay_batch | 10,000 rows complete under 50ms (perf smoke) |
| T14 | apply_decay_batch | empty array → empty array (C2) |
| T15 | apply_decay_batch | mixed-domain batch decays each row at its own rate |
| T16 | apply_decay_batch | output length equals input length (C3 + C4 ordering) |
| T17 | property | compound effect — decay(x, r, e) ≠ linear x - x*r*e/10000 (compounding ≠ linear) |
| T18 | property | determinism — same input twice → deep-equal output |
| T19 | apply_decay | propagates EpochCeilingError when inactive_epochs > MAX_DECAY_EPOCHS |
| T20 | type | TS: rate_for accepts every member of DOMAINS (compile-time check via DOMAINS.forEach). |
Note on T17 design: The original prompt suggested testing
decay(decay(x, r, e1), r, e2) ≠ decay(x, r, e1+e2). We verified during the packet step that this is in fact equal —decay()is associative under same-rate composition because per-step flooring is idempotent across cuts. The genuine non-linearity of compound decay is that it diverges from the linear projection (a 5% rate applied for 2 epochs reduces value by 9.75%, not 10%). T17 tests that real property.
Acceptance: every test above must pass. Coverage target is high enough that mutation-style invariants (B3–B5) are tested explicitly, not implicitly.
§8. Build / lint / test gates
All three gates from CLAUDE.md §5 must be green before PR open:
npm run build
npm run lint
npm test
Baseline at origin/main (P2.1.1 shipped): 2444 passing. Expected delta:
+20 tests (one per T1–T20 row above), zero regressions, total ~2464.
§9. Sign-off
- Audit complete: §11 gates all checked.
- Contract locked.
- Proceed to Step 3 (packet).