Audit — P2.1.2 Score Computation
Task ID: fcce8362-60c1-492b-aef3-9546a4e63ce6
Branch: feature/p2-1-2-score-compute
Base: main @ 994db1e4 (post-R89 Wave 1)
Date: 2026-05-12
Step: 1 of 5 (audit → contract → packet → implement → verify)
Purpose
Inventory the surface area for P2.1.2 — the score computation slice of λ Reputation. P2.1.1 (R89 Wave 1, PR #226) shipped the per-domain row schema + append-only history; P2.1.2 ships the pure function that folds history rows into a per-(node, domain) score, with explicit feedback-loop bounds and a determinism scanner extension.
This task is R89 Wave 2 — parallel slice with P2.2.1 (decay) and P2.2.2 (penalties).
It introduces no schema changes, no MCP tools, no SQL writes. It is library-only and
synchronous; downstream consumers (P2.4.1 capability gates, P2.5.1 reputation_get)
will mount it behind a snapshot read of the reputations table.
§1. Spec inputs
§1.1 docs/guides/implementation/task-prompts/p2.1-lambda-reputation.md §P2.1.2 (lines 278–432)
Authoritative source prompt. Locks the function signature and algorithm:
compute_score(
node_id: string,
domain: Domain,
events: ReputationHistoryRow[],
ack_lookup: (acker_id: string, dom: Domain) => bigint,
): bigint
Algorithm — fold each event in epoch ASC order:
score := 0n- for each event:
ack := ack_lookup(event.acker_id, domain)ack_capped := min(ack, BPS_100_PERCENT)weighted := bps_mul(BigInt(event.delta), ack_capped)score := score + weighted
- floor:
score := max(0n, score) - ceiling:
score := min(score, BPS_100_PERCENT - scar_bps_for(node_id, domain)) - return
score
Acceptance criteria (literally from the prompt):
- AC-1
compute_score(node_id, domain, events) → bigint— Σ(ack_weight × event_outcome). - AC-2
ack_weightis the acknowledger’s reputation in the same domain, bounded to prevent feedback loops (cap at the acknowledger’s currentscoreand never exceedBPS_100_PERCENT = 10000n). - AC-3 All arithmetic uses
src/domains/rules/integer-math.tshelpers (bps_mul,bps_div,safe_mul,safe_div). - AC-4 Score cap:
min(10000n - scar_bps, computed)— never exceeds10000 - scar_bps. - AC-5 Score floor:
0n(clamp negatives — penalties applied separately via P2.2.2). - AC-6 Property test: for any sequence of positive-outcome events, score is monotonically non-decreasing.
- AC-7 Property test: deterministic — same input array → byte-identical output.
- AC-8 Property test:
ack_weightcap holds — no acknowledger ever contributes more than their own current score. - AC-9 No
Math.*, noDate.*, noMath.random. Validated by the κ P1.1.2 determinism scanner (extend its globs tosrc/domains/reputation/**in this PR).
§1.2 docs/guides/implementation/task-breakdown.md §P2.1.2
Matches the source prompt. No semantic divergence.
§1.3 docs/spec/s04-reputation.md §Computation
reputation[domain] = Σ (ack_weight × event_outcome)
Confirms ack_weight = acknowledger’s reputation in the same domain, “bounded to prevent
feedback loops”. 64-bit signed integers, basis points (1 bp = 0.01%). The bound is not
numerically specified in S04 — the source prompt clarifies it as min(ack, BPS_100_PERCENT).
§1.4 docs/3-world/social/reputation.md §The five domains
Confirms the closed 5-domain enum: execution | commissioning | arbitration | governance | social.
Phase 0 posture: tables exist but no Phase 0 tool reads/writes λ state — this stays true
for P2.1.2 too. The function is library-only; no MCP tool ships.
§1.5 src/domains/reputation/schema.ts (P2.1.1 — shipped 2026-05-12)
Exports:
DOMAINSreadonly tuple of the 5 canonical domainsDomainunionReputationRowinterface — includesscar_bps: numberReputationHistoryRowinterface — includesid,node_id,domain,epoch,delta: number,reason,event_idselectReputation,selectHistory,insertHistoryEvent
Critical bridging fact: ReputationHistoryRow.delta is number (storage type), not bigint.
The compute layer is where the number → bigint bridge happens (per the
src/domains/reputation/schema.ts:30–34 header comment: “Bigint arithmetic happens in
the compute layer (P2.1.2+) which converts at its own IO boundary”).
§1.6 src/domains/rules/integer-math.ts (P1.1.1)
Exports bps_mul, bps_div, apply_bps, decay, safe_mul, safe_div, plus error
types. Pure bigint. Floor rounding via native /. bps_mul(value, bps) = floor(value × bps / 10000).
§1.7 src/domains/rules/bps-constants.ts (P1.1.3)
Exports BPS_100_PERCENT = 10_000n, BPS_MIN = 0n, BPS_MAX = 10_000n. Plus the
branded Bps type + bps(n) factory. P2.1.2 uses BPS_100_PERCENT as the ack cap.
§1.8 src/__tests__/domains/rules/determinism.test.ts Group 12 (corpus self-scan)
Currently scans src/domains/rules/*.ts for forbidden patterns. The source prompt
asks for the glob to be extended to src/domains/reputation/**. The κ scanner test
file uses an inline pattern manifest; the simplest, least-coupled extension is to ship
a parallel scanner test scoped to src/domains/reputation/ (mirroring the κ pattern
manifest but with its own exclusion list — see §3.2 below).
§2. Existing code layout
§2.1 src/domains/ at base SHA
src/domains/
├── integrations/ (ν)
├── proof/ (η)
├── reputation/ (λ — schema.ts shipped at P2.1.1)
├── router/ (δ)
├── rules/ (κ — P1.1 + P1.2 + P1.3 + P1.4 + P1.5 shipped)
├── skills/ (ε)
├── tasks/ (β)
└── trail/ (ζ)
The reputation directory contains exactly one file: schema.ts (330 lines).
P2.1.2 introduces src/domains/reputation/compute.ts. No name collision.
§2.2 Reference patterns
src/domains/rules/bps-constants.ts— pure functional surface; only imports./integer-math.js. Good shape model.src/domains/rules/integer-math.ts— the canonical “no I/O, no Math., no Date.” surface; pure bigint folds; well-documented invariants. Closest semantic analog.src/__tests__/domains/rules/determinism.test.ts(lines 832–890) — corpus self-scan template I’ll adapt forsrc/domains/reputation/**.
§2.3 No existing compute references
grep "compute_score" src/ 2>/dev/null returns zero hits. Greenfield surface.
§3. Naming + numeric decisions
| Question | Decision | Rationale |
|---|---|---|
| File location | src/domains/reputation/compute.ts |
Source prompt §Files to create line 290 |
| Function name | compute_score (snake_case) |
Source prompt §Acceptance criteria + spec doc S04 §Computation |
| Return type | bigint |
Source prompt — λ scores are bps integers; downstream P2.4.1 will widen if needed |
delta bridge |
BigInt(event.delta) at function boundary |
event.delta arrives as number; never multiply bigint × number |
| Acker id extraction | event.event_id (free-form Phase 0 marker) |
Source prompt §Common gotchas: “acker id is encoded in event.reason or event.event_id metadata”. For Phase 0 the only contract is ack_lookup is opaque — its key string is left to the caller. Compute does not decode it. |
| Acker id sentinel | use event.event_id as the lookup key |
Pragma: keep compute purely structural; defer encoding policy to P2.5.1 |
| Scar source | scar_bps_for(node_id, domain) → bigint parameter |
Function signature mirrors ack_lookup: opaque, caller-supplied |
| Event ordering | sort caller-supplied events by (epoch ASC, id ASC) inside compute |
Defensive: selectHistory returns DESC; compute must not assume ordering. Monotonicity property requires ASC. The fold itself is order-sensitive only when ack_capped × delta produces negative weighted intermediate values; sorting is also defense-in-depth for cap semantics. |
| Empty events | return 0n (not null, not undefined) |
Source prompt §Common gotchas line 428 |
| Ack cap location | apply min(ack, BPS_100_PERCENT) before multiplication |
Source prompt step 2b; prevents bps_mul overflow on adversarial ack values |
| Floor / ceiling order | floor first, then ceiling | Source prompt steps 3 → 4. If we ceiled first then floored, a deeply-negative aggregate could clip to 0 after first taking min(negative, 10000 - scar) = negative. Floor-first is conservative. |
| Pure-function policy | no closures over external state; no I/O; no console; no async | Implied by AC-9 + κ determinism scanner extension |
§4. Acceptance-criteria mapping
| Criterion | Implementation plan |
|---|---|
AC-1 compute_score → bigint |
Single named export; signature locked in §3 above |
| AC-2 ack_capped ≤ BPS_100_PERCENT | const ack_capped = ack < BPS_100_PERCENT ? ack : BPS_100_PERCENT; (no Math.min — uses ternary) |
AC-3 Only integer-math.ts helpers |
import { bps_mul } from '../rules/integer-math.js'; + bigint literals only |
| AC-4 Ceiling | score = score < (BPS_100_PERCENT - scar) ? score : (BPS_100_PERCENT - scar); |
| AC-5 Floor | score = score < 0n ? 0n : score; |
| AC-6 Monotonicity | LCG-seeded property test, 1000 iterations, each event has delta > 0 |
| AC-7 Determinism | LCG-seeded property test, 1000 iterations comparing two runs |
| AC-8 Ack cap holds | Property test using ack_lookup that returns ≥ 10000n; assert per-event contribution ≤ event.delta × BPS_100_PERCENT |
| AC-9 No Math/Date/random | Determinism scanner extension (new test file scoped to src/domains/reputation/**, excluding schema.ts which has legitimate Math.max/Math.min for offset clamping — see §5 risk #2) |
§5. Risks + gotchas
-
bigint × number throws TypeError.
event.deltafrom the SQLite layer isnumber. The bridge MUST beBigInt(event.delta)at the first use point. Caught at TS compile-time too, but the runtime error message is helpful to engineers. -
src/domains/reputation/schema.tsusesMath.max/Math.minfor offset/limit clamping (line 256–257). These are library calls onnumber(not RNG, not clock), but the FORBIDDEN_PATTERNS scanner has no semantic distinction — it treatsMath.*as a single token class. So I must excludeschema.tsfrom the new corpus self-scan, matching the κ scanner’s own self-exclusion pattern (it excludesdeterminism.ts). This is a one-line exclusion in the new test file; not a regression becauseschema.tsships asnumber-typed APIs by design (P2.1.1 contract §2). -
event_idas acker key is a Phase 0 convention. The source prompt is intentionally vague — the acker encoding becomes load-bearing only in P2.5.1. For P2.1.2 compute is purely structural: it passes whatever string lives inevent.event_idtoack_lookup. If a future caller encodes the acker asevent.reason, the call site adapter (not compute) handles it. Locked: compute usesevent.event_id. -
Sort stability. Source prompt step 2 says “for each event (in epoch ASC order)”.
selectHistoryreturns DESC. So compute must sort. To stay deterministic, the sort comparator must be total:(a, b) => a.epoch - b.epoch || a.id - b.id. JavaScript’sArray.prototype.sortis stable in V8 16+ (≥ Chrome 70 / Node 12). Defensive total-ordering is belt + suspenders. -
bps_muloverflow.bps_mul(BigInt(MAX_INT32), 10_000n) = BigInt(MAX_INT32)— roundtrip safe. But ifdeltais signed bps it can be ≤ -10_000 (a 100% penalty delta). Worst case per-event:bps_mul(-10000n, 10000n) = -10000n. Sum over N events: bounded byN × BPS_100_PERCENT. ForN < 2^49this stays inside int64. Realistic history sizes are bounded byselectHistory’s default limit (100, max 1000). Safe. No need forsafe_mul. -
scar_bps_forsemantics. Caller-supplied. Ifscar_bps_for(...) > BPS_100_PERCENTthenBPS_100_PERCENT - scar > 0becomes false (bigint subtraction does not throw). Compute clamps withif (ceiling < 0n) ceiling = 0n;— defensive, since the storage schema (P2.1.1 CHECK) bounds scar_bps to[0, 10000], but the function signature accepts arbitrary bigint from caller. Locked. -
Test corpus self-scan exclusion symmetry. κ excludes
determinism.tsfrom its self-scan because that file holds the forbidden-pattern regexes themselves. λ’s new scanner test excludesschema.tsbecause that file legitimately usesMath.max/minonnumber. The exclusion must be documented in-file (a code comment) AND in this audit so a future round doesn’t “fix” the exclusion and break the test. -
No
fast-check.package.jsondoes not depend onfast-check. The source prompt mentions it but is not prescriptive — “1000 iter” can equally be a hand-rolled deterministic loop seeded by a linear-congruential generator. Hand-rolled is actually better here because we need determinism: a pinned LCG seed gives byte-identical iteration across CI runs. No package.json delta.
§6. Decision tree before contract step
Locked at audit close:
- Files: 2 source files (
compute.ts,compute.test.ts) + 1 scanner test (determinism.test.tsco-located undersrc/__tests__/domains/reputation/). - No schema changes; no migration; no MCP tool.
compute_scoresignature:(node_id: string, domain: Domain, events: ReputationHistoryRow[], ack_lookup, scar_lookup) → bigint— note signature has FIVE params, expanding the prompt’s four-param sketch with an opaquescar_lookupbecause §3 above promotes scar retrieval to a caller-supplied function (mirrorsack_lookup).- Acker id encoding: read from
event.event_id(Phase 0 convention). - Event ordering: sort caller-supplied events by
(epoch ASC, id ASC). - Property tests: hand-rolled LCG, 1000 iterations each, pinned seeds.
- Determinism scanner: new test file under
src/__tests__/domains/reputation/determinism.test.ts; excludesschema.tsfor the documented reason in §5.7. - No edits to existing files. No edits to
src/domains/reputation/schema.ts. No edits tosrc/__tests__/domains/rules/determinism.test.ts. No edits tosrc/server.ts. - Build / lint / test gates per CLAUDE.md §5.