P3.1.2 — Quorum Computation — Behavioral Contract

Step 2 of the 5-step chain. The audit (Step 1) confirmed a greenfield slice. This contract specifies behaviors, signatures, and the acceptance corpus that Step 5 (verify) measures against.

§1. Module surface

Module: src/domains/consensus/quorum.ts

Exported symbols:

Symbol Kind Signature
quorumThreshold function (n: bigint) => bigint
maxFaulty function (n: bigint) => bigint
hasQuorum function (votes: Vote[], n: bigint) => boolean
intersect function (a: Set<string>, b: Set<string>) => Set<string>
detectDoubleVote function (arbiterId: string, roundId: bigint, votes: Vote[]) => Array<[Vote, Vote]>
voteGroupKey function (v: Vote) => string (helper, exported for test transparency)
QuorumError class extends Error; thrown on n < 1n

No state. No I/O. Zero Math.* / Date.* / network / DB / env.

§2. quorumThreshold(n) contract

Pure function — (bigint) → bigint.

Returns (n * 2n) / 3n + 1n (BigInt floor division — JS BigInt divides toward zero; for non-negative dividend this is mathematical floor).

Input Output Source
1n 1n §Phase 0 posture
2n 2n Derived: (2*2)/3 + 1 = 1 + 1 = 2
3n 3n Derived: (2*3)/3 + 1 = 2 + 1 = 3
4n 3n §Worked table
7n 5n §Worked table
10n 7n §Worked table
13n 9n §Worked table

Preconditions:

  • n >= 1n — otherwise QuorumError('n must be >= 1n') is thrown
  • typeof n === 'bigint' — TypeScript enforces; no runtime type coercion (per CLAUDE.md §13 / forbiddens — no number for ids/counts)

§3. maxFaulty(n) contract

Pure function — (bigint) → bigint.

Returns (n - 1n) / 3n (BigInt floor for non-negative numerator).

Input Output Source
1n 0n Derived
4n 1n §Worked table
7n 2n §Worked table
10n 3n §Worked table
13n 4n §Worked table

Preconditions: identical to quorumThreshold. n >= 1n.

§4. hasQuorum(votes, n) contract

Pure function — (Vote[], bigint) → boolean.

Semantics: groups votes by canonical (round_id, merkle_root, rule_version_hash) 3-tuple. Counts the size of the LARGEST group. Returns true iff that count >= quorumThreshold(n).

The 3-tuple key uses voteGroupKey(v) = "${v.round_id}|${v.merkle_root.toString('hex')}|${v.rule_version_hash.toString('hex')}".

Buffer hex is lowercase per Node’s spec (matches κ canonical convention inherited via P3.1.1).

Preconditions:

  • n >= 1n — propagates QuorumError
  • votes is a Vote[] array (may be empty)
  • Duplicate Votes (identical (arbiter, sub-tuple) pairs) are counted twice here. hasQuorum is a structural counter; equivocation detection is the separate detectDoubleVote concern. Phase-0 callers should pre-deduplicate by (sender_id, sub-tuple) if they want a strict “distinct-arbiter” count. P3.4.1 (finality SM) does this.

Examples:

Setup Result
n=4, 3 votes all same tuple true (3 ≥ 3)
n=4, 4 votes all same tuple true (4 ≥ 3)
n=4, 2 votes root_A + 2 votes root_B false (largest group = 2 < 3)
n=4, 5 votes (3 matching root_A, 2 root_B) true (3 ≥ 3)
n=7, 4 votes all matching false (4 < 5)
n=7, 5 votes all matching true (5 ≥ 5)
n=1, 1 vote true (single-arbiter clause)
n=4, [] empty false (0 < 3)

§5. intersect(a, b) contract

Pure function — (Set<string>, Set<string>) → Set<string>.

Returns a NEW Set<string> containing every member of a that is also in b. Iteration order: insertion order of a (delegated to JS Set iteration semantics).

Generic over arbiter-id strings — the function does no parsing. Type is fixed to Set<string> because P3.5.x callers will pass sender_id (string) and any wider type would erode property-test ability.

Honest-majority property check: for any two quorum-sized subsets of {1,…,n}, intersect(A, B).size >= 1 when n >= 4. The test corpus asserts this both on worked-table sizes and on randomized n ∈ [4, 100].

§6. detectDoubleVote(arbiterId, roundId, votes) contract

Pure function — (string, bigint, Vote[]) → Array<[Vote, Vote]>.

Returns the list of conflicting Vote pairs by arbiterId in roundId.

Algorithm:

  1. Filter votes to those where v.sender_id === arbiterId AND v.round_id === roundId. Call this list mine.
  2. Group mine by voteGroupKey(v) (canonical sub-tuple).
  3. For every pair (g_i, g_j) of DISTINCT group keys, take one representative Vote from each group and emit the pair.
  4. Within the same group (same sub-tuple), no pair is emitted — identical tuples are retries, not equivocation.

Return shape matches P3.5.1’s expected input for buildEquivocationProof: an array of pairs [Vote, Vote], so the caller can iterate proofs.map(([a, b]) => buildEquivocationProof({ attacker_id: arbiterId, round_id: roundId, signed_vote_a: a, signed_vote_b: b, ... })).

Examples:

