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
-
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. -
src/__tests__/domains/reputation/compute.test.ts— unit + property tests. ~380 LOC. New test path (thesrc/__tests__/domains/reputation/directory was created in P2.1.1; onlyschema.test.tslives there at base SHA). -
src/__tests__/domains/reputation/determinism.test.ts— corpus self-scan test scoped tosrc/domains/reputation/**. Excludesschema.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:
U-1: empty events returns 0nU-2: single positive event with ack=10000n returns event.deltaU-3: ack capped at BPS_100_PERCENT when lookup returns oversizedU-4: ack=5000n gives floor(delta/2)U-5: scar=2000n reduces ceiling to 8000nU-6: scar > BPS_100_PERCENT clamped to BPS_100_PERCENT → ceiling=0nU-7: negative ack clamped to 0n; contribution is 0nU-8: out-of-domain event is skippedU-9: out-of-node event is skippedU-10: shuffled input produces same output as sorted inputU-11: negative aggregate clamped to 0n by floorU-12: result is always bigint
Property (1000 iterations each, seeded):
P-1: monotonic under positive-only deltasP-2: deterministic — two runs produce identical outputP-3: ack-cap invariant — per-event contribution ≤ |delta|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_lookupthat 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_lookupwhose output depends only on(acker_id, domain)(pure) - call
compute_scoretwice 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_lookupthat always returnsBigInt(lcg.next() % 1_000_000n)— frequently exceedsBPS_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.