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 inrate_forgives a compile-time error if a sixthDomainmember is added. inactive_epochs === 0nshort-circuit returns the samerowreference, satisfying B1 (===identity in T6).delta < 0nclamp (B2) is required:current_epoch - lastisbigintsubtraction, 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
- Write
src/domains/reputation/decay.tsto spec (§2). - Write
src/__tests__/domains/reputation/decay.test.ts(§3). npm run build— TS clean (exhaustivenessnevercheck satisfied).npm run lint— ESLint clean (project rules).npm test— 2444 baseline + 20 new tests = ~2464 passing, zero regressions.- 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
elapsedtime for the 10k row batch is captured indocs/verification/p2-2-1-decay-verification.md§Test evidence. npm run buildandnpm run lintproduce 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).