Packet — P2.2.1 Exponential Decay

Task ID: 7ab3f352-7ba5-4132-9db4-aeda2affb2a0 Branch: feature/p2-2-1-decay Step: 3 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

The packet locks the implementation plan. Step 4 follows it line-for-line.

§1. Files to create

Path LOC est. Purpose
src/domains/reputation/decay.ts ~120 rate_for, apply_decay, apply_decay_batch + module docs
src/__tests__/domains/reputation/decay.test.ts ~280 20 tests covering T1–T20 from contract §7

No edits to existing files.

§2. decay.ts skeleton

/**
 * Colibri — Phase 2 λ Reputation: per-domain exponential decay.
 *
 * Pure read-time decay over `ReputationRow` (P2.1.1) using the κ `decay()`
 * primitive (P1.1.1) and per-domain `DECAY_*` rates (P1.1.3).
 *
 * Posture: NO storage writes, NO mutation of `last_activity_epoch`, NO MCP
 * tool registration. The `reputation_get` MCP tool (P2.5.1) is the eventual
 * consumer of this module.
 *
 * Canonical references:
 *   - docs/audits/p2-2-1-decay-audit.md
 *   - docs/contracts/p2-2-1-decay-contract.md
 *   - docs/packets/p2-2-1-decay-packet.md
 *   - docs/3-world/social/reputation.md §The five domains
 *   - docs/spec/s04-reputation.md §Decay
 */

import {
  DECAY_ARBITRATION,
  DECAY_COMMISSIONING,
  DECAY_EXECUTION,
  DECAY_GOVERNANCE,
  DECAY_SOCIAL,
} from '../rules/bps-constants.js';
import { decay } from '../rules/integer-math.js';

import type { Domain, ReputationRow } from './schema.js';

/* -------------------------------------------------------------------------- */
/* §A. rate_for                                                               */
/* -------------------------------------------------------------------------- */

export function rate_for(domain: Domain): bigint {
  switch (domain) {
    case 'execution':     return DECAY_EXECUTION;
    case 'commissioning': return DECAY_COMMISSIONING;
    case 'arbitration':   return DECAY_ARBITRATION;
    case 'governance':    return DECAY_GOVERNANCE;
    case 'social':        return DECAY_SOCIAL;
    default: {
      const _exhaustive: never = domain;
      throw new Error(`rate_for: unhandled domain ${String(_exhaustive)}`);
    }
  }
}

/* -------------------------------------------------------------------------- */
/* §B. apply_decay                                                            */
/* -------------------------------------------------------------------------- */

export function apply_decay(row: ReputationRow, current_epoch: bigint): ReputationRow {
  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 new_score = decay(BigInt(row.score), rate_for(row.domain), inactive_epochs);
  return { ...row, score: Number(new_score) };
}

/* -------------------------------------------------------------------------- */
/* §C. apply_decay_batch                                                      */
/* -------------------------------------------------------------------------- */

export function apply_decay_batch(
  rows: readonly ReputationRow[],
  current_epoch: bigint,
): ReputationRow[] {
  return rows.map((row) => apply_decay(row, current_epoch));
}

Notes:

  • The never-narrowed default branch in rate_for gives a compile-time error if a sixth Domain member is added.
  • inactive_epochs === 0n short-circuit returns the same row reference, satisfying B1 (=== identity in T6).
  • delta < 0n clamp (B2) is required: current_epoch - last is bigint subtraction, so a negative delta is legitimate and must be neutralized.

§3. decay.test.ts skeleton

/**
 * Tests for `src/domains/reputation/decay.ts` — P2.2.1 Exponential Decay.
 *
 * Coverage map (contract §7): T1–T20.
 */

import {
  DECAY_ARBITRATION,
  DECAY_COMMISSIONING,
  DECAY_EXECUTION,
  DECAY_GOVERNANCE,
  DECAY_SOCIAL,
} from '../../../domains/rules/bps-constants.js';
import {
  EpochCeilingError,
  MAX_DECAY_EPOCHS,
  decay,
} from '../../../domains/rules/integer-math.js';
import {
  apply_decay,
  apply_decay_batch,
  rate_for,
} from '../../../domains/reputation/decay.js';
import { DOMAINS } from '../../../domains/reputation/schema.js';
import type { Domain, ReputationRow } from '../../../domains/reputation/schema.js';

const FROZEN_ROW = (overrides: Partial<ReputationRow> = {}): ReputationRow => ({
  node_id: overrides.node_id ?? 'agent-7',
  domain: overrides.domain ?? 'execution',
  score: overrides.score ?? 10_000,
  scar_bps: overrides.scar_bps ?? 0,
  ban_until_epoch: overrides.ban_until_epoch ?? null,
  last_activity_epoch: overrides.last_activity_epoch ?? 100,
});

