crisis/tests/test_no_chokepoint.py

83 lines
3 KiB
Python
Raw Normal View History

Decentralize crisis_agents: agents own graphs, detect locally, vote by quorum 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>
2026-05-14 19:55:49 +00:00
"""The centerpiece: assert there is no privileged observer.
After the full lifecycle closed phase, boundary open, equivocation, gossip,
alarm emission, alarm gossip *every honest agent's* ratified-alarms set must
be identical. If we ever re-introduce a chokepoint (a single observer holding
state nobody else can see), this test should fail.
"""
from crisis_agents.agent import MockAgent, MockByzantineAgent
from crisis_agents.claim import Claim
from crisis_agents.mothership import Mothership
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 test_all_honest_agents_agree_on_ratified_alarms():
m = Mothership()
m.add_agent(MockAgent("a", [[]]))
m.add_agent(MockAgent("b", [[]]))
m.add_agent(MockAgent("c", [[]]))
m.open_boundary(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.run_crisis_phase(num_turns=2, gossip_rounds_per_turn=1)
m.emit_alarms_from_detectors()
m.run_gossip_round()
# The headline assertion: three independent vantage points; same result.
ratified_per_agent = {
name: m.ratified_alarms_from(name)
for name in ("a", "b", "c")
}
assert ratified_per_agent["a"] == ratified_per_agent["b"]
assert ratified_per_agent["b"] == ratified_per_agent["c"]
assert len(ratified_per_agent["a"]) == 1
def test_no_chokepoint_attribute_on_mothership():
"""Smoke-check: the mothership doesn't expose any privileged collection
of per-agent graphs. Each agent owns its own state.
"""
m = Mothership()
# If anyone re-adds these, the test fails loudly.
for forbidden in ("all_graphs", "graph_of", "_graphs",
"scan_for_mutations", "detect_byzantine"):
assert not hasattr(m, forbidden), (
f"Mothership grew back a chokepoint: {forbidden}"
)
def test_byzantine_alone_cannot_ratify():
"""If only the byzantine emits an AlarmClaim (against a fictitious target),
no quorum is reached.
"""
m = Mothership()
m.add_agent(MockAgent("a", [[]]))
m.add_agent(MockAgent("b", [[]]))
m.add_agent(MockAgent("c", [[]]))
# No equivocation script — boundary opens cleanly.
m.open_boundary(MockByzantineAgent("d", _intro(), [], set(), set()))
m.run_crisis_phase(num_turns=1, gossip_rounds_per_turn=1)
m.emit_alarms_from_detectors()
m.run_gossip_round()
# No honest agent should have ratified anything.
for name in ("a", "b", "c"):
assert m.ratified_alarms_from(name) == []