mirror of
https://github.com/saymrwulf/crisis.git
synced 2026-09-08 19:50:32 +00:00
142 lines
5 KiB
Python
142 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
|