Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,28 @@
# @suaveplan/network-plugin-governance - Election-engine plugin for the mesh: candidate scoring, reputation, voting, host transitions
## Summary
Election engine for the Suaveplan mesh: candidate scoring, reputation tracking, voting, and host transitions.
Concrete `NetworkPlugin` that consumes `peer:latency` and `peer:disconnected` events from the bus, runs elections, and publishes the winner so `network-plugin-topology` can promote the node to Tier 0.
<!-- begin:auto-docs -->
## Documentation tree
- [election](./src/election/election-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [election-engine](./src/election/election-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [plugin](./src/plugin/governance-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [governance-plugin](./src/plugin/governance-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [reputation](./src/reputation/reputation-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [reputation-tracker](./src/reputation/reputation-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [scoring](./src/scoring/candidate-scorer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [candidate-scorer](./src/scoring/candidate-scorer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [transition](./src/transition/transition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [transition-manager](./src/transition/transition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [voting](./src/voting/voting-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [voting-manager](./src/voting/voting-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the governance plugin. Every failure mode in the election pipeline — missing quorum,…
<!-- end:auto-docs --> - [election-engine](./src/election/election-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/governance-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [governance-plugin](./src/plugin/governance-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [reputation](./src/reputation/reputation-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_- [reputation-tracker](./src/reputation/reputation-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_- [scoring](./src/scoring/candidate-scorer.md) — <!-- begin:auto-docs --> _(no submodules)_- [candidate-scorer](./src/scoring/candidate-scorer.md) — <!-- begin:auto-docs --> _(no submodules)_- [transition](./src/transition/transition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [transition-manager](./src/transition/transition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [voting](./src/voting/voting-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [voting-manager](./src/voting/voting-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the governance plugin. Every failure mode in the election pipeline — missing quorum,…
- [election-engine](./src/election/election-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/governance-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [governance-plugin](./src/plugin/governance-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [reputation](./src/reputation/reputation-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_- [reputation-tracker](./src/reputation/reputation-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_- [scoring](./src/scoring/candidate-scorer.md) — <!-- begin:auto-docs --> _(no submodules)_- [candidate-scorer](./src/scoring/candidate-scorer.md) — <!-- begin:auto-docs --> _(no submodules)_- [transition](./src/transition/transition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [transition-manager](./src/transition/transition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [voting](./src/voting/voting-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [voting-manager](./src/voting/voting-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the governance plugin. Every failure mode in the election pipeline — missing quorum,…
- [election-engine](./src/election/election-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/governance-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [governance-plugin](./src/plugin/governance-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [reputation](./src/reputation/reputation-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_- [reputation-tracker](./src/reputation/reputation-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_- [scoring](./src/scoring/candidate-scorer.md) — <!-- begin:auto-docs --> _(no submodules)_- [candidate-scorer](./src/scoring/candidate-scorer.md) — <!-- begin:auto-docs --> _(no submodules)_- [transition](./src/transition/transition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [transition-manager](./src/transition/transition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [voting](./src/voting/voting-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [voting-manager](./src/voting/voting-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the governance plugin. Every failure mode in the election pipeline — missing quorum,…
- [election-engine](./src/election/election-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/governance-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ - [governance-plugin](./src/plugin/governance-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [reputation](./src/reputation/reputation-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_ - [reputation-tracker](./src/reputation/reputation-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_- [scoring](./src/scoring/candidate-scorer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [candidate-scorer](./src/scoring/candidate-scorer.md) — <!-- begin:auto-docs --> _(no submodules)_- [transition](./src/transition/transition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ - [transition-manager](./src/transition/transition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [voting](./src/voting/voting-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ - [voting-manager](./src/voting/voting-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the governance plugin. Every failure mode in the election pipeline — missing quorum,…

View File

@@ -0,0 +1,49 @@
{
"name": "@suaveplan/network-plugin-governance",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Election-engine plugin for the mesh: candidate scoring, reputation, voting, host transitions",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist", "src", "README.md"],
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "bunx biome check src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/network": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
}
}

View File

@@ -0,0 +1,70 @@
# ElectionEngine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Orchestrates a single election round across three modes (`metric`, `direct`, `hybrid`), publishes the result on the bus, and bumps a monotonic `term` counter so downstream subscribers can detect re-elections. Combines `CandidateScorer` (metric axis) with `VotingManager` (vote axis) into one consistent API.
## Features
- Three election modes: `metric` (score only), `direct` (vote tally only), `hybrid` (score × `(1 + voteShare)`).
- Quorum check on `direct` and `hybrid` modes; throws `NO_QUORUM` when not met.
- Empty-candidate guard via `NO_CANDIDATES`.
- Monotonic `term` counter; one increment per successful round.
- Injectable `scorer` and `voting` for testing and customisation.
- Publishes `election:result` on the bus with `{ mode, winnerId, score, term }`.
## Usage: Basic Example
```ts
import { ElectionEngine } from "@suaveplan/network-plugin-governance";
const engine = new ElectionEngine({ bus });
const result = engine.run("metric", candidates, knownPeers);
```
## Usage: Advanced Example
```ts
import {
CandidateScorer,
ElectionEngine,
VotingManager,
} from "@suaveplan/network-plugin-governance";
const scorer = new CandidateScorer({
latency: 0.5,
uptime: 0.3,
bandwidth: 0.1,
reputation: 0.1,
});
const voting = new VotingManager({ quorumFraction: 0.66 });
const engine = new ElectionEngine({ bus, scorer, voting });
voting.recordVote({ voterId: "alice", candidateId: "host-x" });
voting.recordVote({ voterId: "bob", candidateId: "host-x" });
voting.recordVote({ voterId: "carol", candidateId: "host-y" });
try {
const result = engine.run("hybrid", candidates, 4);
// mode === "hybrid", term increments by 1
} catch (err) {
// NO_QUORUM if too few voters
}
```
## API Reference
- `type ElectionMode = "metric" | "direct" | "hybrid"`.
- `interface ElectionResult { mode, winnerId, score, term }`.
- `interface ElectionEngineConfig { bus, scorer?, voting? }`.
- `new ElectionEngine(config)`.
- `run(mode, candidates, knownPeers): ElectionResult` — throws `NO_CANDIDATES`/`NO_QUORUM`.
- `getVoting(): VotingManager` — exposed so consumers can `recordVote` directly.
- `getTerm(): number`.
## Implementation Notes
`hybrid` mode multiplies each candidate's normalised score by `1 + voteShare` (where `voteShare ∈ [0, 1]`). This means a candidate with 100% of the votes can at most double its score, preventing pure popularity from overriding poor metrics. `direct` mode uses the raw vote tally and synthesises a `score = 1` if the winning candidate is not in the metric set (e.g. an underdog write-in). `metric` mode skips the voting subsystem entirely and is the cheapest path for low-coordination scenarios. The `term` counter is bumped only on success so failed runs don't perturb the counter monotonicity that downstream FSMs rely on.

View File

@@ -0,0 +1,140 @@
import { NETWORK_GOV_CODES, NetworkGovError } from "../error-codes.js";
import {
type CandidateMetrics,
type CandidateScore,
CandidateScorer,
} from "../scoring/candidate-scorer.js";
import { VotingManager } from "../voting/voting-manager.js";
/**
* Election strategy:
*
* - `metric` — purely score-based; ignores votes.
* - `direct` — purely vote-based; ignores scores.
* - `hybrid` — `score × (1 + voteShare)`; combines the two.
*/
export type ElectionMode = "metric" | "direct" | "hybrid";
/**
* Election bus shape — accepts `election:result`.
*/
export interface ElectionBus {
emit(
event: "election:result",
payload: {
mode: ElectionMode;
winnerId: string;
score: number;
term: number;
}
): void;
}
/**
* Result of a successful election.
*/
export interface ElectionResult {
readonly mode: ElectionMode;
readonly winnerId: string;
readonly score: number;
readonly term: number;
}
/**
* Configuration for `ElectionEngine`.
*/
export interface ElectionEngineConfig {
readonly bus: ElectionBus;
readonly scorer?: CandidateScorer;
readonly voting?: VotingManager;
}
/**
* Orchestrates an election round in one of three modes. Increments the
* monotonic `term` counter on every successful run so downstream
* subscribers can detect re-elections.
*/
export class ElectionEngine {
private readonly bus: ElectionBus;
private readonly scorer: CandidateScorer;
private readonly voting: VotingManager;
private term = 0;
public constructor(config: ElectionEngineConfig) {
this.bus = config.bus;
this.scorer = config.scorer ?? new CandidateScorer();
this.voting = config.voting ?? new VotingManager();
}
public getVoting(): VotingManager {
return this.voting;
}
public run(
mode: ElectionMode,
candidates: readonly CandidateMetrics[],
knownPeers: number
): ElectionResult {
if (candidates.length === 0) {
throw new NetworkGovError(
NETWORK_GOV_CODES.NO_CANDIDATES,
"election.no-candidates"
);
}
const scored = this.scorer.score(candidates);
let winner: CandidateScore;
if (mode === "metric") {
winner = this.scorer.selectWinner(scored)!;
} else {
if (!this.voting.hasQuorum(knownPeers)) {
throw new NetworkGovError(
NETWORK_GOV_CODES.NO_QUORUM,
"election.no-quorum",
{
context: {
received: this.voting.size(),
known: knownPeers,
},
}
);
}
const tally = this.voting.tally();
if (mode === "direct") {
if (tally.winner === undefined) {
throw new NetworkGovError(
NETWORK_GOV_CODES.NO_QUORUM,
"election.empty-tally"
);
}
const fromTally = scored.find((c) => c.peerId === tally.winner);
winner = fromTally ?? {
peerId: tally.winner,
score: 1,
};
} else {
// hybrid: blend score with vote share
const total = tally.totalVotes;
const blended = scored.map((c) => ({
peerId: c.peerId,
score:
c.score *
(1 + (tally.counts.get(c.peerId) ?? 0) / total),
}));
winner = this.scorer.selectWinner(blended)!;
}
}
this.term += 1;
const result: ElectionResult = {
mode,
winnerId: winner.peerId,
score: winner.score,
term: this.term,
};
this.bus.emit("election:result", result);
return result;
}
public getTerm(): number {
return this.term;
}
}

View File

@@ -0,0 +1,174 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { CandidateMetrics } from "../scoring/candidate-scorer.js";
import { ElectionEngine, type ElectionResult } from "./election-engine.js";
function makeBus(): {
emit: (event: "election:result", payload: ElectionResult) => void;
events: ElectionResult[];
} {
const events: ElectionResult[] = [];
return {
events,
emit(_event, payload): void {
events.push(payload);
},
};
}
const candidates: CandidateMetrics[] = [
{
peerId: "alice",
latencyMs: 50,
uptimeMs: 1000,
bandwidthBps: 1000,
reputation: 0.9,
},
{
peerId: "bob",
latencyMs: 200,
uptimeMs: 500,
bandwidthBps: 500,
reputation: 0.4,
},
];
describe("ElectionEngine (NET-08)", () => {
it("metric mode picks the highest-scoring candidate", () => {
const bus = makeBus();
const engine = new ElectionEngine({ bus });
const result = engine.run("metric", candidates, 2);
expect(result.mode).toBe("metric");
expect(result.winnerId).toBe("alice");
expect(result.term).toBe(1);
expect(bus.events).toHaveLength(1);
});
it("increments the term on every successful run", () => {
const bus = makeBus();
const engine = new ElectionEngine({ bus });
const r1 = engine.run("metric", candidates, 2);
const r2 = engine.run("metric", candidates, 2);
expect(r2.term).toBe(r1.term + 1);
expect(engine.getTerm()).toBe(2);
});
it("throws NO_CANDIDATES on empty input", () => {
const engine = new ElectionEngine({ bus: makeBus() });
expect(() => engine.run("metric", [], 0)).toThrow(
"election.no-candidates"
);
});
it("direct mode requires quorum", () => {
const engine = new ElectionEngine({ bus: makeBus() });
expect(() => engine.run("direct", candidates, 5)).toThrow(
"election.no-quorum"
);
});
it("direct mode tallies votes and elects the leader", () => {
const bus = makeBus();
const engine = new ElectionEngine({ bus });
engine.getVoting().recordVote({
voterId: "v1",
candidateId: "alice",
});
engine.getVoting().recordVote({
voterId: "v2",
candidateId: "alice",
});
const result = engine.run("direct", candidates, 3);
expect(result.winnerId).toBe("alice");
});
it("direct mode synthesises score=1 for vote winners outside the metric set", () => {
const bus = makeBus();
const engine = new ElectionEngine({ bus });
engine.getVoting().recordVote({
voterId: "v1",
candidateId: "carol",
});
engine.getVoting().recordVote({
voterId: "v2",
candidateId: "carol",
});
const result = engine.run("direct", candidates, 3);
expect(result.winnerId).toBe("carol");
expect(result.score).toBe(1);
});
it("hybrid mode blends score with vote share", () => {
const bus = makeBus();
const engine = new ElectionEngine({ bus });
engine.getVoting().recordVote({
voterId: "v1",
candidateId: "alice",
});
engine.getVoting().recordVote({
voterId: "v2",
candidateId: "bob",
});
const result = engine.run("hybrid", candidates, 3);
expect(result.mode).toBe("hybrid");
expect(["alice", "bob"]).toContain(result.winnerId);
});
it("hybrid mode treats unvoted candidates as zero vote share", () => {
const bus = makeBus();
const engine = new ElectionEngine({ bus });
// only alice gets a vote — bob falls through the `?? 0` branch
engine.getVoting().recordVote({
voterId: "v1",
candidateId: "alice",
});
engine.getVoting().recordVote({
voterId: "v2",
candidateId: "alice",
});
const result = engine.run("hybrid", candidates, 3);
expect(result.winnerId).toBe("alice");
});
it("getVoting exposes the underlying VotingManager", () => {
const engine = new ElectionEngine({ bus: makeBus() });
const v = engine.getVoting();
v.recordVote({ voterId: "alice", candidateId: "host-x" });
expect(v.size()).toBe(1);
});
it("direct mode rejects empty tallies even with quorum", () => {
// edge case: 0 known peers passed → quorum-fraction is 0/0 ⇒ false,
// already covered elsewhere. This case validates the explicit
// empty-tally guard if we somehow had quorum with no votes.
const engine = new ElectionEngine({ bus: makeBus() });
expect(() => engine.run("direct", candidates, 0)).toThrow(
"election.no-quorum"
);
});
it("direct mode raises empty-tally when quorum=0 fraction and no votes", () => {
const engine = new ElectionEngine({
bus: makeBus(),
voting: new (class {
public size(): number {
return 0;
}
public hasQuorum(): boolean {
return true;
}
public tally() {
return {
counts: new Map<string, number>(),
totalVotes: 0,
winner: undefined,
};
}
public recordVote(): void {}
public clear(): void {}
})() as never,
});
expect(() => engine.run("direct", candidates, 1)).toThrow(
"election.empty-tally"
);
});
});

View File

@@ -0,0 +1,59 @@
# NETWORK_GOV_CODES
## Purpose
Frozen registry of error codes for the governance plugin. Every failure mode in the election pipeline — missing quorum, empty candidate set, duplicate votes, failed host transitions — surfaces a typed `NetworkGovError` with one of these codes so callers can pattern-match without parsing message strings. The registry is registered under the `NET_GOV` namespace (via `defineCodes`), keeping it distinct from the mesh core's `NET` namespace.
## Features
- Five frozen codes: `NO_QUORUM`, `NO_CANDIDATES`, `DUPLICATE_VOTE`, `TRANSITION_FAILED`.
- `NetworkGovError` extends `SuaveplanError<NetworkGovErrorCode>` for cross-package compatibility.
- `name: "NetworkGovError"` on every instance for log filtering.
- `cause` and `context` propagation via `SuaveplanErrorOptions`.
## Usage: Basic Example
```ts
import {
NETWORK_GOV_CODES,
NetworkGovError,
} from "@suaveplan/network-plugin-governance";
throw new NetworkGovError(NETWORK_GOV_CODES.NO_QUORUM, "election.no-quorum", {
context: { received: 3, required: 5 },
});
```
## Usage: Advanced Example
```ts
import {
NETWORK_GOV_CODES,
NetworkGovError,
} from "@suaveplan/network-plugin-governance";
try {
runElection();
} catch (err) {
if (
err instanceof NetworkGovError &&
err.code === NETWORK_GOV_CODES.NO_CANDIDATES
) {
// fall back to single-node mode
return;
}
throw err;
}
```
## API Reference
- `NETWORK_GOV_CODES` — frozen `{ NO_QUORUM, NO_CANDIDATES, DUPLICATE_VOTE, TRANSITION_FAILED }` registry.
- `type NetworkGovErrorCode` — union of registered code values.
- `class NetworkGovError extends SuaveplanError<NetworkGovErrorCode>` — typed error class.
## Implementation Notes
The registry is intentionally small. Each code maps to a single, well-understood failure mode in the election pipeline; we resist the temptation to add codes for "could-have-failed" situations that are normal outcomes (e.g. `LOSING_CANDIDATE`). When new failure modes emerge, the rule is: add a code only if a caller would reasonably want to handle it differently than the existing codes. Otherwise reuse an existing code with richer `context`.
The package barrel side-effect-imports `./error-codes.js` so any consumer that drills into a subpath export still registers the codes with the global `defineCodes` registry. Consumers that need stable error-code values for serialization or wire formats can rely on the `NETWORK_GOV_CODES.<KEY>` constants being string literals like `"NET_GOV.NO_QUORUM"` — no runtime mutation, no opaque tagged values.

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NETWORK_GOV_CODES, NetworkGovError } from "./error-codes.js";
describe("NETWORK_GOV_CODES", () => {
it("registers under NET_GOV", () => {
for (const value of Object.values(NETWORK_GOV_CODES)) {
expect(value).toMatch(/^NET_GOV\./);
}
});
it("is frozen", () => {
expect(Object.isFrozen(NETWORK_GOV_CODES)).toBe(true);
});
it("contains the documented codes", () => {
const expected = [
"NO_QUORUM",
"NO_CANDIDATES",
"DUPLICATE_VOTE",
"TRANSITION_FAILED",
] as const;
for (const key of expected) {
expect(NETWORK_GOV_CODES).toHaveProperty(key);
}
});
});
describe("NetworkGovError", () => {
it("extends SuaveplanError", () => {
const err = new NetworkGovError(
NETWORK_GOV_CODES.NO_QUORUM,
"test.no-quorum"
);
expect(err.name).toBe("NetworkGovError");
expect(err.code).toBe(NETWORK_GOV_CODES.NO_QUORUM);
});
it("preserves cause", () => {
const cause = new Error("inner");
const err = new NetworkGovError(
NETWORK_GOV_CODES.TRANSITION_FAILED,
"with-cause",
{ cause }
);
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,22 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
export const NETWORK_GOV_CODES = defineCodes("NET_GOV", [
"NO_QUORUM",
"NO_CANDIDATES",
"DUPLICATE_VOTE",
"TRANSITION_FAILED",
] as const);
export type NetworkGovErrorCode =
(typeof NETWORK_GOV_CODES)[keyof typeof NETWORK_GOV_CODES];
export class NetworkGovError extends SuaveplanError<NetworkGovErrorCode> {
public constructor(
code: NetworkGovErrorCode,
messageKey: string,
options?: import("@suaveplan/error").SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "NetworkGovError";
}
}

View File

@@ -0,0 +1,49 @@
// Error codes (side-effect registration)
import "./error-codes.js";
// Election
export {
type ElectionBus,
ElectionEngine,
type ElectionEngineConfig,
type ElectionMode,
type ElectionResult,
} from "./election/election-engine.js";
// Errors
export {
NETWORK_GOV_CODES,
NetworkGovError,
type NetworkGovErrorCode,
} from "./error-codes.js";
// Plugin
export {
GovernancePlugin,
type GovernancePluginConfig,
} from "./plugin/governance-plugin.js";
// Reputation
export {
type ReputationEventKind,
ReputationTracker,
type ReputationTrackerConfig,
} from "./reputation/reputation-tracker.js";
// Scoring
export {
type CandidateMetrics,
type CandidateScore,
CandidateScorer,
type ScoringWeights,
} from "./scoring/candidate-scorer.js";
// Transition
export {
type TransitionBus,
TransitionManager,
type TransitionManagerConfig,
type TransitionState,
} from "./transition/transition-manager.js";
// Voting
export {
type Vote,
type VoteTally,
VotingManager,
type VotingManagerConfig,
} from "./voting/voting-manager.js";

View File

@@ -0,0 +1,71 @@
# GovernancePlugin
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
NetworkPlugin that wires the governance subsystems (`CandidateScorer`, `ReputationTracker`, `VotingManager`, `ElectionEngine`, `TransitionManager`) into a `NetworkNode` and bridges mesh events into reputation updates. The plugin is the single integration surface — callers only need to register one plugin to get the full election pipeline.
## Features
- Constructs and exposes the four sub-managers; injectable for testing.
- Subscribes to `peer:latency` (counts as `success`) and `peer:disconnected` (counts as `failure`) so reputation reflects observable mesh behaviour.
- `election.run(...)` and `transition.begin(...)` drive the active rounds.
- Clean `stop()` unsubscribes bus handlers; `dispose()` clears all state.
## Usage: Basic Example
```ts
import { GovernancePlugin } from "@suaveplan/network-plugin-governance";
const plugin = new GovernancePlugin();
node.use(plugin);
await node.start();
const result = plugin.election!.run("metric", candidates, knownPeers);
```
## Usage: Advanced Example
```ts
import {
CandidateScorer,
GovernancePlugin,
VotingManager,
} from "@suaveplan/network-plugin-governance";
const plugin = new GovernancePlugin({
scorer: new CandidateScorer({
latency: 0.5,
uptime: 0.2,
bandwidth: 0.2,
reputation: 0.1,
}),
voting: new VotingManager({ quorumFraction: 0.66 }),
});
node.use(plugin);
await node.start();
plugin.voting.recordVote({ voterId: "alice", candidateId: "host-x" });
plugin.voting.recordVote({ voterId: "bob", candidateId: "host-x" });
const result = plugin.election!.run("hybrid", candidates, 4);
plugin.transition!.begin(result.term);
plugin.transition!.transfer();
plugin.transition!.confirm();
plugin.transition!.complete(result.winnerId);
```
## API Reference
- `interface GovernancePluginConfig { scorer?, voting?, reputation? }`.
- `new GovernancePlugin(config?)` — exposes `.scorer`, `.voting`, `.reputation`.
- `install(node)` — wires sub-managers and subscribes to bus events.
- `election: ElectionEngine | null` and `transition: TransitionManager | null` — populated after `install`.
- `start()` / `stop()` / `dispose()` — standard lifecycle.
## Implementation Notes
`election` and `transition` are nullable until `install()` runs because both depend on the node's bus, and we deliberately avoid eager construction with a stub bus. Callers that need to interact with the plugin pre-install must use the always-present `scorer`/`voting`/`reputation` references; the election engine and transition manager are intentionally gated behind a successful `install`. The `peer:latency` → `success` mapping is a defensible approximation: low-latency neighbours are healthy by definition. A more nuanced mapping (e.g. only count as success when latency stays under a threshold) is left to consumers via a custom `ReputationTracker`.

View File

@@ -0,0 +1,79 @@
import { NetworkNode } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { GovernancePlugin } from "./governance-plugin.js";
describe("GovernancePlugin", () => {
it("exposes the canonical name", () => {
const p = new GovernancePlugin();
expect(p.name).toBe("governance");
});
it("populates election and transition after install", async () => {
const node = new NetworkNode();
const plugin = new GovernancePlugin();
node.use(plugin);
await node.start();
expect(plugin.election).not.toBeNull();
expect(plugin.transition).not.toBeNull();
await node.stop();
});
it("records peer:latency events as success", async () => {
const node = new NetworkNode();
const plugin = new GovernancePlugin();
node.use(plugin);
await node.start();
const initial = plugin.reputation.get("alice");
node.networkBus.emit("peer:latency", {
peerId: "alice",
rttMs: 50,
});
expect(plugin.reputation.get("alice")).not.toBe(initial);
await node.stop();
});
it("records peer:disconnected events as failure", async () => {
const node = new NetworkNode();
const plugin = new GovernancePlugin();
node.use(plugin);
await node.start();
node.networkBus.emit("peer:disconnected", { peerId: "alice" });
expect(plugin.reputation.get("alice")).toBeLessThan(0.5);
await node.stop();
});
it("ignores non-payload bus events", async () => {
const node = new NetworkNode();
const plugin = new GovernancePlugin();
node.use(plugin);
await node.start();
node.networkBus.emit("peer:latency", null);
node.networkBus.emit("peer:disconnected", null);
await node.stop();
});
it("dispose clears reputation and voting", async () => {
const node = new NetworkNode();
const plugin = new GovernancePlugin();
plugin.install(node);
plugin.voting.recordVote({
voterId: "alice",
candidateId: "host-x",
});
plugin.reputation.record("alice", "success");
plugin.dispose();
expect(plugin.voting.size()).toBe(0);
expect(plugin.election).toBeNull();
expect(plugin.transition).toBeNull();
});
it("stop unsubscribes bus listeners safely twice", async () => {
const node = new NetworkNode();
const plugin = new GovernancePlugin();
node.use(plugin);
await node.start();
await node.stop();
// calling stop directly again is safe
plugin.stop();
});
});

View File

@@ -0,0 +1,105 @@
import type { NetworkNode, NetworkPlugin } from "@suaveplan/network";
import { ElectionEngine } from "../election/election-engine.js";
import { ReputationTracker } from "../reputation/reputation-tracker.js";
import { CandidateScorer } from "../scoring/candidate-scorer.js";
import { TransitionManager } from "../transition/transition-manager.js";
import { VotingManager } from "../voting/voting-manager.js";
/**
* Configuration for `GovernancePlugin`.
*/
export interface GovernancePluginConfig {
readonly scorer?: CandidateScorer;
readonly voting?: VotingManager;
readonly reputation?: ReputationTracker;
}
/**
* NetworkPlugin that wires the governance subsystems
* (`CandidateScorer`, `ReputationTracker`, `VotingManager`,
* `ElectionEngine`, `TransitionManager`) into a `NetworkNode`. The
* plugin subscribes to `peer:latency` and `peer:disconnected` so the
* reputation tracker reflects mesh-observable behaviour.
*/
export class GovernancePlugin implements NetworkPlugin {
public readonly name = "governance";
public readonly scorer: CandidateScorer;
public readonly voting: VotingManager;
public readonly reputation: ReputationTracker;
public election: ElectionEngine | null = null;
public transition: TransitionManager | null = null;
private offLatency: (() => void) | null = null;
private offDisconnected: (() => void) | null = null;
public constructor(config: GovernancePluginConfig = {}) {
this.scorer = config.scorer ?? new CandidateScorer();
this.voting = config.voting ?? new VotingManager();
this.reputation = config.reputation ?? new ReputationTracker();
}
public install(node: NetworkNode): void {
const bus = node.networkBus as unknown as Parameters<
typeof this.attachElection
>[0];
this.attachElection(bus);
this.offLatency = node.networkBus.on(
"peer:latency",
(payload: unknown) => {
if (
typeof payload === "object" &&
payload !== null &&
"peerId" in payload
) {
this.reputation.record(
(payload as { peerId: string }).peerId,
"success"
);
}
}
);
this.offDisconnected = node.networkBus.on(
"peer:disconnected",
(payload: unknown) => {
if (
typeof payload === "object" &&
payload !== null &&
"peerId" in payload
) {
this.reputation.record(
(payload as { peerId: string }).peerId,
"failure"
);
}
}
);
}
private attachElection(
bus: ConstructorParameters<typeof ElectionEngine>[0]["bus"]
): void {
this.election = new ElectionEngine({
bus,
scorer: this.scorer,
voting: this.voting,
});
this.transition = new TransitionManager({ bus: bus as never });
}
public start(): void {
// election is driven by callers via `election.run`
}
public stop(): void {
this.offLatency?.();
this.offDisconnected?.();
this.offLatency = null;
this.offDisconnected = null;
}
public dispose(): void {
this.reputation.clear();
this.voting.clear();
this.election = null;
this.transition = null;
}
}

View File

@@ -0,0 +1,58 @@
# ReputationTracker
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Maintains a per-peer reputation score in `[0, 1]` (configurable bounds) that fades over time and responds to three event kinds: `success`, `failure`, and `violation`. Used by `CandidateScorer` as one of the four election axes. Reputation is intentionally pessimistic — a single violation can drop a peer's score significantly so untrusted nodes are unlikely to win Tier-0 elections.
## Features
- Configurable `decay` (multiplicative, default `0.95`), `initial` value (default `0.5`), and per-event deltas.
- Hard bounds via `minReputation`/`maxReputation` (defaults `[0, 1]`).
- Decay is applied **before** the event delta so old behaviour fades and new behaviour dominates.
- `reset(peerId)` to forget one peer; `clear()` to wipe everything.
- `get(peerId)` returns `initial` for unknown peers so first-time peers don't get a score of zero.
## Usage: Basic Example
```ts
import { ReputationTracker } from "@suaveplan/network-plugin-governance";
const tracker = new ReputationTracker();
tracker.record("alice", "success");
tracker.record("alice", "success");
const score = tracker.get("alice");
```
## Usage: Advanced Example
```ts
import { ReputationTracker } from "@suaveplan/network-plugin-governance";
// Slow decay, harsh violation penalty.
const tracker = new ReputationTracker({
decay: 0.99,
initial: 0.7,
successDelta: 0.02,
failureDelta: 0.05,
violationDelta: 0.6,
});
tracker.record("alice", "violation");
tracker.record("alice", "success"); // doesn't recover quickly
```
## API Reference
- `type ReputationEventKind = "success" | "failure" | "violation"`.
- `interface ReputationTrackerConfig` — `decay`, `initial`, `successDelta`, `failureDelta`, `violationDelta`, `minReputation`, `maxReputation`.
- `record(peerId, kind): number` — apply an event and return the new score.
- `get(peerId): number` — read current score (or `initial` if unknown).
- `reset(peerId)` / `clear()` — forget one peer / all peers.
- `getInitial(): number` — exposed for downstream visualisations.
## Implementation Notes
Decay is applied multiplicatively so the score asymptotically approaches zero in the absence of events. After each event, `score = clamp(score × decay + delta)`. The clamp prevents under/overshooting the configured bounds when `delta` is large relative to the score range. We deliberately lump `failure` and `violation` into separate event kinds rather than a single `negative` kind so the policy can punish protocol violations harder than transient network failures, which are usually no-fault.

View File

@@ -0,0 +1,84 @@
/**
* Event sources that adjust a peer's reputation.
*/
export type ReputationEventKind = "success" | "failure" | "violation";
/**
* Configuration for a `ReputationTracker`.
*/
export interface ReputationTrackerConfig {
readonly decay?: number;
readonly initial?: number;
readonly successDelta?: number;
readonly failureDelta?: number;
readonly violationDelta?: number;
readonly minReputation?: number;
readonly maxReputation?: number;
}
/**
* Tracks per-peer reputation as a value in `[min, max]`. Successes push
* the value up, failures and violations push it down. After every event
* the score is multiplicatively decayed toward zero by the configured
* `decay` factor so old behaviour fades.
*/
export class ReputationTracker {
private readonly scores = new Map<string, number>();
private readonly decay: number;
private readonly initial: number;
private readonly successDelta: number;
private readonly failureDelta: number;
private readonly violationDelta: number;
private readonly minReputation: number;
private readonly maxReputation: number;
public constructor(config: ReputationTrackerConfig = {}) {
this.decay = config.decay ?? 0.95;
this.initial = config.initial ?? 0.5;
this.successDelta = config.successDelta ?? 0.05;
this.failureDelta = config.failureDelta ?? 0.1;
this.violationDelta = config.violationDelta ?? 0.5;
this.minReputation = config.minReputation ?? 0;
this.maxReputation = config.maxReputation ?? 1;
}
public record(peerId: string, kind: ReputationEventKind): number {
const current = this.scores.get(peerId) ?? this.initial;
const decayed = current * this.decay;
const delta =
kind === "success"
? this.successDelta
: kind === "failure"
? -this.failureDelta
: -this.violationDelta;
const next = clamp(
decayed + delta,
this.minReputation,
this.maxReputation
);
this.scores.set(peerId, next);
return next;
}
public get(peerId: string): number {
return this.scores.get(peerId) ?? this.initial;
}
public reset(peerId: string): void {
this.scores.delete(peerId);
}
public clear(): void {
this.scores.clear();
}
public getInitial(): number {
return this.initial;
}
}
function clamp(value: number, lo: number, hi: number): number {
if (value < lo) return lo;
if (value > hi) return hi;
return value;
}

View File

@@ -0,0 +1,91 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ReputationTracker } from "./reputation-tracker.js";
describe("ReputationTracker", () => {
it("returns the initial value for unknown peers", () => {
const t = new ReputationTracker({ initial: 0.42 });
expect(t.get("ghost")).toBe(0.42);
expect(t.getInitial()).toBe(0.42);
});
it("applies success deltas and decay", () => {
const t = new ReputationTracker({
initial: 0.5,
decay: 0.9,
successDelta: 0.1,
});
const v = t.record("alice", "success");
// 0.5 * 0.9 + 0.1 = 0.55
expect(v).toBeCloseTo(0.55, 5);
});
it("applies failure deltas and decay", () => {
const t = new ReputationTracker({
initial: 0.5,
decay: 1,
failureDelta: 0.2,
});
const v = t.record("alice", "failure");
expect(v).toBeCloseTo(0.3, 5);
});
it("applies violation deltas heavily", () => {
const t = new ReputationTracker({
initial: 0.8,
decay: 1,
violationDelta: 0.5,
});
const v = t.record("alice", "violation");
expect(v).toBeCloseTo(0.3, 5);
});
it("clamps to minReputation", () => {
const t = new ReputationTracker({
initial: 0.1,
decay: 1,
failureDelta: 1,
minReputation: 0,
});
expect(t.record("alice", "failure")).toBe(0);
});
it("clamps to maxReputation", () => {
const t = new ReputationTracker({
initial: 0.95,
decay: 1,
successDelta: 1,
maxReputation: 1,
});
expect(t.record("alice", "success")).toBe(1);
});
it("decays old behaviour over time", () => {
const t = new ReputationTracker({
initial: 1,
decay: 0.5,
successDelta: 0,
});
const v1 = t.record("alice", "success");
// 1 * 0.5 + 0 = 0.5
expect(v1).toBe(0.5);
const v2 = t.record("alice", "success");
// 0.5 * 0.5 + 0 = 0.25
expect(v2).toBe(0.25);
});
it("reset forgets one peer", () => {
const t = new ReputationTracker({ initial: 0.5 });
t.record("alice", "success");
t.reset("alice");
expect(t.get("alice")).toBe(0.5);
});
it("clear forgets every peer", () => {
const t = new ReputationTracker({ initial: 0.3 });
t.record("alice", "success");
t.record("bob", "failure");
t.clear();
expect(t.get("alice")).toBe(0.3);
expect(t.get("bob")).toBe(0.3);
});
});

View File

@@ -0,0 +1,65 @@
# CandidateScorer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Computes a deterministic composite score for each peer in an election candidate set, weighting four axes (latency, uptime, bandwidth, reputation) so independent observers across the mesh agree on the winner. Tiebreaks deterministically via FNV-1a on `peerId` so two nodes that see the same metrics always pick the same Tier-0 host.
## Features
- Min-max normalisation per axis so the composite stays unitless and bounded.
- Inverted latency: lower-RTT peers score higher on the latency axis.
- Configurable `ScoringWeights`; defaults are 40/30/20/10 (latency/uptime/bandwidth/reputation).
- Deterministic tiebreaking via FNV-1a on `peerId` (lowest hash wins).
- Empty candidate set returns `[]`/`undefined` cleanly.
## Usage: Basic Example
```ts
import { CandidateScorer } from "@suaveplan/network-plugin-governance";
const scorer = new CandidateScorer();
const scored = scorer.score([
{ peerId: "alice", latencyMs: 50, uptimeMs: 1000, bandwidthBps: 1000, reputation: 0.9 },
{ peerId: "bob", latencyMs: 200, uptimeMs: 500, bandwidthBps: 500, reputation: 0.5 },
]);
const winner = scorer.selectWinner(scored);
```
## Usage: Advanced Example
```ts
import {
CandidateScorer,
type ScoringWeights,
} from "@suaveplan/network-plugin-governance";
// Heavily weight reputation for a slow, trust-prone link.
const weights: ScoringWeights = {
latency: 0.2,
uptime: 0.2,
bandwidth: 0.1,
reputation: 0.5,
};
const scorer = new CandidateScorer(weights);
const winner = scorer.selectWinner(scorer.score(metrics));
if (winner === undefined) {
// empty candidate set — fall back to single-node mode
}
```
## API Reference
- `interface CandidateMetrics { peerId, latencyMs, uptimeMs, bandwidthBps, reputation }`.
- `interface ScoringWeights { latency, uptime, bandwidth, reputation }` — must sum to a positive number.
- `interface CandidateScore { peerId, score }`.
- `new CandidateScorer(weights?)` — default weights 40/30/20/10.
- `score(candidates): CandidateScore[]` — composite score per candidate.
- `selectWinner(scored): CandidateScore | undefined` — highest score with deterministic tiebreak.
## Implementation Notes
When all candidates have an identical metric value on an axis, `(hi - lo)` would be zero. The scorer handles this by returning `1` from the normaliser (every candidate is equally "best" on that axis), avoiding both NaN propagation and an artificial zero-weight degeneration. Latency is clamped to `≥ 1 ms` before inversion to dodge division by zero on the (rare) localhost case. Tiebreaking via `fnv1a(peerId)` is preferred to lexicographic sort because it scrambles peer-id locality — alphabetical neighbours don't deterministically beat each other every election.

View File

@@ -0,0 +1,131 @@
/**
* Per-peer raw metrics from the rest of the network plugins.
*/
export interface CandidateMetrics {
readonly peerId: string;
readonly latencyMs: number;
readonly uptimeMs: number;
readonly bandwidthBps: number;
readonly reputation: number;
}
/**
* Weights for each scoring dimension. Must sum to a value in `(0, 1]`.
*/
export interface ScoringWeights {
readonly latency: number;
readonly uptime: number;
readonly bandwidth: number;
readonly reputation: number;
}
/**
* Scored candidate with raw metrics and the computed composite score.
*/
export interface CandidateScore {
readonly peerId: string;
readonly score: number;
}
const DEFAULT_WEIGHTS: ScoringWeights = {
latency: 0.4,
uptime: 0.3,
bandwidth: 0.2,
reputation: 0.1,
};
/**
* Stable 32-bit FNV-1a hash for deterministic tiebreaking on `peerId`.
*/
function fnv1a(s: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return h >>> 0;
}
/**
* Weighted multi-criteria scorer:
*
* score = w_latency × normalize(1 / latency)
* + w_uptime × normalize(uptime)
* + w_bandwidth × normalize(bandwidth)
* + w_reputation × normalize(reputation)
*
* Each axis is min-max-normalised across the candidate set so the
* composite is unitless and bounded in `[0, 1]` per axis. Latency is
* inverted so lower-latency candidates score higher.
*
* Tiebreaking on identical composite scores is deterministic by
* `fnv1a(peerId)` (lowest hash wins) so independent observers reach the
* same winner.
*/
export class CandidateScorer {
private readonly weights: ScoringWeights;
public constructor(weights: ScoringWeights = DEFAULT_WEIGHTS) {
this.weights = weights;
}
public score(candidates: readonly CandidateMetrics[]): CandidateScore[] {
if (candidates.length === 0) return [];
const inv = candidates.map((c) => ({
peerId: c.peerId,
invLatency: 1 / Math.max(c.latencyMs, 1),
uptime: c.uptimeMs,
bandwidth: c.bandwidthBps,
reputation: c.reputation,
}));
const min = {
invLatency: Math.min(...inv.map((c) => c.invLatency)),
uptime: Math.min(...inv.map((c) => c.uptime)),
bandwidth: Math.min(...inv.map((c) => c.bandwidth)),
reputation: Math.min(...inv.map((c) => c.reputation)),
};
const max = {
invLatency: Math.max(...inv.map((c) => c.invLatency)),
uptime: Math.max(...inv.map((c) => c.uptime)),
bandwidth: Math.max(...inv.map((c) => c.bandwidth)),
reputation: Math.max(...inv.map((c) => c.reputation)),
};
const norm = (value: number, lo: number, hi: number): number =>
hi === lo ? 1 : (value - lo) / (hi - lo);
return inv.map((c) => ({
peerId: c.peerId,
score:
this.weights.latency *
norm(c.invLatency, min.invLatency, max.invLatency) +
this.weights.uptime * norm(c.uptime, min.uptime, max.uptime) +
this.weights.bandwidth *
norm(c.bandwidth, min.bandwidth, max.bandwidth) +
this.weights.reputation *
norm(c.reputation, min.reputation, max.reputation),
}));
}
/**
* Pick the highest-scoring candidate; tie-break deterministically by
* FNV-1a hash of `peerId`.
*/
public selectWinner(
scored: readonly CandidateScore[]
): CandidateScore | undefined {
if (scored.length === 0) return undefined;
let best = scored[0]!;
for (const c of scored) {
if (c.score > best.score) {
best = c;
continue;
}
if (
c.score === best.score &&
fnv1a(c.peerId) < fnv1a(best.peerId)
) {
best = c;
}
}
return best;
}
}

View File

@@ -0,0 +1,177 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { type CandidateMetrics, CandidateScorer } from "./candidate-scorer.js";
const baseline: CandidateMetrics[] = [
{
peerId: "alice",
latencyMs: 50,
uptimeMs: 1000,
bandwidthBps: 1000,
reputation: 0.9,
},
{
peerId: "bob",
latencyMs: 200,
uptimeMs: 500,
bandwidthBps: 500,
reputation: 0.5,
},
];
describe("CandidateScorer (NET-08)", () => {
it("returns empty array for empty input", () => {
const s = new CandidateScorer();
expect(s.score([])).toEqual([]);
});
it("ranks lower-latency / higher-everything candidates higher", () => {
const s = new CandidateScorer();
const scored = s.score(baseline);
const aliceScore = scored.find((c) => c.peerId === "alice")!.score;
const bobScore = scored.find((c) => c.peerId === "bob")!.score;
expect(aliceScore).toBeGreaterThan(bobScore);
});
it("selectWinner picks the top score", () => {
const s = new CandidateScorer();
const winner = s.selectWinner(s.score(baseline));
expect(winner?.peerId).toBe("alice");
});
it("selectWinner upgrades when a later candidate scores higher", () => {
const s = new CandidateScorer();
// bob first (lower-quality), alice second (higher-quality) — forces
// the iteration to upgrade `best` mid-loop.
const reversed: CandidateMetrics[] = [...baseline].reverse();
const winner = s.selectWinner(s.score(reversed));
expect(winner?.peerId).toBe("alice");
});
it("selectWinner returns undefined for empty input", () => {
const s = new CandidateScorer();
expect(s.selectWinner([])).toBeUndefined();
});
it("breaks ties deterministically by FNV-1a hash", () => {
const s = new CandidateScorer();
// identical metrics → identical scores → tiebreak by hash
const candidates: CandidateMetrics[] = [
{
peerId: "alice",
latencyMs: 100,
uptimeMs: 100,
bandwidthBps: 100,
reputation: 0.5,
},
{
peerId: "bob",
latencyMs: 100,
uptimeMs: 100,
bandwidthBps: 100,
reputation: 0.5,
},
];
const winner = s.selectWinner(s.score(candidates));
// run again to confirm determinism
const winner2 = s.selectWinner(s.score([...candidates].reverse()));
expect(winner?.peerId).toBe(winner2?.peerId);
});
it("respects custom weights", () => {
const reputationOnly = new CandidateScorer({
latency: 0,
uptime: 0,
bandwidth: 0,
reputation: 1,
});
const winner = reputationOnly.selectWinner(
reputationOnly.score(baseline)
);
expect(winner?.peerId).toBe("alice");
});
it("clamps zero-latency to 1ms before inversion", () => {
const s = new CandidateScorer();
const candidates: CandidateMetrics[] = [
{
peerId: "alice",
latencyMs: 0,
uptimeMs: 1,
bandwidthBps: 1,
reputation: 0,
},
{
peerId: "bob",
latencyMs: 1,
uptimeMs: 1,
bandwidthBps: 1,
reputation: 0,
},
];
const scored = s.score(candidates);
// both should produce finite scores (no Infinity from 1/0)
for (const c of scored) {
expect(Number.isFinite(c.score)).toBe(true);
}
});
it("normaliser returns 1 when all values are equal on an axis", () => {
const s = new CandidateScorer({
latency: 0,
uptime: 1,
bandwidth: 0,
reputation: 0,
});
const candidates: CandidateMetrics[] = [
{
peerId: "alice",
latencyMs: 100,
uptimeMs: 500,
bandwidthBps: 100,
reputation: 0,
},
{
peerId: "bob",
latencyMs: 100,
uptimeMs: 500,
bandwidthBps: 100,
reputation: 0,
},
];
const scored = s.score(candidates);
for (const c of scored) {
expect(c.score).toBe(1);
}
});
it("tiebreaks toward whichever peerId hashes lowest", () => {
// single-candidate-equal-score path: build a longer set where
// a third candidate has a higher hash than the current best.
const s = new CandidateScorer();
const candidates: CandidateMetrics[] = [
{
peerId: "z",
latencyMs: 100,
uptimeMs: 100,
bandwidthBps: 100,
reputation: 0.5,
},
{
peerId: "a",
latencyMs: 100,
uptimeMs: 100,
bandwidthBps: 100,
reputation: 0.5,
},
{
peerId: "m",
latencyMs: 100,
uptimeMs: 100,
bandwidthBps: 100,
reputation: 0.5,
},
];
const winner = s.selectWinner(s.score(candidates));
expect(winner).toBeDefined();
});
});

View File

@@ -0,0 +1,73 @@
# TransitionManager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Linear FSM that walks a host handover through `preparing → transferring → confirming → completed`. On any failure the FSM jumps to `failed`, emits `transition:failed`, and resets to `idle` so the next election can run cleanly. Decoupling this from the election engine keeps each round's state machine narrow and observable.
## Features
- Strict FSM: every step asserts the current state and throws `TRANSITION_FAILED` on out-of-order calls.
- Bus events: `transition:state-change` on every step, `transition:completed` on success, `transition:failed` on rollback.
- Auto-reset to `idle` after both `completed` and `failed` so the next term starts clean.
- `getState()` exposes the live FSM state for observability.
## Usage: Basic Example
```ts
import { TransitionManager } from "@suaveplan/network-plugin-governance";
const transition = new TransitionManager({ bus });
transition.begin(1);
transition.transfer();
transition.confirm();
transition.complete("host-x");
```
## Usage: Advanced Example
```ts
import {
NETWORK_GOV_CODES,
NetworkGovError,
TransitionManager,
} from "@suaveplan/network-plugin-governance";
const transition = new TransitionManager({ bus });
transition.begin(1);
transition.transfer();
try {
// simulate the new host failing to ack
transition.fail("ack-timeout");
} catch (err) {
if (
err instanceof NetworkGovError &&
err.code === NETWORK_GOV_CODES.TRANSITION_FAILED
) {
// wrong state — already idle
}
}
// FSM is back at idle; next election can begin
transition.begin(2);
```
## API Reference
- `type TransitionState = "idle" | "preparing" | "transferring" | "confirming" | "completed" | "failed"`.
- `interface TransitionBus { emit("transition:state-change" | "transition:failed" | "transition:completed", payload) }`.
- `new TransitionManager({ bus })`.
- `getState(): TransitionState`.
- `begin(term)` — `idle → preparing`.
- `transfer()` — `preparing → transferring`.
- `confirm()` — `transferring → confirming`.
- `complete(winnerId)` — `confirming → completed → idle`.
- `fail(reason)` — any non-terminal state → `failed → idle`.
## Implementation Notes
The FSM is intentionally strict — it throws on out-of-order calls rather than ignoring them, because a transition that skips a step has almost certainly hit a bug rather than a recoverable runtime condition. After both `complete` and `fail`, the manager assigns `state = idle` directly (without firing another `state-change` event) so subscribers see clean `…→ completed` or `…→ failed` chains. The `term` is captured at `begin` and threaded into every event so subscribers can correlate transitions across overlapping elections.

View File

@@ -0,0 +1,129 @@
import { NETWORK_GOV_CODES, NetworkGovError } from "../error-codes.js";
/**
* States in the host-handover FSM.
*
* `idle → preparing → transferring → confirming → completed | failed`
*/
export type TransitionState =
| "idle"
| "preparing"
| "transferring"
| "confirming"
| "completed"
| "failed";
/**
* Bus shape for transition events.
*/
export interface TransitionBus {
emit(
event: "transition:state-change",
payload: {
from: TransitionState;
to: TransitionState;
term: number;
}
): void;
emit(
event: "transition:failed",
payload: { term: number; reason: string }
): void;
emit(
event: "transition:completed",
payload: { term: number; winnerId: string }
): void;
}
/**
* Configuration for `TransitionManager`.
*/
export interface TransitionManagerConfig {
readonly bus: TransitionBus;
}
/**
* Linear FSM that walks a host handover through `preparing →
* transferring → confirming → completed`. On any `fail()` call the FSM
* jumps to `failed`, emits `transition:failed`, then resets to `idle`
* so the next election can run.
*/
export class TransitionManager {
private state: TransitionState = "idle";
private currentTerm = 0;
private readonly bus: TransitionBus;
public constructor(config: TransitionManagerConfig) {
this.bus = config.bus;
}
public getState(): TransitionState {
return this.state;
}
public begin(term: number): void {
this.assertState("idle");
this.currentTerm = term;
this.transition("preparing");
}
public transfer(): void {
this.assertState("preparing");
this.transition("transferring");
}
public confirm(): void {
this.assertState("transferring");
this.transition("confirming");
}
public complete(winnerId: string): void {
this.assertState("confirming");
this.transition("completed");
this.bus.emit("transition:completed", {
term: this.currentTerm,
winnerId,
});
// reset so the next election can run
this.state = "idle";
}
public fail(reason: string): void {
const previous = this.state;
if (previous === "idle" || previous === "completed") {
throw new NetworkGovError(
NETWORK_GOV_CODES.TRANSITION_FAILED,
"transition.invalid-fail",
{ context: { state: previous } }
);
}
this.transition("failed");
this.bus.emit("transition:failed", {
term: this.currentTerm,
reason,
});
this.state = "idle";
}
private transition(to: TransitionState): void {
const from = this.state;
this.state = to;
this.bus.emit("transition:state-change", {
from,
to,
term: this.currentTerm,
});
}
private assertState(expected: TransitionState): void {
if (this.state !== expected) {
throw new NetworkGovError(
NETWORK_GOV_CODES.TRANSITION_FAILED,
"transition.invalid-state",
{
context: { expected, actual: this.state },
}
);
}
}
}

View File

@@ -0,0 +1,111 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { TransitionManager } from "./transition-manager.js";
function makeBus(): {
events: Array<{ event: string; payload: unknown }>;
emit: (event: never, payload: unknown) => void;
} {
const events: Array<{ event: string; payload: unknown }> = [];
return {
events,
emit(event: never, payload: unknown): void {
events.push({ event, payload });
},
};
}
describe("TransitionManager", () => {
it("walks idle → preparing → transferring → confirming → completed", () => {
const bus = makeBus();
const t = new TransitionManager({ bus: bus as never });
expect(t.getState()).toBe("idle");
t.begin(1);
expect(t.getState()).toBe("preparing");
t.transfer();
expect(t.getState()).toBe("transferring");
t.confirm();
expect(t.getState()).toBe("confirming");
t.complete("alice");
// resets to idle after completed
expect(t.getState()).toBe("idle");
expect(
bus.events.some(
(e) =>
e.event === "transition:completed" &&
(e.payload as { winnerId: string }).winnerId === "alice"
)
).toBe(true);
});
it("rejects begin when not idle", () => {
const t = new TransitionManager({ bus: makeBus() as never });
t.begin(1);
expect(() => t.begin(2)).toThrow("transition.invalid-state");
});
it("rejects transfer when not preparing", () => {
const t = new TransitionManager({ bus: makeBus() as never });
expect(() => t.transfer()).toThrow("transition.invalid-state");
});
it("rejects confirm when not transferring", () => {
const t = new TransitionManager({ bus: makeBus() as never });
t.begin(1);
expect(() => t.confirm()).toThrow("transition.invalid-state");
});
it("rejects complete when not confirming", () => {
const t = new TransitionManager({ bus: makeBus() as never });
t.begin(1);
expect(() => t.complete("alice")).toThrow("transition.invalid-state");
});
it("fails from non-idle state and resets", () => {
const bus = makeBus();
const t = new TransitionManager({ bus: bus as never });
t.begin(1);
t.transfer();
t.fail("ack-timeout");
expect(t.getState()).toBe("idle");
expect(
bus.events.some(
(e) =>
e.event === "transition:failed" &&
(e.payload as { reason: string }).reason === "ack-timeout"
)
).toBe(true);
});
it("rejects fail from idle", () => {
const t = new TransitionManager({ bus: makeBus() as never });
expect(() => t.fail("from-idle")).toThrow("transition.invalid-fail");
});
it("rejects fail from completed", () => {
const t = new TransitionManager({ bus: makeBus() as never });
// can't actually be in completed state — auto-resets to idle.
// Force it via setState-like pattern is impossible, so verify fail
// from idle (already covered) and trust the linear FSM.
expect(() => t.fail("after-complete")).toThrow(
"transition.invalid-fail"
);
});
it("emits state-change events on every transition", () => {
const bus = makeBus();
const t = new TransitionManager({ bus: bus as never });
t.begin(7);
const stateChanges = bus.events.filter(
(e) => e.event === "transition:state-change"
);
expect(stateChanges).toHaveLength(1);
const { from, to, term } = stateChanges[0]!.payload as {
from: string;
to: string;
term: number;
};
expect(from).toBe("idle");
expect(to).toBe("preparing");
expect(term).toBe(7);
});
});

View File

@@ -0,0 +1,74 @@
# VotingManager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Collects per-peer votes for one election round, dedupes by voter id, and surfaces an aggregate tally when a configurable fraction of the known peer set has voted. Drives the `direct` and `hybrid` modes of `ElectionEngine`. Tiebreaks deterministically by lexicographic `candidateId` so independent observers reach the same winner without further coordination.
## Features
- `recordVote` rejects duplicate voter ids with `DUPLICATE_VOTE`.
- `hasQuorum(knownPeers)` checks whether enough peers have voted.
- `tally()` returns counts, total votes, and a deterministic winner.
- Configurable `quorumFraction` (default `0.5` — strict majority).
- `clear()` resets state between rounds.
## Usage: Basic Example
```ts
import { VotingManager } from "@suaveplan/network-plugin-governance";
const voting = new VotingManager({ quorumFraction: 0.5 });
voting.recordVote({ voterId: "alice", candidateId: "host-x" });
voting.recordVote({ voterId: "bob", candidateId: "host-x" });
if (voting.hasQuorum(3)) {
const { winner, counts } = voting.tally();
}
```
## Usage: Advanced Example
```ts
import {
NETWORK_GOV_CODES,
NetworkGovError,
VotingManager,
} from "@suaveplan/network-plugin-governance";
const voting = new VotingManager({ quorumFraction: 0.66 });
try {
voting.recordVote({ voterId: "alice", candidateId: "host-x" });
voting.recordVote({ voterId: "alice", candidateId: "host-y" }); // throws
} catch (err) {
if (
err instanceof NetworkGovError &&
err.code === NETWORK_GOV_CODES.DUPLICATE_VOTE
) {
// log and ignore — alice's first vote stands
}
}
const tally = voting.tally();
if (tally.winner === undefined) {
// no votes were cast yet
}
voting.clear();
```
## API Reference
- `interface Vote { voterId, candidateId }`.
- `interface VotingManagerConfig { quorumFraction? }` — default `0.5`.
- `interface VoteTally { counts, totalVotes, winner }`.
- `recordVote(vote)` — throws `DUPLICATE_VOTE` on repeated voter ids.
- `hasQuorum(knownPeers): boolean` — `false` when `knownPeers ≤ 0`.
- `tally(): VoteTally` — deterministic tiebreak on lexicographic `candidateId`.
- `clear()` / `size()`.
## Implementation Notes
`tally()` is O(votes) and recomputes every call rather than caching, since the cost is small and a stale cache would cause subtle bugs across rounds. The `winner` field of `VoteTally` is `undefined` only when no votes have been recorded — even a single vote is sufficient for a tally winner. Callers MUST check `hasQuorum` separately before promoting that winner; the `tally()` API intentionally does not bake quorum into its return shape so consumers can observe partial-tally telemetry mid-round.

View File

@@ -0,0 +1,86 @@
import { NETWORK_GOV_CODES, NetworkGovError } from "../error-codes.js";
/**
* Single vote cast by `voterId` for a candidate `peerId`.
*/
export interface Vote {
readonly voterId: string;
readonly candidateId: string;
}
/**
* Configuration for `VotingManager`.
*/
export interface VotingManagerConfig {
/**
* Fraction of `knownPeers` required for a quorum.
* `0.5` (default) means strict majority.
*/
readonly quorumFraction?: number;
}
/**
* Aggregated tally for one election round.
*/
export interface VoteTally {
readonly counts: ReadonlyMap<string, number>;
readonly totalVotes: number;
readonly winner: string | undefined;
}
/**
* Collects votes from peers via `recordVote`, deduplicates by voter id,
* and surfaces a tally when a configurable fraction of the known peer
* set has voted (`hasQuorum`). The tally tiebreaks on lexicographic
* `candidateId` so independent observers agree on the winner.
*/
export class VotingManager {
private readonly votes = new Map<string, string>();
private readonly quorumFraction: number;
public constructor(config: VotingManagerConfig = {}) {
this.quorumFraction = config.quorumFraction ?? 0.5;
}
public recordVote(vote: Vote): void {
if (this.votes.has(vote.voterId)) {
throw new NetworkGovError(
NETWORK_GOV_CODES.DUPLICATE_VOTE,
"voting.duplicate-vote",
{ context: { voterId: vote.voterId } }
);
}
this.votes.set(vote.voterId, vote.candidateId);
}
public hasQuorum(knownPeers: number): boolean {
if (knownPeers <= 0) return false;
return this.votes.size / knownPeers >= this.quorumFraction;
}
public tally(): VoteTally {
const counts = new Map<string, number>();
for (const candidateId of this.votes.values()) {
counts.set(candidateId, (counts.get(candidateId) ?? 0) + 1);
}
let winner: string | undefined;
let bestCount = -1;
for (const [candidateId, count] of counts) {
if (count > bestCount) {
bestCount = count;
winner = candidateId;
} else if (count === bestCount && winner !== undefined) {
if (candidateId < winner) winner = candidateId;
}
}
return { counts, totalVotes: this.votes.size, winner };
}
public clear(): void {
this.votes.clear();
}
public size(): number {
return this.votes.size;
}
}

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { VotingManager } from "./voting-manager.js";
describe("VotingManager", () => {
it("records votes and reports size", () => {
const v = new VotingManager();
v.recordVote({ voterId: "alice", candidateId: "host-x" });
v.recordVote({ voterId: "bob", candidateId: "host-x" });
expect(v.size()).toBe(2);
});
it("rejects duplicate votes from the same voter", () => {
const v = new VotingManager();
v.recordVote({ voterId: "alice", candidateId: "host-x" });
expect(() =>
v.recordVote({ voterId: "alice", candidateId: "host-y" })
).toThrow("voting.duplicate-vote");
});
it("hasQuorum returns false for zero peers", () => {
const v = new VotingManager();
expect(v.hasQuorum(0)).toBe(false);
});
it("hasQuorum respects the configured fraction", () => {
const v = new VotingManager({ quorumFraction: 0.5 });
v.recordVote({ voterId: "alice", candidateId: "host-x" });
v.recordVote({ voterId: "bob", candidateId: "host-x" });
expect(v.hasQuorum(4)).toBe(true);
expect(v.hasQuorum(5)).toBe(false);
});
it("tally returns counts and the winner", () => {
const v = new VotingManager();
v.recordVote({ voterId: "alice", candidateId: "host-x" });
v.recordVote({ voterId: "bob", candidateId: "host-x" });
v.recordVote({ voterId: "carol", candidateId: "host-y" });
const tally = v.tally();
expect(tally.counts.get("host-x")).toBe(2);
expect(tally.counts.get("host-y")).toBe(1);
expect(tally.totalVotes).toBe(3);
expect(tally.winner).toBe("host-x");
});
it("tally tiebreaks lexicographically on candidate id", () => {
const v = new VotingManager();
v.recordVote({ voterId: "alice", candidateId: "host-z" });
v.recordVote({ voterId: "bob", candidateId: "host-a" });
const tally = v.tally();
expect(tally.winner).toBe("host-a");
});
it("tally returns undefined winner for empty input", () => {
const v = new VotingManager();
const tally = v.tally();
expect(tally.winner).toBeUndefined();
expect(tally.totalVotes).toBe(0);
});
it("clear resets state", () => {
const v = new VotingManager();
v.recordVote({ voterId: "alice", candidateId: "host-x" });
v.clear();
expect(v.size()).toBe(0);
});
});

View File

@@ -0,0 +1,13 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("network-plugin-governance dep-graph", () => {
it("loads index.js (acyclic static imports)", async () => {
const mod = await import("../src/index.js");
expect(mod.GovernancePlugin).toBeDefined();
expect(mod.ElectionEngine).toBeDefined();
expect(mod.CandidateScorer).toBeDefined();
expect(mod.VotingManager).toBeDefined();
expect(mod.ReputationTracker).toBeDefined();
expect(mod.TransitionManager).toBeDefined();
});
});

View File

@@ -0,0 +1,8 @@
// Ensure `globalThis.crypto` is available in bare-Node vitest runs.
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"references": [{ "path": "./tsconfig.lib.json" }]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir:
"../../../node_modules/.vite/packages/network-plugin-governance",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/network",
"@suaveplan/types",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: [
"src/__benchmarks__/**",
"benchmarks/**",
"e2e/**",
"dist/**",
"node_modules/**",
],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.schemas.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});