Contract — P2.1.2 Score Computation
Task ID: fcce8362-60c1-492b-aef3-9546a4e63ce6
Branch: feature/p2-1-2-score-compute
Step: 2 of 5 (audit ✓ → contract → packet → implement → verify)
Audit: docs/audits/p2-1-2-score-compute-audit.md
The behavioural contract for λ score computation. Locked at this step; any deviation in implementation must update this contract first.
§1. Goal
Ship a pure, deterministic, bigint-only function that folds an array of
ReputationHistoryRow events for a (node, domain) pair into a current score, with:
- ack_weight feedback-loop cap at
BPS_100_PERCENT = 10_000n. - score floor at
0n(negatives are downstream P2.2.2 penalty territory). - score ceiling at
BPS_100_PERCENT - scar_bps(node, domain). - arithmetic exclusively via
src/domains/rules/integer-math.ts. - monotonicity under positive-only event sequences.
- byte-identical determinism over identical inputs.
No schema change. No SQL write. No MCP tool. No Phase 0 surface mutation.
§2. Public TypeScript surface
Exported from src/domains/reputation/compute.ts:
import type { Domain, ReputationHistoryRow } from './schema.js';
/**
* Resolve the acknowledger's current bigint score for a (acker_id, domain).
* Returns `0n` when the acknowledger is unknown (the calling layer decides
* the policy; compute has no opinion).
*/
export type AckLookup = (acker_id: string, dom: Domain) => bigint;
/**
* Resolve the (node_id, domain)'s permanent scar_bps. Returns `0n` for an
* unscarred node. The caller policies which storage row this reads from.
*/
export type ScarLookup = (node_id: string, dom: Domain) => bigint;
/**
* Fold an event history into a current score.
*
* score(events) = Σ (clamp(ack(event.event_id, domain), 10000n) × delta)
*
* Clamped to [0n, 10_000n - scar(node_id, domain)] and floor-rounded
* via `bps_mul`.
*
* @param node_id — owner of the score
* @param domain — λ domain to fold over
* @param events — history rows; sorted internally by (epoch ASC, id ASC)
* @param ack_lookup — opaque acker → bigint resolver (caller-supplied)
* @param scar_lookup — opaque (node, domain) → bigint scar resolver
* @returns — current score in basis points, `bigint`, in [0n, 10000n]
*/
export function compute_score(
node_id: string,
domain: Domain,
events: readonly ReputationHistoryRow[],
ack_lookup: AckLookup,
scar_lookup: ScarLookup,
): bigint;
Forbidden exports (would violate AX-01 read-only invariant):
- Any function whose body mutates
events. - Any function whose body calls
Math.*,Date.*,Math.random,crypto.*,process.hrtime,setTimeout,fetch,XMLHttpRequest. - Any function that reads/writes the SQLite database directly.
- Any
asyncdeclaration (pure synchronous).
§3. Invariants
I1. Integer-only
All numeric quantities flow as bigint. The only number → bigint bridge is
BigInt(event.delta) at the read boundary. Constants come from bps-constants.ts.
No float literal anywhere in the file.
I2. No side effects
compute_score does not log, write, sort-in-place, or mutate events. Internal
sort produces a NEW array via [...events].sort(...).
I3. Deterministic
Given identical (node_id, domain, events, ack_lookup, scar_lookup) inputs (where the
lookups are themselves pure), compute_score returns byte-identical output across runs.
This is the property exercised by 1000-iteration determinism tests.
I4. Monotonic under positive-only inputs
If every event.delta > 0 and every ack_lookup(...) ≥ 0n, the partial fold is
monotonically non-decreasing before the final ceiling clamp. The post-clamp value
is monotonic up to the ceiling itself (i.e., it can flatten but never decrease).
I5. Ack cap
ack_capped = ack < BPS_100_PERCENT ? ack : BPS_100_PERCENT. The cap is applied
before multiplication. No event’s contribution exceeds bps_mul(|delta|, BPS_100_PERCENT)
in magnitude.
I6. Score floor 0n, ceiling 10000n - scar
After fold, the raw sum may be negative (if early events had negative deltas and
positive acks). Floor clamps to 0n. Ceiling clamps to max(0n, BPS_100_PERCENT - scar)
— the inner max defends against caller-supplied scar values that exceed BPS_100_PERCENT
(spec posture says scar ≤ 10000; we don’t trust the lookup at the function boundary).
I7. Empty events → 0n
compute_score(node_id, domain, [], _, _) returns 0n. Downstream consumers (P2.4.1,
P2.5.1) rely on bigint always, never null/undefined.
I8. Sort is total
Events are sorted by (epoch ASC, id ASC). Ties on epoch resolve by id (the
AUTOINCREMENT monotonic cursor from P2.1.1 schema). This makes the fold order a
total function of the input set, so two callers passing the same set in different
shuffled orders produce the same output.
I9. Pure imports
Only imports:
bps_mulfrom'../rules/integer-math.js'BPS_100_PERCENTfrom'../rules/bps-constants.js'Domain,ReputationHistoryRow(type-only) from'./schema.js'
No node:*, no better-sqlite3, no zod, no async, no I/O.
§4. Algorithm (locked)
compute_score(node_id, domain, events, ack_lookup, scar_lookup):
sorted := [...events].sort((a, b) =>
a.epoch !== b.epoch ? a.epoch - b.epoch : a.id - b.id)
score := 0n
for event in sorted:
if event.domain !== domain: continue // out-of-domain rows are skipped
if event.node_id !== node_id: continue // wrong-owner rows are skipped
ack := ack_lookup(event.event_id, domain)
ack_capped := ack < BPS_100_PERCENT ? ack : BPS_100_PERCENT
if ack_capped < 0n: ack_capped := 0n // defensive (lookup may return negative)
weighted := bps_mul(BigInt(event.delta), ack_capped)
score := score + weighted
if score < 0n: score := 0n // floor
scar := scar_lookup(node_id, domain)
if scar < 0n: scar := 0n // defensive
if scar > BPS_100_PERCENT: scar := BPS_100_PERCENT
ceiling := BPS_100_PERCENT - scar
if score > ceiling: score := ceiling // ceiling
return score
Why the row filters (domain + node_id match) live here
The spec contract says compute folds events for a (node, domain) pair. The caller
should pass a history slice already pre-filtered by selectHistory(db, node_id, domain).
But a defensive in-function filter makes the contract robust against caller error —
if a caller passes the wrong slice, the wrong-owner / wrong-domain rows silently drop
rather than poison the fold. This is symmetric with how P2.1.1’s insertHistoryEvent
validates input with Zod even though the caller “should” already have valid data.
Why ack_capped applies before multiplication
Posture: defend against an ack_lookup that returns a value > BPS_100_PERCENT.
After bps_mul(delta, ack), an oversized ack would inflate the contribution by
delta × (excess / 10000), violating feedback-loop bounds. Capping first means
weighted ≤ delta in magnitude, which keeps the fold inside int64 even for the
worst-case history (1000 events × 10000 bps = 10_000_000n — eight orders of
magnitude under MAX_INT64).
§5. Test contract
§5.1 Unit tests (compute.test.ts)
| ID | Name | Asserts |
|---|---|---|
| U-1 | empty events returns 0n | compute_score(n, d, [], _, _) === 0n |
| U-2 | single positive event, ack=10000n, scar=0n | result equals event.delta |
| U-3 | single positive event, ack=20000n, scar=0n | ack capped at 10000n; result equals event.delta (not 2×) |
| U-4 | single positive event, ack=5000n | result equals floor(delta × 5000 / 10000) = delta/2 |
| U-5 | scar=2000n reduces ceiling to 8000n | computed > 8000n clips to 8000n |
| U-6 | scar > 10000n defensively clipped to 10000n → ceiling=0n | result is 0n |
| U-7 | negative ack defensively clipped to 0n | result equals 0n for that event |
| U-8 | events from other domains are skipped | passing 1 in-domain + 1 out-of-domain → only in-domain counts |
| U-9 | events from other nodes are skipped | passing 1 own + 1 foreign-node → only own counts |
| U-10 | events sorted by epoch ASC then id ASC | shuffled input produces same output as sorted input |
| U-11 | floor clamps a negative aggregate to 0n | one event with delta = -500, ack=10000n → result is 0n |
| U-12 | result is always bigint typeof | typeof result === 'bigint' |
§5.2 Property tests (compute.test.ts, 1000 iterations each)
Pinned LCG seed; seed = 0x1f9bc0deafn. The LCG is s_{n+1} = (s_n × 0x5851f42d4c957f2dn + 0x14057b7ef767814fn) mod 2^64.
| ID | Name | Property |
|---|---|---|
| P-1 | monotonicity under positive deltas | for any sequence with all delta > 0 and ack ≥ 0, compute_score(prefix_k) ≤ compute_score(prefix_{k+1}) for all k (post-ceiling-clamp: ≤ holds; pre-clamp: ≤ also holds via fold) |
| P-2 | determinism (two-run equality) | for any random input, compute_score(...) produces byte-identical output on two runs |
| P-3 | ack-cap invariant | for ack_lookup that returns ≥ 10000n, the per-event contribution magnitude is ≤ |delta| (since bps_mul(delta, 10000n) === delta) |
| P-4 | empty / single / many events all produce bigint | for any random N ∈ [0, 100], typeof result === 'bigint' |
§5.3 Scanner test (determinism.test.ts under reputation)
Mirrors src/__tests__/domains/rules/determinism.test.ts Group 12, scoped to
src/domains/reputation/. Excludes schema.ts (which has legitimate
Math.max/Math.min on number for offset/limit clamping per P2.1.1 contract §3).
The exclusion is documented in-file with a code comment citing P2.1.1 contract §3.
The new test asserts:
compute.tscontains zero forbidden patterns- Any other
.tsfiles added later undersrc/domains/reputation/(exceptschema.ts) are also clean
§6. Acceptance criteria
| ID | Criterion | Test |
|---|---|---|
| AC-1 | compute_score exported with locked signature (§2) |
Compile-time check; named import in test file |
| AC-2 | ack_capped ≤ BPS_100_PERCENT before bps_mul |
U-3, P-3 |
| AC-3 | Arithmetic via integer-math.ts only |
scanner test (no Math.*) + grep of imports in code review |
| AC-4 | Ceiling = BPS_100_PERCENT - scar, clamped to 0n if scar > 10000n |
U-5, U-6 |
| AC-5 | Floor = 0n; negative aggregate clamps | U-11 |
| AC-6 | Monotonicity under positive-only events | P-1, 1000 iterations |
| AC-7 | Determinism | P-2, 1000 iterations |
| AC-8 | Ack-cap holds — no contribution exceeds |delta| × BPS_100_PERCENT magnitude |
P-3, 1000 iterations |
| AC-9 | No Math., Date., Math.random in src/domains/reputation/compute.ts |
scanner test |
| AC-10 | Empty events → 0n | U-1 |
| AC-11 | Out-of-domain events skipped | U-8 |
| AC-12 | Out-of-node events skipped | U-9 |
| AC-13 | Sort total + stable across input shuffles | U-10 |
| AC-14 | Pure bigint return type | U-12 |
§7. Failure modes — what compute_score does NOT do
- It does NOT decode
event.event_idto find the acker — it passes the raw string toack_lookup. Encoding policy is P2.5.1 territory. - It does NOT throw on unknown ackers —
ack_lookupis opaque; if it returns0nfor unknown, the event’s contribution is0n. Compute has no opinion. - It does NOT validate
eventshape. The caller is responsible. (Zod lives inschema.ts.) - It does NOT mutate the database. It is read-only. The
reputationstable is not touched by P2.1.2 — that’s P2.5.1’s job. - It does NOT decay scores by epoch staleness. That’s P2.2.1’s job.
- It does NOT apply penalty multipliers from
s04-reputation.md§Penalty schedule. Penalties enter the fold pre-coded as negativeevent.deltavalues by P2.2.2.
§8. Non-goals
- Tool registration. P2.1.2 ships zero MCP tools.
- Database writes. P2.1.2 has zero SQL writes.
- Cache. P2.1.2 has no internal cache; recomputation per call is required for determinism (snapshot caching is P2.5.1).
ScarLookupstorage. P2.1.2 receives the scar via the lookup function; the storage isreputations.scar_bps(P2.1.1).AckLookupstorage. P2.1.2 receives the ack via the lookup function; the storage is the snapshotreputations.scoreof the acknowledger (P2.5.1 will mount this).
§9. Risks tracked from audit §5 → resolution
| Risk | Mitigation |
|---|---|
| bigint × number TypeError | BigInt(event.delta) at the bridge; covered by U-2 / U-4 |
| schema.ts has Math.max/min | Documented in-file exclusion in scanner test |
| event_id as acker key is convention | Locked in §4 algorithm; spec-deferred to P2.5.1 |
| Sort stability | Total comparator (epoch, id); V8 stable since Node 12 |
| bps_mul overflow | Worst-case 1000 × 10000 = 10_000_000n — far below MAX_INT64 |
| scar_bps > 10000 from lookup | Defensive clamp in algorithm step 5 |
| Self-scan exclusion symmetry with κ | Documented in audit §5.7 and in scanner test header comment |
| No fast-check | Hand-rolled LCG with pinned seed; pure bigint mod; reproducible across CI |
§10. Hand-off readiness for next step (Packet)
Packet locks: file order, exact test names, commit map. This contract gives it everything it needs without further design questions.