Setup Result
A signs (42, root_X, rv_1) and (42, root_Y, rv_1) 1 pair
A signs (42, root_X, rv_1) twice (same sig) 0 pairs
A signs (42, root_X, rv_1) and (42, root_X, rv_2) 1 pair
A signs 3 distinct tuples in round 42 3 pairs (C(3,2))
A signs in round 42 and 43 0 pairs (different rounds)
B’s votes mixed in ignored — only arbiterId matters

When >2 distinct tuples exist, all C(k, 2) pairs are returned.

§7. QuorumError contract

export class QuorumError extends Error {
  override readonly name = 'QuorumError';
}

Thrown by quorumThreshold(n) and maxFaulty(n) when n < 1n. Not thrown by hasQuorum directly — it propagates through the inner quorumThreshold call.

§8. Invariants (asserted in property tests)

For all n ∈ [1n, 100n]:

  1. quorumThreshold(n) >= 1n
  2. maxFaulty(n) >= 0n
  3. maxFaulty(n) <= n - 1n
  4. quorumThreshold(n) + maxFaulty(n) === n for all n >= 1n.

    Note on consensus.md drift: docs/3-world/physics/laws/consensus.md line 40 states the invariant as q + f = n + 1 - (n mod 3 == 0 ? 1 : 0). Direct calculation shows this is off-by-one when n mod 3 != 0. The CORRECT invariant by symbolic derivation is q + f = n for all n >= 1n:

    • n = 3k: q = 2k+1, f = k-1, q+f = 3k = n
    • n = 3k+1: q = 2k+1, f = k, q+f = 3k+1 = n
    • n = 3k+2: q = 2k+2, f = k, q+f = 3k+2 = n

    The test corpus asserts the calculated truth (q + f === n) as the primary invariant and pins the consensus.md formula as a secondary assertion that holds ONLY on n mod 3 == 0. A spec-doc reconcile is left as a follow-up hygiene item (out of P3.1.2 scope).

  5. 2n * quorumThreshold(n) > n — guarantees any two quorums intersect
  6. quorumThreshold(n) > maxFaulty(n) for all n >= 1n

For randomized n ∈ [4n, 100n] (200 iterations, deterministic seed):

  1. Construct two quorum-sized subsets A, B of {1,…,n}; assert intersect(A, B).size >= 1. Subsets are generated by a seeded linear-congruential bigint PRNG (no Math.random).

§9. Acceptance corpus (Step 5 will measure these)

ID Property Count
AC#1 Worked table — quorumThreshold 4 fixtures
AC#2 Worked table — maxFaulty 4 fixtures
AC#3 Single-arbiter clause — quorumThreshold(1n) 1
AC#4 Single-arbiter clause — maxFaulty(1n) 1
AC#5 Single-arbiter clause — hasQuorum(...,1n) trivial 1
AC#6 quorumThreshold precondition: n < 1n throws 3 cases (0n, -1n, -100n)
AC#7 maxFaulty precondition: n < 1n throws 3 cases
AC#8 Honest-majority invariant: n ∈ [1n, 100n] 100 iterations
AC#9 Quorum-strictly-majority: 2*q > n, n ∈ [1n, 100n] 100 iterations
AC#10 hasQuorum happy path (n=4, all match) 1
AC#11 hasQuorum split (n=4, 2+2) 1
AC#12 hasQuorum partial (n=4, 5 votes, 3 match) 1
AC#13 hasQuorum n=7 below 1
AC#14 hasQuorum n=7 at threshold 1
AC#15 hasQuorum empty list 1
AC#16 intersect basic 1
AC#17 intersect disjoint 1
AC#18 intersect identical 1
AC#19 intersect honest-majority property n=7 1
AC#20 intersect honest-majority property randomized n ∈ [4n, 100n] 200 iterations
AC#21 detectDoubleVote — 2 distinct tuples → 1 pair 1
AC#22 detectDoubleVote — same tuple retry → 0 pairs 1
AC#23 detectDoubleVote — 3 tuples → 3 pairs 1
AC#24 detectDoubleVote — different round → 0 pairs 1
AC#25 detectDoubleVote — other arbiter ignored 1
AC#26 voteGroupKey is canonical (lowercase hex) 1
AC#27 Forbidden-token corpus scan (zero Math./Date./number\|) 1
AC#28 QuorumError is a named Error subclass 1

Expected new tests: 28 grouped specs across ~21+ logical cases yielding ~24-30 it() blocks. Target delta against the 2687 baseline: +20-30.

§10. Out of scope

  • Threshold-signature aggregation (Phase 3 P3.2.x or downstream).
  • Voting weight / reputation modulation (λ).
  • Network-layer gossip / fan-out (P3.3.x).
  • VRF leader election (P3.6.x).
  • View-change quorum logic (P3.1.3) — only consumes hasQuorum.

§11. Implementation notes (for Step 3 packet)

  • Use template literals for voteGroupKey; avoid Buffer.equals / Buffer.compare because we need a string-keyed Map.
  • Buffer.prototype.toString('hex') is lowercase by Node’s spec — matches κ canonical lowercase-hex convention.
  • The honest-majority test generator must use a seeded bigint PRNG (deterministic + forbidden-op compliant). LCG with seed = 0xC4F3F0DEn is sufficient: next = (next * 6364136223846793005n + 1442695040888963407n) & 0xFFFFFFFFFFFFFFFFn.
  • All for loops use bigint indices to stay consistent — for (let i = 0n; i < n; i++).
  • TypeScript node-ESM import: import type { Vote } from './messages.js'; (the .js suffix is mandatory under the package’s NodeNext resolution; see existing messages.ts imports).

Back to top

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

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