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:

  1. score := 0n
  2. 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
  3. floor: score := max(0n, score)
  4. ceiling: score := min(score, BPS_100_PERCENT - scar_bps_for(node_id, domain))
  5. return score

Acceptance criteria (literally from the prompt):

  • AC-1 compute_score(node_id, domain, events) → bigint — Σ(ack_weight × event_outcome).
  • AC-2 ack_weight is the acknowledger’s reputation in the same domain, bounded to prevent feedback loops (cap at the acknowledger’s current score and never exceed BPS_100_PERCENT = 10000n).
  • AC-3 All arithmetic uses src/domains/rules/integer-math.ts helpers (bps_mul, bps_div, safe_mul, safe_div).
  • AC-4 Score cap: min(10000n - scar_bps, computed) — never exceeds 10000 - 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_weight cap holds — no acknowledger ever contributes more than their own current score.
  • AC-9 No Math.*, no Date.*, no Math.random. Validated by the κ P1.1.2 determinism scanner (extend its globs to src/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:

  • DOMAINS readonly tuple of the 5 canonical domains
  • Domain union
  • ReputationRow interface — includes scar_bps: number
  • ReputationHistoryRow interface — includes id, node_id, domain, epoch, delta: number, reason, event_id
  • selectReputation, 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 for src/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

  1. bigint × number throws TypeError. event.delta from the SQLite layer is number. The bridge MUST be BigInt(event.delta) at the first use point. Caught at TS compile-time too, but the runtime error message is helpful to engineers.

  2. src/domains/reputation/schema.ts uses Math.max / Math.min for offset/limit clamping (line 256–257). These are library calls on number (not RNG, not clock), but the FORBIDDEN_PATTERNS scanner has no semantic distinction — it treats Math.* as a single token class. So I must exclude schema.ts from the new corpus self-scan, matching the κ scanner’s own self-exclusion pattern (it excludes determinism.ts). This is a one-line exclusion in the new test file; not a regression because schema.ts ships as number-typed APIs by design (P2.1.1 contract §2).

  3. event_id as 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 in event.event_id to ack_lookup. If a future caller encodes the acker as event.reason, the call site adapter (not compute) handles it. Locked: compute uses event.event_id.

  4. Sort stability. Source prompt step 2 says “for each event (in epoch ASC order)”. selectHistory returns 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’s Array.prototype.sort is stable in V8 16+ (≥ Chrome 70 / Node 12). Defensive total-ordering is belt + suspenders.

  5. bps_mul overflow. bps_mul(BigInt(MAX_INT32), 10_000n) = BigInt(MAX_INT32) — roundtrip safe. But if delta is 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 by N × BPS_100_PERCENT. For N < 2^49 this stays inside int64. Realistic history sizes are bounded by selectHistory’s default limit (100, max 1000). Safe. No need for safe_mul.

  6. scar_bps_for semantics. Caller-supplied. If scar_bps_for(...) > BPS_100_PERCENT then BPS_100_PERCENT - scar > 0 becomes false (bigint subtraction does not throw). Compute clamps with if (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.

  7. Test corpus self-scan exclusion symmetry. κ excludes determinism.ts from its self-scan because that file holds the forbidden-pattern regexes themselves. λ’s new scanner test excludes schema.ts because that file legitimately uses Math.max/min on number. 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.

  8. No fast-check. package.json does not depend on fast-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.ts co-located under src/__tests__/domains/reputation/).
  • No schema changes; no migration; no MCP tool.
  • compute_score signature: (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 opaque scar_lookup because §3 above promotes scar retrieval to a caller-supplied function (mirrors ack_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; excludes schema.ts for the documented reason in §5.7.
  • No edits to existing files. No edits to src/domains/reputation/schema.ts. No edits to src/__tests__/domains/rules/determinism.test.ts. No edits to src/server.ts.
  • Build / lint / test gates per CLAUDE.md §5.

Back to top

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

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