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 async declaration (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_mul from '../rules/integer-math.js'
  • BPS_100_PERCENT from '../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.ts contains zero forbidden patterns
  • Any other .ts files added later under src/domains/reputation/ (except schema.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_id to find the acker — it passes the raw string to ack_lookup. Encoding policy is P2.5.1 territory.
  • It does NOT throw on unknown ackers — ack_lookup is opaque; if it returns 0n for unknown, the event’s contribution is 0n. Compute has no opinion.
  • It does NOT validate event shape. The caller is responsible. (Zod lives in schema.ts.)
  • It does NOT mutate the database. It is read-only. The reputations table 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 negative event.delta values 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).
  • ScarLookup storage. P2.1.2 receives the scar via the lookup function; the storage is reputations.scar_bps (P2.1.1).
  • AckLookup storage. P2.1.2 receives the ack via the lookup function; the storage is the snapshot reputations.score of 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.


Back to top

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

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