Packet — P2.1.2 Score Computation

Task ID: fcce8362-60c1-492b-aef3-9546a4e63ce6 Branch: feature/p2-1-2-score-compute Step: 3 of 5 (audit ✓ → contract ✓ → packet → implement → verify)

Execution plan. Locked file order, locked test names, locked commit map. Packet gates Step 4; any change requires updating it first.

§1. File order

  1. src/domains/reputation/compute.ts — pure function + types. ~110 LOC excl. comments. No file in the repo depends on it at compile time yet; downstream P2.4.1 / P2.5.1 will import it.

  2. src/__tests__/domains/reputation/compute.test.ts — unit + property tests. ~380 LOC. New test path (the src/__tests__/domains/reputation/ directory was created in P2.1.1; only schema.test.ts lives there at base SHA).

  3. src/__tests__/domains/reputation/determinism.test.ts — corpus self-scan test scoped to src/domains/reputation/**. Excludes schema.ts. ~80 LOC.

No other files touched. No edits to:

  • src/domains/reputation/schema.ts (P2.1.1 contract surface — frozen).
  • src/__tests__/domains/rules/determinism.test.ts (κ scanner — out of scope).
  • src/domains/rules/*.ts (κ files — out of scope).
  • src/server.ts (no MCP tool ships in P2.1.2).
  • package.json (no new deps; hand-rolled LCG, no fast-check).

§2. compute.ts contents

Layout:

src/domains/reputation/compute.ts
├── header doctrine (canonical refs, invariants list)
├── type AckLookup
├── type ScarLookup
├── const BPS_100_PERCENT (re-imported from bps-constants.ts)
├── function compute_score
└── (nothing else — single export of the function + 2 types)

The function body:

export function compute_score(
  node_id: string,
  domain: Domain,
  events: readonly ReputationHistoryRow[],
  ack_lookup: AckLookup,
  scar_lookup: ScarLookup,
): bigint {
  // Sort defensively by (epoch ASC, id ASC). Total comparator → deterministic.
  const sorted = [...events].sort((a, b) =>
    a.epoch !== b.epoch ? a.epoch - b.epoch : a.id - b.id,
  );

  let score = 0n;
  for (const event of sorted) {
    if (event.domain !== domain) continue;
    if (event.node_id !== node_id) continue;
    const ackRaw = ack_lookup(event.event_id, domain);
    const ackNonNeg = ackRaw < 0n ? 0n : ackRaw;
    const ackCapped =
      ackNonNeg < BPS_100_PERCENT ? ackNonNeg : BPS_100_PERCENT;
    const weighted = bps_mul(BigInt(event.delta), ackCapped);
    score = score + weighted;
  }

  if (score < 0n) score = 0n;
  let scar = scar_lookup(node_id, domain);
  if (scar < 0n) scar = 0n;
  if (scar > BPS_100_PERCENT) scar = BPS_100_PERCENT;
  const ceiling = BPS_100_PERCENT - scar;
  if (score > ceiling) score = ceiling;
  return score;
}

All branching uses ternary / if — no Math.min / Math.max. All arithmetic is bigint via the imported bps_mul. No async, no Date.

Imports (locked)

import { bps_mul } from '../rules/integer-math.js';
import { BPS_100_PERCENT } from '../rules/bps-constants.js';
import type { Domain, ReputationHistoryRow } from './schema.js';

§3. compute.test.ts contents

Block structure mirrors src/__tests__/domains/reputation/schema.test.ts:

describe('compute_score — unit', () => { U-1 … U-12 })
describe('compute_score — property (1000 iterations)', () => { P-1 … P-4 })

§3.1 Test fixtures

Hand-rolled deterministic helpers in the test file:

// LCG with pinned seed → 100% reproducible byte-for-byte across CI runs.
const LCG_SEED = 0x1f9bc0deafn;
const LCG_MUL = 0x5851f42d4c957f2dn;
const LCG_INC = 0x14057b7ef767814fn;
const LCG_MOD = 1n << 64n;

class LCG {
  constructor(public state: bigint) {}
  next(): bigint {
    this.state = (this.state * LCG_MUL + LCG_INC) % LCG_MOD;
    return this.state;
  }
  // Returns a bigint in [0n, max).
  nextRange(max: bigint): bigint {
    return this.next() % max;
  }
  // Returns a JS number in [0, max).
  nextNumber(max: number): number {
    return Number(this.nextRange(BigInt(max)));
  }
}

A makeEvent(id, epoch, delta, eventId) factory yields a ReputationHistoryRow with node_id='alice', domain='execution', reason='test'.

§3.2 Test names (exact)

Unit:

  1. U-1: empty events returns 0n
  2. U-2: single positive event with ack=10000n returns event.delta
  3. U-3: ack capped at BPS_100_PERCENT when lookup returns oversized
  4. U-4: ack=5000n gives floor(delta/2)
  5. U-5: scar=2000n reduces ceiling to 8000n
  6. U-6: scar > BPS_100_PERCENT clamped to BPS_100_PERCENT → ceiling=0n
  7. U-7: negative ack clamped to 0n; contribution is 0n
  8. U-8: out-of-domain event is skipped
  9. U-9: out-of-node event is skipped
  10. U-10: shuffled input produces same output as sorted input
  11. U-11: negative aggregate clamped to 0n by floor
  12. U-12: result is always bigint

Property (1000 iterations each, seeded):

  1. P-1: monotonic under positive-only deltas
  2. P-2: deterministic — two runs produce identical output
  3. P-3: ack-cap invariant — per-event contribution ≤ |delta|
  4. P-4: result is bigint regardless of input shape

§3.3 P-1 monotonicity strategy

For each iteration:

  • random N ∈ [0, 50]
  • N events with delta = lcg.nextNumber(500) + 1 (always positive, in [1, 500])
  • random ack_lookup that returns a value in [0n, 9999n] without exceeding BPS_100_PERCENT (so the cap never bites and the monotonicity is exposed)
  • scar = 0n (so ceiling=10000n; only floor matters)
  • compute partial scores after events[0..k] for k = 1..N
  • assert each partial ≥ previous (note: post-ceiling-clamp may flatten; we compare with the explicit clamp-aware check partial[k] ≥ min(partial[k-1], 10000n))

§3.4 P-2 determinism strategy

For each iteration:

  • random N events with random deltas (positive or negative)
  • random ack_lookup whose output depends only on (acker_id, domain) (pure)
  • call compute_score twice with the same arguments
  • assert r1 === r2

§3.5 P-3 ack-cap strategy

For each iteration:

  • single event with delta = lcg.nextNumber(10000) + 1 (positive)
  • ack_lookup that always returns BigInt(lcg.next() % 1_000_000n) — frequently exceeds BPS_100_PERCENT
  • compute score
  • assert score ≤ BigInt(delta) (cap holds: weighted ≤ delta × 10000 / 10000 = delta)
  • assert score ≥ 0n

§3.6 P-4 type strategy

For each iteration:

  • random N ∈ [0, 100] events with random deltas
  • compute
  • assert typeof result === 'bigint'

§4. determinism.test.ts contents

Structure mirrors src/__tests__/domains/rules/determinism.test.ts Group 12:

// src/__tests__/domains/reputation/determinism.test.ts
import { readdir, readFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

// Tightly-scoped exclusion list. schema.ts uses Math.max/Math.min on `number`
// for offset/limit clamping (P2.1.1 contract §3 — number-typed surface by
// design); compute.ts is bigint-only. The exclusion is symmetric with the κ
// scanner's exclusion of determinism.ts (the scanner can't include its own
// pattern library). Adding a new file under src/domains/reputation/ without
// updating this exclusion is intentional: any new file must be Math-free.
const EXCLUDE = new Set<string>(['schema.ts']);

function stripComments(src: string): string {
  const noBlock = src.replace(/\/\*[\s\S]*?\*\//g, '');
  const noLine = noBlock.replace(/(^|[^:])\/\/[^\n]*/g, '$1');
  return noLine;
}

