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— otherwiseQuorumError('n must be >= 1n')is throwntypeof n === 'bigint'— TypeScript enforces; no runtime type coercion (per CLAUDE.md §13 / forbiddens — nonumberfor 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— propagatesQuorumErrorvotesis aVote[]array (may be empty)- Duplicate Votes (identical
(arbiter, sub-tuple)pairs) are counted twice here.hasQuorumis a structural counter; equivocation detection is the separatedetectDoubleVoteconcern. 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:
- Filter
votesto those wherev.sender_id === arbiterIdANDv.round_id === roundId. Call this listmine. - Group
minebyvoteGroupKey(v)(canonical sub-tuple). - For every pair
(g_i, g_j)of DISTINCT group keys, take one representative Vote from each group and emit the pair. - 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]:
quorumThreshold(n) >= 1nmaxFaulty(n) >= 0nmaxFaulty(n) <= n - 1n-
quorumThreshold(n) + maxFaulty(n) === nfor alln >= 1n.Note on consensus.md drift:
docs/3-world/physics/laws/consensus.mdline 40 states the invariant asq + f = n + 1 - (n mod 3 == 0 ? 1 : 0). Direct calculation shows this is off-by-one whenn mod 3 != 0. The CORRECT invariant by symbolic derivation isq + f = nfor alln >= 1n:n = 3k: q = 2k+1, f = k-1, q+f = 3k = nn = 3k+1: q = 2k+1, f = k, q+f = 3k+1 = nn = 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 onn mod 3 == 0. A spec-doc reconcile is left as a follow-up hygiene item (out of P3.1.2 scope). 2n * quorumThreshold(n) > n— guarantees any two quorums intersectquorumThreshold(n) > maxFaulty(n)for alln >= 1n
For randomized n ∈ [4n, 100n] (200 iterations, deterministic seed):
- Construct two quorum-sized subsets
A,Bof{1,…,n}; assertintersect(A, B).size >= 1. Subsets are generated by a seeded linear-congruential bigint PRNG (noMath.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; avoidBuffer.equals/Buffer.comparebecause we need a string-keyedMap. 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 = 0xC4F3F0DEnis sufficient:next = (next * 6364136223846793005n + 1442695040888963407n) & 0xFFFFFFFFFFFFFFFFn. - All
forloops use bigint indices to stay consistent —for (let i = 0n; i < n; i++). - TypeScript node-ESM import:
import type { Vote } from './messages.js';(the.jssuffix is mandatory under the package’s NodeNext resolution; see existingmessages.tsimports).