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 (from decay()) propagates when inactive_epochs > 10_000n.
  • UnderflowError cannot fire — max(0n, …) is applied before calling decay().
  • 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).

Back to top

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

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