describe('reputation corpus self-scan', () => {
  it('no forbidden tokens in src/domains/reputation/*.ts (excluding schema.ts)', async () => {
    const thisFile = fileURLToPath(import.meta.url);
    const reputationDir = join(
      dirname(thisFile),
      '..', '..', '..',
      'domains', 'reputation',
    );
    const entries = await readdir(reputationDir, { withFileTypes: true });

    const patterns = [
      { re: /\bMath\.[A-Za-z_]\w*/g, label: 'Math.*' },
      { re: /\bDate\.[A-Za-z_]\w*/g, label: 'Date.*' },
      { re: /\bnew\s+Date\b/g, label: 'new Date' },
      { re: /\b(?:setTimeout|setInterval|setImmediate)\b/g, label: 'timer' },
      { re: /\b(?:fetch|XMLHttpRequest)\b/g, label: 'network' },
      { re: /\brequire\s*\(\s*['"](?:fs|node:fs)['"]/g, label: 'require(fs)' },
      { re: /\bfrom\s+['"](?:fs|node:fs)['"]/g, label: 'import fs' },
      { re: /\bcrypto\.[A-Za-z_]\w*/g, label: 'crypto.*' },
      { re: /\bprocess\.(?:hrtime|nextTick)\b/g, label: 'process.time' },
      { re: /\bawait\b/g, label: 'await' },
      { re: /\basync\s+(?:function|\()/g, label: 'async fn' },
      { re: /(?<![0-9n])\b\d+\.\d+\b/g, label: 'float literal' },
    ];

    let scanned = 0;
    for (const entry of entries) {
      if (!entry.isFile()) continue;
      if (!entry.name.endsWith('.ts')) continue;
      if (EXCLUDE.has(entry.name)) continue;
      const raw = await readFile(join(reputationDir, entry.name), 'utf8');
      const src = stripComments(raw);
      for (const { re, label } of patterns) {
        const m = src.match(re);
        if (m && m.length > 0) {
          throw new Error(
            `forbidden pattern '${label}' in src/domains/reputation/${entry.name}: ${m.join(', ')}`,
          );
        }
      }
      scanned += 1;
    }
    expect(scanned).toBeGreaterThanOrEqual(1);
  });

  it('compute.ts exists and is scanned', async () => {
    // Concrete check: scanned set must include compute.ts. Belt + suspenders.
    const thisFile = fileURLToPath(import.meta.url);
    const reputationDir = join(
      dirname(thisFile),
      '..', '..', '..',
      'domains', 'reputation',
    );
    const entries = await readdir(reputationDir, { withFileTypes: true });
    const names = entries.filter((e) => e.isFile()).map((e) => e.name);
    expect(names).toContain('compute.ts');
  });
});

The compute.ts exists and is scanned test guards against future refactors that accidentally rename compute.ts — without it, the first test would silently scan zero non-excluded files and pass (the scanned >= 1 guard fails open if all files get excluded, but only schema.ts is excluded; future drift would land outside the exclusion list and trip the first test).

§5. Commit map (locked)

Step Commit subject
1 (audit, done) audit(p2-1-2-score-compute): inventory surface
2 (contract, done) contract(p2-1-2-score-compute): behavioral contract
3 (packet, this) packet(p2-1-2-score-compute): execution plan
4 (implement) feat(p2-1-2-score-compute): Σ-aggregation with ack-weight cap + monotonicity property test
5 (verify) verify(p2-1-2-score-compute): test evidence

§6. Gates (CLAUDE.md §5)

Before claiming Step 5 done in the worktree:

npm run build
npm run lint
npm test

All three must pass. The test count baseline per memory at SHA 994db1e4 is 2444 (post-P2.1.1). Expected delta after this PR: +15 to +20 (12 unit + 4 property + 2 scanner = ~18 net new test cases). No regression budget.

§7. Hand-off readiness for next step (Implement)

Implementation is mechanical from §2 + §3 + §4. The packet contains the function body verbatim, the exact test names, and the scanner-test structure. No design ambiguity remains at this gate.


Back to top

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

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