@polyweave/quorum (1.0.6)
Installation
@polyweave:registry=https://hub.kl1.tenere.ai/api/packages/Polyweave/npm/npm install @polyweave/quorum@1.0.6"@polyweave/quorum": "1.0.6"About this package
@polyweave/quorum
First-ahead-by-k quorum voting primitives with push-stream duplex integration for Polyweave orchestration.
Published as @polyweave/quorum.
Synopsis
import {
createQuorumSession,
firstAheadByK,
stableCandidateKey,
summarizeVoteCounts,
createQuorumDuplex
} from '@polyweave/quorum';
const session = createQuorumSession({ k: 2, maxVotes: 10 });
session.addVote('candidate-a');
session.addVote('candidate-b');
session.addVote('candidate-a');
const state = session.getState();
console.log(state.decided); // true
console.log(state.stoppedBecause); // 'ahead-by-k'
console.log(state.selectedCandidate); // 'candidate-a'
// Convenience: one-shot first-ahead-by-k
const result = firstAheadByK(['A', 'B', 'A', 'A'], { k: 2 });
console.log(result.selectedCandidate); // 'A'
// Deterministic object key for voting on complex candidates
const key1 = stableCandidateKey({ name: 'Alice', score: 95 });
const key2 = stableCandidateKey({ score: 95, name: 'Alice' });
console.log(key1 === key2); // true (stable ordering)
// Frame-native quorum as a tool
const quorumTool = createQuorumDuplex({ k: 3 });
quorumTool.sink.write({ type: 'TOOL_CALL', content: { arguments: { candidates: ['A', 'B', 'A', 'B', 'A'], k: 2 } } });
Install
npm install @polyweave/quorum
Why
LLM multi-agent voting needs to stop early — you don't need all N votes when one candidate pulls ahead by k. This module implements first-ahead-by-k: as votes arrive, the session decides immediately when the leader's count exceeds the runner-up's by at least k. It handles min-votes (don't decide too early), max-votes (fallback to plurality), deterministic stable keys for complex objects (sorted property traversal), and a full push-stream duplex (createQuorumDuplex) that exposes quorum as a TOOL_CALL handler — feeding candidates through an optional keyFn duplex for embedding-based clustering before voting.
API
createQuorumSession(options?)
Creates a stateful quorum voting session.
Signature
function createQuorumSession(options?: {
k?: number,
keyFn?: (value: any) => string,
maxVotes?: number,
minVotes?: number
}): QuorumSession
Parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
options.k |
number |
No | 1 |
Lead margin required to decide. Must be a positive integer. A leader with count C wins when C >= runnerUpCount + k. |
options.keyFn |
function |
No | stableCandidateKey |
Function that maps a candidate value to a stable string key for grouping votes. |
options.maxVotes |
number |
No | Infinity |
Maximum total votes before auto-deciding. When reached, the current leader is selected with stoppedBecause: 'max-votes'. Must be a positive integer. |
options.minVotes |
number |
No | 1 |
Minimum votes required before any decision is evaluated. Must be a positive integer. |
Error Conditions
| Condition | Error Type | Message |
|---|---|---|
k is not a positive integer |
Error |
'k must be a positive integer' |
maxVotes is not a positive integer |
Error |
'maxVotes must be a positive integer' |
minVotes is not a positive integer |
Error |
'minVotes must be a positive integer' |
Return Value — QuorumSession
{
addVote: (candidate: any, metadata?: object) => SessionState,
getState: () => SessionState,
isDecided: () => boolean
}
addVote(candidate, metadata?)
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
candidate |
any |
Yes | — | The vote value. Passed through keyFn to determine its canonical key for counting. |
metadata |
object |
No | {} |
Arbitrary metadata stored with this vote (e.g., attemptId, timestamp, confidence). |
Returns the current SessionState. If the session is already decided, the vote is silently ignored — the vote count does not increase and the state is returned unchanged. This makes addVote idempotent after decision.
getState()
Returns the current SessionState:
{
k: number, // configured lead margin
totalVotes: number, // total votes received
decided: boolean, // whether a decision has been reached
stoppedBecause: string | null, // 'ahead-by-k', 'max-votes', or null (if not decided)
selectedKey: string | null, // canonical key of selected candidate
selectedCandidate: any | null, // the selected candidate value (raw, not key)
leader: VoteRow | null, // { key, count, candidate, votes }
runnerUp: VoteRow | null, // second-place row (null if only 1 candidate)
counts: VoteRow[] // all candidates sorted by count desc, then key asc
}
isDecided()
Returns boolean. True once stoppedBecause is set. Monotonic — once true, never becomes false.
Decision Logic
- If
totalVotes < minVotes— do not evaluate (return undecided). - Sort counts descending by count, ascending by key for ties.
- If
leader.count >= runnerUp.count + k— ahead-by-k decision. SetselectedKey,decided = true. - If
totalVotes >= maxVotes— max-votes fallback. Leader wins even if not ahead by k. Setdecided = true.
firstAheadByK(votes, options?)
Convenience function: creates a session, feeds all votes, and returns the final state.
Signature
function firstAheadByK(
votes: any[],
options?: { k?: number, keyFn?: (value: any) => string, maxVotes?: number, minVotes?: number }
): SessionState
Parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
votes |
any[] |
Yes | — | Array of candidate values or keys. Each element is passed to session.addVote(element). |
options |
object |
No | {} |
Forwarded to createQuorumSession. Same parameters. |
Return Value
The final SessionState after all votes have been added (or decision is reached mid-iteration).
Error Conditions
Same as createQuorumSession (forwarded parameter validation).
stableCandidateKey(value)
Returns a deterministic string key for any JavaScript value. Useful for voting on complex objects with consistent identity across serialization boundaries.
Signature
function stableCandidateKey(value: any): string
Key Generation Rules
| Input Type | Key Format |
|---|---|
null |
'null' |
undefined |
'undefined' |
string, number, boolean |
JSON.stringify(value) |
Array |
'[' + elements.map(stableCandidateKey).join(',') + ']' |
Object |
'{' + sorted keys with stableCandidateKey values + '}' |
| Nested objects | Recursively traversed |
Object keys are sorted lexicographically before encoding, so { b: 2, a: 1 } and { a: 1, b: 2 } produce identical keys.
Error Conditions
No errors thrown. All inputs produce a string.
summarizeVoteCounts(counts)
Sorts and materializes a Map of vote entries into a ranked array.
Signature
function summarizeVoteCounts(
counts: Map<string, { key: string, candidate: any, count: number, votes: any[] }>
): VoteRow[]
Return Value
Array of VoteRow sorted by count descending, then by key ascending for ties:
[
{ key: string, count: number, candidate: any, votes: any[] },
...
]
The votes array in each row is a shallow copy of the accumulated vote metadata entries { candidate, metadata }.
createQuorumDuplex(options?)
Creates a push-stream frame duplex that exposes quorum voting as a tool-call handler. Supports two modes: identity (direct voting on candidates) and keyFn (embedding-based clustering before voting).
Signature
function createQuorumDuplex(options?: {
k?: number,
maxVotes?: number,
minVotes?: number,
keyFn?: IFrameDuplex,
name?: string
}): IFrameDuplex
Parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
options.k |
number |
No | 1 |
Default lead margin (overridable via args.k in TOOL_CALL). |
options.maxVotes |
number |
No | Infinity |
Default max votes (overridable via args.maxVotes). |
options.minVotes |
number |
No | 1 |
Default min votes (overridable via args.minVotes). |
options.keyFn |
IFrameDuplex |
No | undefined |
Optional key-function duplex for voting. See below. |
options.name |
string |
No | null |
Node name exposed as duplex.name. |
Mode 1: Identity (no keyFn)
When keyFn is not provided, the duplex operates in identity mode:
const tool = createQuorumDuplex({ k: 2 });
// tool._toolName === 'quorum'
Frames handled:
| Inbound | Outbound | Description |
|---|---|---|
TOOL_CALL |
TOOL_RESULT |
Runs first-ahead-by-k on args.candidates. Responds with full SessionState. Uses args.k, args.maxVotes, args.minVotes as overrides. |
DESCRIBE |
DESCRIPTION |
Returns schema: { name: 'quorum', description: 'First-ahead-by-k quorum consensus over candidate frames.', capabilities: ['execute'], schema: { type: 'object', properties: { candidates: { type: 'array' }, k: { type: 'number', default: 3 }, maxVotes: { type: 'number' } } } } |
TICK, CANCEL, ABORT |
— | No-op. |
| Any other frame type | TOOL_RESULT |
Echoes frame.content or the frame itself. |
Mode 2: keyFn (embedding-based voting)
When keyFn is provided (as an IFrameDuplex), candidates are piped through keyFn to obtain cluster labels or embedding-derived keys before quorum counting:
- All
TOOL_CALLcandidates are written tokeyFn.sinkas individual TOOL_CALL frames:{ type: 'TOOL_CALL', content: { arguments: { candidate: item } } }. keyFn.sourceis listened to via a frame sink. EachTOOL_RESULTframe'scontent.key(orcontent) is collected as the canonical key.- Keys are consumed in FIFO order, falling back to the raw candidate if the key was not yet available.
- A
createQuorumSessionis created with the keys, and voting proceeds as in identity mode.
This allows embedding/classifier adapters to serve as the key function — the quorum runs on semantic clusters rather than raw values.
Additional frames handled vs identity mode:
| Inbound | Outbound | Description |
|---|---|---|
CANCEL, ABORT |
— | Resets keyResults array. |
Error Conditions
No explicit errors thrown. Parameter validation only occurs in the inner createQuorumSession call when processing a TOOL_CALL (see createQuorumSession error conditions).
Tests
npm test
Covers:
- quorum.test.js —
stableCandidateKeyorder-invariance,firstAheadByKahead-by-k selection, session vote metadata tracking and post-decision no-op,maxVotesfallback to plurality. - rigor.test.js — Extensive property tests: stable key determinism (null, undefined, strings, numbers, arrays, objects, sorted-key invariance, distinct-value uniqueness),
summarizeVoteCountssort order, session invariants (starts not-decided, decision monotonicity, k=1 first-vote-decides, k=2 needs-two-same, selected key matches leader, minVotes delays decision, totalVotes accuracy, maxVotes triggers decision).
Requirements
- Node.js 22 or later.
- ESM only (
"type": "module"). - Dependencies:
@polyweave/core.
Dependencies
Dependencies
| ID | Version |
|---|---|
| @polyweave/core | * |
Development Dependencies
| ID | Version |
|---|---|
| @rigor/core | * |