crisis/tests/test_demo_fact_check.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

109 lines
3.8 KiB
Python

"""End-to-end test: the fact_check scenario walks the decentralized flow
and produces a quorum-ratified proof."""
import json
from pathlib import Path
from crisis_agents.cli import main as cli_main
from crisis_agents.mothership import Mothership
from crisis_agents.proof import (
ProofDocument,
build_proof,
verify_proof_self_consistent,
)
from crisis_agents.scenarios import build_fact_check_scenario
from crisis_agents.vote import quorum_for
class TestFactCheckEndToEnd:
def test_scenario_loads(self):
s = build_fact_check_scenario()
assert s.name == "fact_check"
assert len(s.honest_agents) == 3
assert s.byzantine_joiner.name == "agent_delta"
assert s.crisis_phase_turns == 2 # intro + equivocation
assert "Pluto" in s.reference_doc
def test_runs_through_all_phases(self):
s = build_fact_check_scenario()
m = Mothership()
for a in s.honest_agents:
m.add_agent(a)
m.run_closed_phase(num_turns=s.closed_phase_turns)
m.open_boundary(s.byzantine_joiner)
m.run_crisis_phase(num_turns=s.crisis_phase_turns,
gossip_rounds_per_turn=1)
m.emit_alarms_from_detectors()
m.run_gossip_round()
# Every honest agent ratifies the same single alarm.
threshold = quorum_for(m.boundary.size())
ratified_sets = [
m.ratified_alarms_from(name)
for name in ("agent_alpha", "agent_beta", "agent_gamma")
]
assert ratified_sets[0] == ratified_sets[1] == ratified_sets[2]
assert len(ratified_sets[0]) == 1
r = ratified_sets[0][0]
assert r.statement_id == "s03"
assert r.quorum_threshold == threshold
assert r.signer_count >= threshold
def test_proof_round_trips_through_json(self, tmp_path):
s = build_fact_check_scenario()
m = Mothership()
for a in s.honest_agents:
m.add_agent(a)
m.run_closed_phase(num_turns=s.closed_phase_turns)
m.open_boundary(s.byzantine_joiner)
m.run_crisis_phase(num_turns=s.crisis_phase_turns,
gossip_rounds_per_turn=1)
m.emit_alarms_from_detectors()
m.run_gossip_round()
r = m.ratified_alarms_from("agent_alpha")[0]
proof = build_proof(r)
out = tmp_path / "proof.json"
out.write_text(proof.to_json())
reloaded = ProofDocument.from_json(out.read_text())
assert verify_proof_self_consistent(reloaded).ok
class TestCli:
def test_cli_demo_runs(self, tmp_path, capsys):
exit_code = cli_main(["demo", "--scenario", "fact_check",
"--out-dir", str(tmp_path)])
assert exit_code == 0
captured = capsys.readouterr()
# The five named phases appear
for phase in ("Phase 1", "Phase 2", "Phase 3",
"Phase 4", "Phase 5", "Phase 6"):
assert phase in captured.out
# The chokepoint-free marker prints
assert "no chokepoint" in captured.out
# Exactly one proof file written
proofs = list(tmp_path.glob("proof_*.json"))
assert len(proofs) == 1
obj = json.loads(proofs[0].read_text())
assert obj["statement_id"] == "s03"
assert len(obj["signer_process_id_hexes"]) >= 3
def test_cli_verify_passes_on_valid_proof(self, tmp_path, capsys):
cli_main(["demo", "--scenario", "fact_check", "--out-dir", str(tmp_path)])
proof_path = next(tmp_path.glob("proof_*.json"))
capsys.readouterr()
exit_code = cli_main(["verify", str(proof_path)])
assert exit_code == 0
out = capsys.readouterr().out
assert "self-consistent: True" in out
def test_cli_unknown_scenario(self, capsys):
exit_code = cli_main(["demo", "--scenario", "nonexistent"])
assert exit_code == 2