mirror of
https://github.com/saymrwulf/crisis.git
synced 2026-09-06 19:30:35 +00:00
The previous design routed every Crisis message through a `Mothership` that
also held every agent's LamportGraph, ran the byzantine scan from a
privileged vantage, and built proofs from its own view. That made the
mothership a chokepoint — exactly what a BFT layer is supposed to remove.
This commit redistributes responsibility along the lines you'd expect from
a real open protocol:
Each `CrisisAgent` now owns:
- its own `LamportGraph` (the agent's view of the network)
- `emit_claim(claim) → Message`: wraps a Claim into a fully-valid Crisis
Message built from the agent's OWN graph state, with chain link + cross
references + mined PoW nonce
- `receive(message)`: extends my graph if integrity holds; idempotent
- `gossip_to(peer) → int`: shares everything I have with peer until
quiescence (Algorithm 4 in the paper, in-process flavor)
- `detect_mutations() → list[LocalAlarm]`: scans MY graph for same-id
spacelike vertex pairs via the existing
`LamportGraph.find_mutations`, filtered by application-layer
`statement_id` so cross-detector AlarmClaims canonicalize
The `Mothership` shrinks to coordinator-only:
- bootstrap (register honest agents; trigger boundary open with a joiner)
- clock (call each agent's `next_turn()` per turn)
- first-hop routing (sender's emission → declared target subset)
- all-pairs gossip rounds between turns
- emit_alarms_from_detectors(): poll each agent for its LocalAlarms,
wrap any returned alarms into AlarmClaim payloads, broadcast them as
Crisis Messages over the gossip layer
Gone (regression-tested in `test_no_chokepoint.py`):
- `Mothership._graphs`, `Mothership.all_graphs()`, `Mothership.graph_of()`
- `alarm.scan_for_mutations(mothership)`
- any path where the mothership reads an agent's internal state
New voting layer (`crisis_agents/vote.py`):
- `AlarmClaim`: a Crisis-payload dataclass discriminated by `kind="alarm"`.
Wraps the accused process_id, statement_id, witness_digests, and
detection turn. Round-trips through JSON same as Claim.
- `quorum_for(n) = ceil(2n/3)`: classic BFT threshold.
- `tally_alarms(graph, threshold)`: groups AlarmClaim vertices by
(accused, statement_id, witness_pair), counts unique signer
process_ids, ratifies groups meeting the threshold. Deterministic
ordering so two equal graphs produce equal `RatifiedAlarm` lists.
- `RatifiedAlarm`: the network-level consensus on byzantine behavior.
Multi-signer proofs (`crisis_agents/proof.py`):
- schema_version bumped 1 → 2.
- ProofDocument now embeds every signer's process_id_hex and the
quorum threshold that was met. Self-consistency check enforces
distinct signers, witness pairs, and signer count ≥ threshold.
Byzantine scenario rewrite:
- `MockByzantineAgent` now takes an `intro_claim` for its first turn (a
benign broadcast). The intro is technically necessary: the agent's two
contradictory variants both chain to the intro vertex, so they can
propagate through gossip — without it, the second variant would fail
the chain constraint in any graph already holding the first.
- `fact_check` scenario: closed phase still has 3 honest agents emitting
6 claims each into the closed log; Crisis phase grew to 2 turns (intro
+ equivocation) so the byzantine can establish its same-id anchor
before equivocating.
End-to-end CLI output reframed around six phases:
1. closed team (no Crisis)
2. boundary opens
3. emission + gossip
4. decentralized detection (each agent reports its own findings)
5. alarms emitted + gossiped + ratified by quorum
6. proof emission
Tests (51 fresh + 5 carried over for boundary):
- `test_mothership.py`: per-agent graph ownership, broadcast vs.
targeted delivery semantics, gossip propagation, regression guards
against the removed centralization attributes.
- `test_alarm.py`: every honest agent independently detects the same
mutation; the byzantine doesn't detect itself; witness pairs are
canonical across detectors.
- `test_vote.py`: AlarmClaim round-trip, quorum formulas, tally
determinism, mothership convenience method matches direct tallying.
- `test_proof.py`: build_proof from RatifiedAlarm; multi-signer JSON
round-trip; tampered-witness/below-quorum/duplicate-signer rejection.
- `test_no_chokepoint.py` (the centerpiece): after the full lifecycle,
every honest agent's ratified-alarm set is byte-identical. A single
byzantine accuser alone cannot ratify. Forbidden attributes don't
exist on Mothership.
Full suite: 163 tests, all green in 0.80s.
CrisisViz: untouched by this refactor. The `crisis_data.json` pipeline
the visualizer consumes is produced by the orthogonal
`crisis.demo.Simulation`, which this commit doesn't touch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
141 lines
5 KiB
Python
141 lines
5 KiB
Python
"""Tests for AlarmClaim + tally_alarms (the voting layer)."""
|
|
|
|
import pytest
|
|
|
|
from crisis_agents.agent import MockAgent, MockByzantineAgent
|
|
from crisis_agents.alarm import LocalAlarm
|
|
from crisis_agents.claim import Claim
|
|
from crisis_agents.mothership import Mothership
|
|
from crisis_agents.vote import (
|
|
AlarmClaim,
|
|
RatifiedAlarm,
|
|
collect_alarm_claims,
|
|
quorum_for,
|
|
tally_alarms,
|
|
)
|
|
|
|
|
|
def _claim(sid: str, verdict: str = "true", evidence: str = "ok") -> Claim:
|
|
return Claim(statement_id=sid, verdict=verdict, confidence=0.9, # type: ignore[arg-type]
|
|
evidence=evidence, timestamp_logical=0)
|
|
|
|
|
|
def _intro(name: str = "delta") -> Claim:
|
|
return Claim(statement_id=f"intro:{name}", verdict="unknown", confidence=1.0,
|
|
evidence=f"{name} joining the team", timestamp_logical=0)
|
|
|
|
|
|
def _full_run() -> Mothership:
|
|
"""3 honest + 1 byzantine; equivocation; gossip; alarms emitted;
|
|
final gossip propagates the AlarmClaims to every agent."""
|
|
m = Mothership()
|
|
m.add_agent(MockAgent("a", [[]]))
|
|
m.add_agent(MockAgent("b", [[]]))
|
|
m.add_agent(MockAgent("c", [[]]))
|
|
byz = MockByzantineAgent(
|
|
"d", _intro(),
|
|
scripted_pairs=[(
|
|
_claim("s03", verdict="true", evidence="to_ac"),
|
|
_claim("s03", verdict="false", evidence="to_b"),
|
|
)],
|
|
split_a={"a", "c"},
|
|
split_b={"b"},
|
|
)
|
|
m.open_boundary(byz)
|
|
m.run_crisis_phase(num_turns=2, gossip_rounds_per_turn=1)
|
|
# Honest agents emit AlarmClaims based on what they observed.
|
|
m.emit_alarms_from_detectors()
|
|
# One more gossip round so every honest agent sees all AlarmClaims.
|
|
m.run_gossip_round()
|
|
return m
|
|
|
|
|
|
class TestQuorumThreshold:
|
|
|
|
def test_quorum_formulas(self):
|
|
# ceil(2N/3) — the classic BFT threshold
|
|
assert quorum_for(1) == 1
|
|
assert quorum_for(2) == 2
|
|
assert quorum_for(3) == 2
|
|
assert quorum_for(4) == 3
|
|
assert quorum_for(7) == 5
|
|
assert quorum_for(10) == 7
|
|
|
|
|
|
class TestAlarmClaimRoundtrip:
|
|
|
|
def test_serialize_deserialize(self):
|
|
ac = AlarmClaim(
|
|
accused_process_id_hex="76468f93",
|
|
statement_id="s03",
|
|
witness_digests=("aaaa", "bbbb"),
|
|
detected_at_turn=1,
|
|
)
|
|
roundtrip = AlarmClaim.from_payload(ac.to_payload())
|
|
assert roundtrip == ac
|
|
|
|
def test_from_local_alarm(self):
|
|
la = LocalAlarm(
|
|
detector_name="a",
|
|
detector_process_id_hex="11",
|
|
accused_process_id_hex="22",
|
|
statement_id="s03",
|
|
witness_digests=("aa", "bb"),
|
|
)
|
|
ac = AlarmClaim.from_local_alarm(la, detected_at_turn=5)
|
|
assert ac.accused_process_id_hex == "22"
|
|
assert ac.statement_id == "s03"
|
|
assert ac.witness_digests == ("aa", "bb")
|
|
assert ac.detected_at_turn == 5
|
|
|
|
def test_rejects_non_alarm_payload(self):
|
|
regular_claim = Claim(
|
|
statement_id="s01", verdict="true", confidence=0.9,
|
|
evidence="ok", timestamp_logical=0,
|
|
)
|
|
with pytest.raises(ValueError, match="not an AlarmClaim"):
|
|
AlarmClaim.from_payload(regular_claim.to_payload())
|
|
|
|
|
|
class TestTallyAlarms:
|
|
|
|
def test_collect_alarm_claims_finds_only_alarms(self):
|
|
"""Mixed-payload graphs: alarm claims are picked, regular claims skipped."""
|
|
m = _full_run()
|
|
for name in ("a", "b", "c"):
|
|
collected = collect_alarm_claims(m.agents[name].graph)
|
|
signers = {signer for signer, _ in collected}
|
|
# The 3 honest agents have each emitted exactly one AlarmClaim
|
|
assert len(signers) == 3
|
|
|
|
def test_tally_meets_quorum(self):
|
|
"""3 honest detectors + threshold of 3 (ceil(2*4/3)) ⇒ ratified."""
|
|
m = _full_run()
|
|
# boundary size = 4 (3 honest + 1 byzantine joined)
|
|
threshold = quorum_for(m.boundary.size())
|
|
for name in ("a", "b", "c"):
|
|
ratified = tally_alarms(m.agents[name].graph,
|
|
quorum_threshold=threshold)
|
|
assert len(ratified) == 1
|
|
r = ratified[0]
|
|
assert isinstance(r, RatifiedAlarm)
|
|
assert r.statement_id == "s03"
|
|
assert r.signer_count >= threshold
|
|
assert r.quorum_threshold == threshold
|
|
|
|
def test_tally_blocks_single_signer(self):
|
|
"""A single AlarmClaim cannot ratify on its own."""
|
|
m = _full_run()
|
|
# Force a high quorum (4 of 4): nothing should ratify.
|
|
ratified = tally_alarms(m.agents["a"].graph, quorum_threshold=4)
|
|
assert ratified == []
|
|
|
|
def test_mothership_ratified_alarms_from_helper(self):
|
|
"""The convenience method on the mothership produces the same set
|
|
as direct tallying."""
|
|
m = _full_run()
|
|
threshold = quorum_for(m.boundary.size())
|
|
ratified_via_helper = m.ratified_alarms_from("a")
|
|
ratified_direct = tally_alarms(m.agents["a"].graph,
|
|
quorum_threshold=threshold)
|
|
assert ratified_via_helper == ratified_direct
|