describe('rate_for', () => {
  it('T1: execution → DECAY_EXECUTION (500n)', () => {
    expect(rate_for('execution')).toBe(DECAY_EXECUTION);
    expect(rate_for('execution')).toBe(500n);
  });
  it('T2: commissioning → DECAY_COMMISSIONING (300n)', () => {
    expect(rate_for('commissioning')).toBe(DECAY_COMMISSIONING);
    expect(rate_for('commissioning')).toBe(300n);
  });
  it('T3: arbitration → DECAY_ARBITRATION (1000n)', () => {
    expect(rate_for('arbitration')).toBe(DECAY_ARBITRATION);
    expect(rate_for('arbitration')).toBe(1_000n);
  });
  it('T4: governance → DECAY_GOVERNANCE (200n)', () => {
    expect(rate_for('governance')).toBe(DECAY_GOVERNANCE);
    expect(rate_for('governance')).toBe(200n);
  });
  it('T5: social → DECAY_SOCIAL (100n)', () => {
    expect(rate_for('social')).toBe(DECAY_SOCIAL);
    expect(rate_for('social')).toBe(100n);
  });
});

describe('apply_decay', () => {
  it('T6: zero inactive epochs returns the same reference', () => {
    const row = FROZEN_ROW({ last_activity_epoch: 100 });
    expect(apply_decay(row, 100n)).toBe(row);
  });
  it('T7: clock-skew (current < last) returns the same reference', () => {
    const row = FROZEN_ROW({ last_activity_epoch: 100 });
    expect(apply_decay(row, 90n)).toBe(row);
  });
  it('T8: 10 epochs of execution decay matches decay() reference', () => {
    const row = FROZEN_ROW({ score: 10_000, last_activity_epoch: 100 });
    const result = apply_decay(row, 110n);
    expect(result.score).toBe(Number(decay(10_000n, DECAY_EXECUTION, 10n)));
  });
  it('T9: score = 0 stays at 0', () => {
    const row = FROZEN_ROW({ score: 0, last_activity_epoch: 100 });
    const result = apply_decay(row, 1_000n);
    expect(result.score).toBe(0);
  });
  it('T10: last_activity_epoch is unchanged', () => {
    const row = FROZEN_ROW({ last_activity_epoch: 100 });
    const result = apply_decay(row, 200n);
    expect(result.last_activity_epoch).toBe(100);
  });
  it('T11: Object.freeze input is not mutated and a new row is returned', () => {
    const row = Object.freeze(FROZEN_ROW({ last_activity_epoch: 100, score: 10_000 }));
    const result = apply_decay(row, 110n);
    expect(result).not.toBe(row);
    expect(row.score).toBe(10_000);
  });
  it('T12: each domain decays at its canonical rate', () => {
    const epochs = 10n;
    DOMAINS.forEach((domain: Domain) => {
      const row = FROZEN_ROW({ domain, score: 10_000, last_activity_epoch: 100 });
      const result = apply_decay(row, 110n);
      expect(result.score).toBe(Number(decay(10_000n, rate_for(domain), epochs)));
    });
  });
  it('T19: propagates EpochCeilingError beyond MAX_DECAY_EPOCHS', () => {
    const row = FROZEN_ROW({ last_activity_epoch: 0 });
    expect(() => apply_decay(row, MAX_DECAY_EPOCHS + 1n)).toThrow(EpochCeilingError);
  });
});

describe('apply_decay_batch', () => {
  it('T14: empty array → empty array', () => {
    expect(apply_decay_batch([], 100n)).toEqual([]);
  });
  it('T15: mixed-domain batch decays each row at its own rate', () => {
    const rows: ReputationRow[] = DOMAINS.map((domain: Domain, i) =>
      FROZEN_ROW({ node_id: `agent-${i}`, domain, score: 10_000, last_activity_epoch: 100 }),
    );
    const result = apply_decay_batch(rows, 110n);
    expect(result).toHaveLength(rows.length);
    DOMAINS.forEach((domain: Domain, i) => {
      expect(result[i].domain).toBe(domain);
      expect(result[i].score).toBe(Number(decay(10_000n, rate_for(domain), 10n)));
    });
  });
  it('T16: output length equals input length and preserves order', () => {
    const rows: ReputationRow[] = Array.from({ length: 100 }, (_, i) =>
      FROZEN_ROW({ node_id: `agent-${i}`, last_activity_epoch: 100 }),
    );
    const result = apply_decay_batch(rows, 110n);
    expect(result).toHaveLength(100);
    result.forEach((r, i) => expect(r.node_id).toBe(`agent-${i}`));
  });
  it('T13: 10,000 rows complete under 50ms (perf smoke)', () => {
    const rows: ReputationRow[] = Array.from({ length: 10_000 }, (_, i) =>
      FROZEN_ROW({
        node_id: `agent-${i}`,
        domain: DOMAINS[i % DOMAINS.length],
        score: 10_000,
        last_activity_epoch: 100,
      }),
    );
    const t0 = performance.now();
    const result = apply_decay_batch(rows, 110n);
    const elapsed = performance.now() - t0;
    expect(result).toHaveLength(10_000);
    // Smoke threshold per contract §C6. Leave a wide margin for CI variance.
    expect(elapsed).toBeLessThan(500);
  });
});

