crisis/tests/test_mothership.py
saymrwulf a1064660d5 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 21:55:49 +02:00

158 lines
6.4 KiB
Python

"""Tests for the slimmed-down Mothership (bootstrap + clock + routing only)."""
import pytest
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:
"""A benign 'I have joined' claim for the byzantine's first turn."""
return Claim(statement_id=f"intro:{name}", verdict="unknown", confidence=1.0,
evidence=f"{name} joining the team", timestamp_logical=0)
class TestClosedPhase:
def test_no_dag_in_closed_phase_for_active_agents(self):
"""In the closed phase, agents don't extend their graphs."""
m = Mothership()
m.add_agent(MockAgent("a", [[_claim("s01")]]))
m.add_agent(MockAgent("b", [[_claim("s01")]]))
result = m.run_closed_phase(num_turns=1)
# Two agents emitted one claim each via the closed-phase log
assert len(result.closed_log) == 2
# No Crisis messages sent yet, so per-agent graphs are still empty
for agent in m.agents.values():
assert agent.graph.vertex_count() == 0
assert not m.boundary.is_open
def test_add_agent_after_open_rejected(self):
m = Mothership()
m.add_agent(MockAgent("a", [[_claim("s01")]]))
m.open_boundary(MockByzantineAgent("byz", _intro("byz"), [], set(), set()))
with pytest.raises(RuntimeError, match="cannot add_agent"):
m.add_agent(MockAgent("late", []))
class TestCrisisPhaseAgentOwnership:
def test_each_agent_owns_its_graph(self):
"""After open_boundary every agent has its own LamportGraph."""
m = Mothership()
m.add_agent(MockAgent("a", [[]]))
m.add_agent(MockAgent("b", [[]]))
joiner = MockByzantineAgent("d", _intro(), [], set(), set())
m.open_boundary(joiner)
# Each agent has a graph attribute, and they're distinct objects
graphs = [a.graph for a in m.agents.values()]
assert len(graphs) == 3
assert len({id(g) for g in graphs}) == 3 # distinct identity
for g in graphs:
assert g.vertex_count() == 0
def test_broadcast_emission_reaches_every_agent(self):
"""A target_subset=None emission ends up in every peer's graph."""
m = Mothership()
m.add_agent(MockAgent("a", [[]]))
m.add_agent(MockAgent("b", [[]]))
# Joiner with a single broadcast intro, no equivocation script
joiner = MockByzantineAgent("d", _intro(), [], set(), set())
m.open_boundary(joiner)
m.run_crisis_phase(num_turns=1, gossip_rounds_per_turn=0)
for name, agent in m.agents.items():
assert agent.graph.vertex_count() == 1, (
f"agent {name!r} should have received the intro broadcast"
)
def test_targeted_emission_skips_non_targets(self):
"""A target_subset emission only reaches its named peers."""
m = Mothership()
m.add_agent(MockAgent("a", [[]]))
m.add_agent(MockAgent("b", [[]]))
# Byzantine: emits intro to everyone (turn 0), then equivocation
# to {a} vs {b} (turn 1).
byz = MockByzantineAgent(
"d", _intro(),
scripted_pairs=[(
_claim("s03", verdict="true", evidence="to_a"),
_claim("s03", verdict="false", evidence="to_b"),
)],
split_a={"a"},
split_b={"b"},
)
m.open_boundary(byz)
m.run_crisis_phase(num_turns=2, gossip_rounds_per_turn=0)
# a has: intro + variant-true; b has: intro + variant-false; d has: intro
graphs = {n: a.graph for n, a in m.agents.items()}
assert graphs["a"].vertex_count() == 2
assert graphs["b"].vertex_count() == 2
assert graphs["d"].vertex_count() == 1 # targeted emissions skip sender
# The variant payloads are distinct between a and b
a_payloads = [v.payload for v in graphs["a"].all_vertices()]
b_payloads = [v.payload for v in graphs["b"].all_vertices()]
assert any(b'"verdict":"true"' in p for p in a_payloads)
assert any(b'"verdict":"false"' in p for p in b_payloads)
class TestGossipRound:
def test_gossip_propagates_byzantine_equivocation(self):
"""After one gossip round, every honest agent has both variants —
the prerequisite for decentralized detection."""
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)
# Two turns (intro + equivocation), then gossip
m.run_crisis_phase(num_turns=2, gossip_rounds_per_turn=1)
# After gossip, every honest agent should have both byzantine variants
# (intro + 2 equivocations = 3 vertices minimum). The byzantine itself
# ends up with intro + everything its peers shared back.
for name in ("a", "b", "c"):
payloads = [v.payload for v in m.agents[name].graph.all_vertices()]
assert any(b'"verdict":"true"' in p for p in payloads), (
f"agent {name!r} missing the true-variant after gossip"
)
assert any(b'"verdict":"false"' in p for p in payloads), (
f"agent {name!r} missing the false-variant after gossip"
)
def test_mothership_doesnt_hold_a_graph_dict(self):
"""Regression guard against the chokepoint we just removed."""
m = Mothership()
# The old API exposed `m.all_graphs()` and `m.graph_of(name)`.
# Neither should exist now.
assert not hasattr(m, "all_graphs")
assert not hasattr(m, "graph_of")
assert not hasattr(m, "_graphs")
def test_run_crisis_phase_requires_open_boundary(self):
m = Mothership()
m.add_agent(MockAgent("a", [[_claim("s01")]]))
with pytest.raises(RuntimeError, match="boundary not yet open"):
m.run_crisis_phase(num_turns=1)