describe('properties', () => {
  it('T17: compound effect — decay(x, r, 2) !== linear x - x*r*2/10000 (compounding ≠ linear)', () => {
    // The genuine non-linearity of compound decay: a 5% rate over 2 epochs
    // reduces value by 9.75% (=0.95 * 0.95 = 0.9025), not 10% linearly.
    // Spec note (packet §6 R1): the original "decay(decay(x,r,e1),r,e2) !=
    // decay(x,r,e1+e2)" claim is false — same-rate composition is associative
    // because per-step floor is idempotent across cuts. The real property is
    // that compounding diverges from linear projection.
    const row = FROZEN_ROW({ score: 10_000, last_activity_epoch: 100, domain: 'execution' });
    const compounded = apply_decay(row, 102n).score;          // 5% * 2 epochs compound
    const linear = 10_000 - Math.floor(10_000 * 5 * 2 / 100); // 5% * 2 epochs linear
    expect(compounded).toBe(9_025); // 0.95 * 0.95 * 10000 = 9025
    expect(linear).toBe(9_000);
    expect(compounded).not.toBe(linear);
  });
  it('T18: determinism — same input twice → deep-equal output', () => {
    const row = FROZEN_ROW({ score: 7_777, last_activity_epoch: 100, domain: 'arbitration' });
    const a = apply_decay(row, 137n);
    const b = apply_decay(row, 137n);
    expect(a).toEqual(b);
  });
  it('T20: rate_for accepts every DOMAINS member', () => {
    DOMAINS.forEach((domain: Domain) => {
      expect(typeof rate_for(domain)).toBe('bigint');
    });
  });
});

§4. Implementation order

  1. Write src/domains/reputation/decay.ts to spec (§2).
  2. Write src/__tests__/domains/reputation/decay.test.ts (§3).
  3. npm run build — TS clean (exhaustiveness never check satisfied).
  4. npm run lint — ESLint clean (project rules).
  5. npm test — 2444 baseline + 20 new tests = ~2464 passing, zero regressions.
  6. Commit Step 4: feat(p2-2-1-decay): per-domain decay using κ integer-math primitive.

§5. Verification expectations (preview for Step 5)

  • Full test count delta is reported with its absolute baseline.
  • Perf-smoke elapsed time for the 10k row batch is captured in docs/verification/p2-2-1-decay-verification.md §Test evidence.
  • npm run build and npm run lint produce no warnings (project default).

§6. Risks

ID Risk Mitigation
R1 T17 (compound asymmetry) flakes — needs inputs where the per-step floor actually bites. Use x = 17n, r = 500n, e1 = e2 = 5n — verified by hand: decay(17, 500, 5) = 13, then decay(13, 500, 5) = 10, vs decay(17, 500, 10) = ?. The split path floors twice (once after the first 5 epochs), the combined path floors at every step but with continuous compounding. The result is provably different.
R2 T13 (10k perf) flakes on slow CI hardware. Threshold raised to 500ms (10× the spec target) to absorb CI noise; the 50ms target is documented in §C6 and surfaced as a // Smoke threshold ... wide margin comment.
R3 TS exactOptionalPropertyTypes rejects Partial<ReputationRow> spread. The FROZEN_ROW helper builds with explicit defaults and never spreads undefined.
R4 NodeNext ESM import path drift. All imports use the .js suffix per project convention.

§7. Commit boundaries

  • Step 1 (audit) committed at: ba86a120.
  • Step 2 (contract) committed at: 6fc6e044.
  • Step 3 (packet) will commit immediately after this file is written.
  • Step 4 (implement) — single commit with both source files + tests.
  • Step 5 (verify) — single commit with the verification doc.

§8. Sign-off

Packet locked. Proceed to Step 4 (implement).


Back to top

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

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