mirror of
https://github.com/saymrwulf/crisis.git
synced 2026-05-14 20:37:54 +00:00
crisis_agents: drop the wall-clock, drive asynchronously to quiescence
The previous driver imposed a synchronous turn-counted clock that the
Crisis paper explicitly forbids — Crisis is supposed to work in
asynchronous P2P networks, with any synchronicity being virtual and
derived inside the consensus algorithm from the DAG structure, not
imposed externally by a coordinator. This commit removes the wall clock.
What changed in the engine:
- `Mothership.run_crisis_phase(num_turns, gossip_rounds_per_turn)`
is replaced by `run_until_quiescent(max_steps=200)`. The loop
interleaves three concerns on each iteration — emissions, gossip,
and alarm emissions — until none make progress. Termination is by
quiescence, not by a fixed turn count. `max_steps` is a safety
bound (loop-iteration cap), not an exposed clock.
- `Mothership.run_closed_phase(num_turns)` becomes
`run_closed_phase(max_steps=50)`. Same quiescence model — the
closed-phase conversation runs until no agent has more to say.
- Agents grew `pending_alarm_claims()`: each agent checks its own
graph for un-alarmed mutations and produces AlarmClaims directly.
The driver loop calls this every iteration, so alarms emit and
propagate in the same loop as regular emissions and gossip — no
separate "alarm phase."
- `Mothership.emit_alarms_from_detectors()` and the explicit
`run_gossip_round()` step are no longer needed by callers; both
are subsumed by the async loop. `run_gossip_round()` stays as a
helper but tests no longer call it externally.
What changed in the agent interface:
- `CrisisAgent.next_turn(turn, received_claims)` becomes
`try_emit()` — no arguments. Agents in an async network don't see
a global tick. They decide based on their own internal state.
- `CrisisAgent.observe(claim)` is the new optional callback the
closed-phase loop uses to feed context into agents that care
(overridden by LiveClaudeAgent to populate its prompt buffer).
- `pending_alarm_claims()` is idempotent: an internal
`_already_alarmed` set tracks claims this agent has emitted, so
the loop calls it every step without flooding the network with
duplicate alarms.
What changed in the dataclass schema:
- `AlarmClaim.detected_at_turn` -> `emitted_at_step`. The word
"turn" implies a global clock; "step" is a per-agent sequence
number used only for log ordering — local, not networked.
- `ClosedPhaseEntry.turn` and `CrisisPhaseEntry.turn` -> `step`.
Same rename, same reasoning.
- `Scenario.closed_phase_turns` and `Scenario.crisis_phase_turns`
are gone. The scenario no longer prescribes how many turns; it
just provides agents and lets the async loop run them out.
What changed in the CLI:
- Phase 3 reports "drove to quiescence in N step(s)" with a
breakdown of regular emissions / gossip transfers / alarm
emissions, instead of "ran N turns".
- `QuiescenceReport` (new dataclass) carries the run statistics
back from `run_until_quiescent`/`run_closed_phase` — steps taken,
emissions made, gossip transfers, alarm claims emitted, plus
whether termination was via quiescence or max-step cap.
New regression tests (`test_async_quiescence.py`):
- `test_run_until_quiescent_terminates`: the loop must exit.
- `test_two_runs_produce_identical_final_state`: determinism check —
if anything in the loop depended on real wall time, this would
fail.
- `test_max_steps_bound_caps_runtime`: setting max_steps=1 exits
immediately and `QuiescenceReport.reached_quiescence` reflects
reality.
- `test_no_turn_argument_exposed_to_agents`: introspects
`CrisisAgent.try_emit` signature; fails if anyone re-adds a
`turn` parameter.
- `test_no_turn_field_on_alarmclaim`: introspects the dataclass
fields; fails if `detected_at_turn` reappears.
- `test_alarms_propagate_through_async_loop_alone`: the loop alone
(no manual emit_alarms / run_gossip_round) ratifies an alarm.
- `test_quiescence_report_counts_match_logs`: sanity check that
the report's emission count equals the crisis log length.
Suite: 163 -> 170 tests, all green in 0.79s.
Behavioral end-state is identical to the previous (synchronous)
version: same fact-check scenario, same byzantine equivocation, same
proof JSON shape, same three signers, same quorum-met outcome. The
difference is structural: the protocol now matches the paper's async
shape, and a future port to actual TCP gossip + concurrent agents
needs no change to this engine.
CrisisViz: still untouched. The `crisis_data.json` pipeline that
drives the visualizer is orthogonal.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
a1064660d5
commit
0976239ebd
13 changed files with 509 additions and 401 deletions
|
|
@ -1,19 +1,19 @@
|
||||||
"""
|
"""
|
||||||
CrisisAgent — a first-class network participant.
|
CrisisAgent — a first-class network participant in an asynchronous network.
|
||||||
|
|
||||||
Each agent owns:
|
Each agent owns:
|
||||||
- a stable 32-byte process_id (derived from its name)
|
- a stable 32-byte process_id (derived from its name)
|
||||||
- its own LamportGraph (the agent's view of the network)
|
- its own LamportGraph (the agent's view of the network)
|
||||||
- its own weight system (shared across the network for compatibility)
|
- its own weight system (shared across the network for compatibility)
|
||||||
- the means to wrap Claims into Crisis Messages and extend its own graph
|
- decision logic: `try_emit()` is asked "do you have something to say?",
|
||||||
|
`pending_alarm_claims()` is asked "do you currently observe an
|
||||||
|
un-alarmed equivocation?"
|
||||||
|
|
||||||
Crucially the agent is **NOT a passive script driven by the mothership**. The
|
There is no global clock. Agents don't see a "turn number" because there
|
||||||
mothership coordinates the clock and the bootstrap; the agent does the work.
|
isn't one — any synchronicity in the network is virtual, derived from the
|
||||||
|
DAG structure by the consensus algorithm itself (not by the driver loop).
|
||||||
This is the change from the centralized version: previously the mothership
|
The mothership/driver just cycles asking each agent for any pending
|
||||||
held a dict of all agents' graphs and called `_wrap_as_message` against each.
|
content until the network is quiescent.
|
||||||
Now `emit_claim` lives on the agent. The mothership routes the resulting
|
|
||||||
message to its delivery targets, but it never reads the graph.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -38,27 +38,29 @@ def agent_id_from_name(name: str) -> bytes:
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AgentTurn:
|
class AgentTurn:
|
||||||
"""One emission from an agent in a given turn.
|
"""One emission from an agent.
|
||||||
|
|
||||||
|
The word "turn" here is vestigial — it means "this single emission
|
||||||
|
event," not "a tick of a global clock." Kept because renaming the type
|
||||||
|
causes more churn than it's worth.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
claim: The Claim being emitted.
|
claim: The Claim being emitted.
|
||||||
target_subset: None means broadcast (every agent including sender
|
target_subset: None means broadcast to every peer including
|
||||||
receives it via the mothership's initial routing).
|
sender. A set of peer names means initial delivery
|
||||||
A set of peer names means initial delivery is limited
|
is limited to those peers (the byzantine
|
||||||
to those peers — the byzantine equivocation building
|
equivocation building block).
|
||||||
block. Subsequent gossip rounds may propagate it to
|
|
||||||
other peers anyway.
|
|
||||||
"""
|
"""
|
||||||
claim: Claim
|
claim: Claim
|
||||||
target_subset: Optional[set[str]] = None
|
target_subset: Optional[set[str]] = None
|
||||||
|
|
||||||
|
|
||||||
class CrisisAgent(ABC):
|
class CrisisAgent(ABC):
|
||||||
"""A network participant with its own graph and its own brain.
|
"""An asynchronous network participant.
|
||||||
|
|
||||||
Concrete subclasses implement `next_turn` to decide what to say. The
|
Concrete subclasses implement `try_emit` to decide what to say. The
|
||||||
base class handles emit/receive/gossip mechanics so every agent — mock
|
base class handles emit/receive/gossip/detect mechanics uniformly so
|
||||||
or live — uses the same Crisis-protocol machinery underneath.
|
every agent — mock or live — uses the same Crisis substrate.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, name: str, *, weight_system: Optional[WeightSystem] = None):
|
def __init__(self, name: str, *, weight_system: Optional[WeightSystem] = None):
|
||||||
|
|
@ -68,16 +70,38 @@ class CrisisAgent(ABC):
|
||||||
self.process_id: bytes = agent_id_from_name(name)
|
self.process_id: bytes = agent_id_from_name(name)
|
||||||
self.weight_system: WeightSystem = weight_system or ProofOfWorkWeight(min_leading_zeros=0)
|
self.weight_system: WeightSystem = weight_system or ProofOfWorkWeight(min_leading_zeros=0)
|
||||||
self.graph: LamportGraph = LamportGraph(weight_system=self.weight_system)
|
self.graph: LamportGraph = LamportGraph(weight_system=self.weight_system)
|
||||||
|
# Track alarms we've already emitted so pending_alarm_claims doesn't
|
||||||
|
# repeat. Keyed by (accused, statement_id, sorted-witness-pair).
|
||||||
|
self._already_alarmed: set[tuple[str, str, tuple[str, str]]] = set()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Decision-making — to be implemented by subclasses
|
# Decision-making — implemented by subclasses
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def next_turn(self, turn: int, received_claims: list[Claim]) -> list[AgentTurn]:
|
def try_emit(self) -> list[AgentTurn]:
|
||||||
"""Produce this agent's emissions for the given turn."""
|
"""Return any emissions the agent is ready to make right now.
|
||||||
|
|
||||||
|
The agent decides based on its own internal state. The driver loop
|
||||||
|
asks this repeatedly until the agent returns nothing. There is no
|
||||||
|
turn argument — agents in an async network don't see a global tick.
|
||||||
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
def observe(self, claim: Claim) -> None:
|
||||||
|
"""Optional callback for pre-Crisis context.
|
||||||
|
|
||||||
|
Used by the closed-phase loop: when one agent emits a claim, every
|
||||||
|
other agent's `observe(claim)` is called so they can incorporate
|
||||||
|
the conversation history into their own state. Default is no-op;
|
||||||
|
subclasses like LiveClaudeAgent override to maintain a context
|
||||||
|
buffer for their LLM prompt.
|
||||||
|
|
||||||
|
In the Crisis phase this is NOT called — agents introspect their
|
||||||
|
own LamportGraph for context. The closed phase has no graph yet.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Crisis-protocol mechanics — uniform across all agents
|
# Crisis-protocol mechanics — uniform across all agents
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -86,17 +110,20 @@ class CrisisAgent(ABC):
|
||||||
"""Wrap a Claim into a fully-valid Crisis Message built FROM this
|
"""Wrap a Claim into a fully-valid Crisis Message built FROM this
|
||||||
agent's own graph state.
|
agent's own graph state.
|
||||||
|
|
||||||
The agent does NOT extend its own graph here — the mothership decides
|
The agent does NOT extend its own graph here — the routing layer
|
||||||
whether the sender receives a copy (broadcast: yes; targeted: no, to
|
decides whether the sender's own graph receives a copy.
|
||||||
enable byzantine equivocation without immediately failing the chain
|
|
||||||
constraint in the sender's own graph).
|
|
||||||
"""
|
"""
|
||||||
payload = claim.to_payload()
|
return self._build_message(claim.to_payload())
|
||||||
|
|
||||||
|
def _build_message(self, payload: bytes) -> Message:
|
||||||
|
"""Build a Crisis Message with arbitrary payload bytes.
|
||||||
|
|
||||||
|
Used by both `emit_claim` (regular Claims) and the alarm-emission
|
||||||
|
path (AlarmClaim payloads).
|
||||||
|
"""
|
||||||
digests_list: list[bytes] = []
|
digests_list: list[bytes] = []
|
||||||
|
|
||||||
# Step 1: chain link — if there's any same-id vertex in MY graph, the
|
# Chain link
|
||||||
# new message must reference one of them.
|
|
||||||
same_id = [v for v in self.graph.all_vertices() if v.id == self.process_id]
|
same_id = [v for v in self.graph.all_vertices() if v.id == self.process_id]
|
||||||
past_digests: set[bytes] = set()
|
past_digests: set[bytes] = set()
|
||||||
if same_id:
|
if same_id:
|
||||||
|
|
@ -113,7 +140,7 @@ class CrisisAgent(ABC):
|
||||||
digests_list.append(last.message_digest)
|
digests_list.append(last.message_digest)
|
||||||
past_digests = {v.message_digest for v in self.graph.past(last)}
|
past_digests = {v.message_digest for v in self.graph.past(last)}
|
||||||
|
|
||||||
# Step 2: cross-references — one most-recent vertex per other id.
|
# Cross-references
|
||||||
seen_other_ids: set[bytes] = {self.process_id}
|
seen_other_ids: set[bytes] = {self.process_id}
|
||||||
for v in self.graph.all_vertices():
|
for v in self.graph.all_vertices():
|
||||||
if v.id in seen_other_ids:
|
if v.id in seen_other_ids:
|
||||||
|
|
@ -123,10 +150,10 @@ class CrisisAgent(ABC):
|
||||||
digests_list.append(v.message_digest)
|
digests_list.append(v.message_digest)
|
||||||
seen_other_ids.add(v.id)
|
seen_other_ids.add(v.id)
|
||||||
|
|
||||||
# Step 3: mine a valid PoW nonce.
|
# Mine PoW
|
||||||
if isinstance(self.weight_system, ProofOfWorkWeight):
|
if isinstance(self.weight_system, ProofOfWorkWeight):
|
||||||
return self.weight_system.mine_nonce(
|
return self.weight_system.mine_nonce(
|
||||||
self.process_id, tuple(digests_list), payload
|
self.process_id, tuple(digests_list), payload,
|
||||||
)
|
)
|
||||||
return Message(
|
return Message(
|
||||||
nonce=os.urandom(NONCE_LENGTH),
|
nonce=os.urandom(NONCE_LENGTH),
|
||||||
|
|
@ -136,36 +163,17 @@ class CrisisAgent(ABC):
|
||||||
)
|
)
|
||||||
|
|
||||||
def receive(self, message: Message) -> Optional[Vertex]:
|
def receive(self, message: Message) -> Optional[Vertex]:
|
||||||
"""Extend my graph with the given message if integrity holds.
|
"""Extend my graph with the given message if integrity holds."""
|
||||||
|
|
||||||
Returns the resulting Vertex on success, None if the integrity check
|
|
||||||
rejects it (duplicate, missing references, broken chain). Receiving
|
|
||||||
is idempotent: extending with a message whose digest is already in
|
|
||||||
the graph is a silent no-op (returns None).
|
|
||||||
"""
|
|
||||||
if message.compute_digest() in self.graph:
|
if message.compute_digest() in self.graph:
|
||||||
return None
|
return None
|
||||||
return self.graph.extend(message)
|
return self.graph.extend(message)
|
||||||
|
|
||||||
def detect_mutations(self):
|
|
||||||
"""Scan MY graph for byzantine equivocation. Returns a list of
|
|
||||||
LocalAlarms (defined in alarm.py). Imported lazily to avoid a
|
|
||||||
cyclic import at module load time.
|
|
||||||
"""
|
|
||||||
from crisis_agents.alarm import detect_mutations_in_graph
|
|
||||||
return detect_mutations_in_graph(self.graph, self.name, self.process_id)
|
|
||||||
|
|
||||||
def gossip_to(self, peer: "CrisisAgent") -> int:
|
def gossip_to(self, peer: "CrisisAgent") -> int:
|
||||||
"""Share my vertices with `peer`. Returns the count newly accepted.
|
"""Share my vertices with `peer`. Returns count newly accepted.
|
||||||
|
|
||||||
Iterates until no progress: a message can only be accepted after all
|
Iterates until no progress: a message is only accepted after all
|
||||||
its referenced digests already exist in the peer's graph, so this is
|
its referenced digests already exist in the peer's graph
|
||||||
a multi-pass extend (Algorithm 4 in the paper, in-process flavor).
|
(Algorithm 4 in the paper, in-process flavor).
|
||||||
|
|
||||||
Honest gossip: the sender doesn't pick what to share — it shares
|
|
||||||
everything it has, and the peer's integrity check filters. A byzantine
|
|
||||||
could selectively gossip, but that's modeled at emit time, not gossip
|
|
||||||
time; we don't expose a "skip this vertex" hook here.
|
|
||||||
"""
|
"""
|
||||||
accepted = 0
|
accepted = 0
|
||||||
progress = True
|
progress = True
|
||||||
|
|
@ -179,6 +187,49 @@ class CrisisAgent(ABC):
|
||||||
progress = True
|
progress = True
|
||||||
return accepted
|
return accepted
|
||||||
|
|
||||||
|
def detect_mutations(self):
|
||||||
|
"""Scan MY graph for same-id spacelike vertex pairs.
|
||||||
|
|
||||||
|
Returns a list of LocalAlarms. Imported lazily to avoid a cyclic
|
||||||
|
import at module load time.
|
||||||
|
"""
|
||||||
|
from crisis_agents.alarm import detect_mutations_in_graph
|
||||||
|
return detect_mutations_in_graph(self.graph, self.name, self.process_id)
|
||||||
|
|
||||||
|
def pending_alarm_claims(self) -> list:
|
||||||
|
"""Run detection and produce AlarmClaim payloads for any newly
|
||||||
|
observed equivocations.
|
||||||
|
|
||||||
|
An "already alarmed" set tracks claims this agent has already
|
||||||
|
emitted, so calling this repeatedly is idempotent — the driver
|
||||||
|
loop can call it until quiescence without flooding the network
|
||||||
|
with duplicate AlarmClaims.
|
||||||
|
|
||||||
|
Returns a list of AlarmClaim instances (defined in vote.py) that
|
||||||
|
the driver should broadcast on the agent's behalf.
|
||||||
|
"""
|
||||||
|
from crisis_agents.vote import AlarmClaim
|
||||||
|
|
||||||
|
local_alarms = self.detect_mutations()
|
||||||
|
new_claims: list = []
|
||||||
|
for la in local_alarms:
|
||||||
|
# Canonical key for dedup
|
||||||
|
key = (la.accused_process_id_hex, la.statement_id, la.witness_digests)
|
||||||
|
if key in self._already_alarmed:
|
||||||
|
continue
|
||||||
|
# detected_at_step is the agent's local sequence number — we
|
||||||
|
# don't have a meaningful global step, so we use the count of
|
||||||
|
# alarms already raised by this agent as a stable ordinal.
|
||||||
|
ac = AlarmClaim(
|
||||||
|
accused_process_id_hex=la.accused_process_id_hex,
|
||||||
|
statement_id=la.statement_id,
|
||||||
|
witness_digests=la.witness_digests,
|
||||||
|
emitted_at_step=len(self._already_alarmed),
|
||||||
|
)
|
||||||
|
new_claims.append(ac)
|
||||||
|
self._already_alarmed.add(key)
|
||||||
|
return new_claims
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"{type(self).__name__}(name={self.name!r}, id={self.process_id.hex()[:8]}...)"
|
return f"{type(self).__name__}(name={self.name!r}, id={self.process_id.hex()[:8]}...)"
|
||||||
|
|
||||||
|
|
@ -191,13 +242,10 @@ class CrisisAgent(ABC):
|
||||||
class MockAgent(CrisisAgent):
|
class MockAgent(CrisisAgent):
|
||||||
"""An agent that emits a predetermined sequence of claims.
|
"""An agent that emits a predetermined sequence of claims.
|
||||||
|
|
||||||
`scripted_claims[N]` is the list of Claims this agent emits on its Nth
|
`scripted_claims[N]` is the list of Claims emitted on the agent's Nth
|
||||||
`next_turn()` invocation. The agent maintains its own invocation counter
|
`try_emit()` invocation. After the script is exhausted, the agent
|
||||||
independent of the mothership's turn counter, so it can be reused across
|
emits nothing forever — the driver loop's quiescence check terminates
|
||||||
closed-phase and Crisis-phase calls without restarting.
|
naturally.
|
||||||
|
|
||||||
All emissions are broadcast (no equivocation). For equivocation, use
|
|
||||||
`MockByzantineAgent`.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, name: str, scripted_claims: list[list[Claim]],
|
def __init__(self, name: str, scripted_claims: list[list[Claim]],
|
||||||
|
|
@ -206,7 +254,7 @@ class MockAgent(CrisisAgent):
|
||||||
self._script = scripted_claims
|
self._script = scripted_claims
|
||||||
self._invocations = 0
|
self._invocations = 0
|
||||||
|
|
||||||
def next_turn(self, turn: int, received_claims: list[Claim]) -> list[AgentTurn]:
|
def try_emit(self) -> list[AgentTurn]:
|
||||||
idx = self._invocations
|
idx = self._invocations
|
||||||
self._invocations += 1
|
self._invocations += 1
|
||||||
if idx >= len(self._script):
|
if idx >= len(self._script):
|
||||||
|
|
@ -217,19 +265,16 @@ class MockAgent(CrisisAgent):
|
||||||
class MockByzantineAgent(CrisisAgent):
|
class MockByzantineAgent(CrisisAgent):
|
||||||
"""An agent designed to equivocate.
|
"""An agent designed to equivocate.
|
||||||
|
|
||||||
Lifecycle:
|
On its first `try_emit()` invocation it broadcasts an `intro_claim` so
|
||||||
- Invocation 0: emit a broadcast `intro_claim` (a benign "I've joined"
|
every honest agent has a same-id vertex to chain the equivocation off.
|
||||||
message). This is **necessary** for the equivocation step: both
|
On subsequent invocations it emits pairs of contradictory claims from
|
||||||
variants of the equivocating claim will chain to this intro, so they
|
`scripted_pairs`, with the first variant targeted at `split_a` and the
|
||||||
can propagate through the gossip layer (otherwise the chain constraint
|
second at `split_b`.
|
||||||
in `Message.message_integrity` step 6 would reject the second variant
|
|
||||||
in any graph that already holds the first).
|
|
||||||
- Invocations 1..N: emit pairs of contradictory claims, with the first
|
|
||||||
variant targeted at `split_a` and the second at `split_b`. Both
|
|
||||||
variants in a pair carry the same `statement_id` but contradict on
|
|
||||||
`verdict`.
|
|
||||||
|
|
||||||
Set `scripted_pairs` empty to test "byzantine joined but didn't equivocate".
|
Byzantines never emit AlarmClaims about other agents — there's a
|
||||||
|
subclass override of `pending_alarm_claims` that returns empty. (A
|
||||||
|
more advanced byzantine could emit FALSE AlarmClaims to test the
|
||||||
|
quorum-vote isolation; not in scope for this PoC.)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, name: str, intro_claim: Claim,
|
def __init__(self, name: str, intro_claim: Claim,
|
||||||
|
|
@ -245,12 +290,10 @@ class MockByzantineAgent(CrisisAgent):
|
||||||
self._split_b = split_b
|
self._split_b = split_b
|
||||||
self._invocations = 0
|
self._invocations = 0
|
||||||
|
|
||||||
def next_turn(self, turn: int, received_claims: list[Claim]) -> list[AgentTurn]:
|
def try_emit(self) -> list[AgentTurn]:
|
||||||
idx = self._invocations
|
idx = self._invocations
|
||||||
self._invocations += 1
|
self._invocations += 1
|
||||||
if idx == 0:
|
if idx == 0:
|
||||||
# The introduction turn: a single broadcast so all peers learn my
|
|
||||||
# identity and have a same-id vertex to chain my equivocations to.
|
|
||||||
return [AgentTurn(claim=self._intro, target_subset=None)]
|
return [AgentTurn(claim=self._intro, target_subset=None)]
|
||||||
pair_idx = idx - 1
|
pair_idx = idx - 1
|
||||||
if pair_idx >= len(self._script):
|
if pair_idx >= len(self._script):
|
||||||
|
|
@ -260,3 +303,7 @@ class MockByzantineAgent(CrisisAgent):
|
||||||
if claim_b is not None:
|
if claim_b is not None:
|
||||||
out.append(AgentTurn(claim=claim_b, target_subset=set(self._split_b)))
|
out.append(AgentTurn(claim=claim_b, target_subset=set(self._split_b)))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
def pending_alarm_claims(self) -> list:
|
||||||
|
"""Byzantines don't emit alarms in our threat model."""
|
||||||
|
return []
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,15 @@
|
||||||
crisis-agents — command-line entry point.
|
crisis-agents — command-line entry point.
|
||||||
|
|
||||||
Subcommands:
|
Subcommands:
|
||||||
demo Run a scripted scenario end-to-end. Walks the four phases:
|
demo Run a scripted scenario end-to-end. The Crisis phase runs an
|
||||||
closed team → boundary opens → Crisis-active rounds + gossip →
|
asynchronous event loop to quiescence — no global clock, no
|
||||||
decentralized detection + alarm voting → proof emission.
|
fixed turn count.
|
||||||
verify Re-check a proof JSON for self-consistency.
|
verify Re-check a proof JSON for self-consistency.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
crisis-agents demo --scenario fact_check
|
crisis-agents demo --scenario fact_check
|
||||||
crisis-agents demo --scenario fact_check --live
|
crisis-agents demo --scenario fact_check --live
|
||||||
crisis-agents verify proof_agent_delta_s03.json
|
crisis-agents verify proof_<accused>_s03.json
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -53,12 +53,13 @@ def _run_demo(args: argparse.Namespace) -> int:
|
||||||
mothership.add_agent(agent)
|
mothership.add_agent(agent)
|
||||||
|
|
||||||
# ---- Phase 1: closed team, no Crisis ----
|
# ---- Phase 1: closed team, no Crisis ----
|
||||||
print(f"--- Phase 1: closed team, no Crisis ({scenario.closed_phase_turns} turn(s)) ---")
|
print("--- Phase 1: closed team, no Crisis ---")
|
||||||
mothership.run_closed_phase(num_turns=scenario.closed_phase_turns)
|
closed_report = mothership.run_closed_phase()
|
||||||
honest_names = [a.name for a in mothership.agents.values()]
|
honest_names = [a.name for a in mothership.agents.values()]
|
||||||
print(
|
print(
|
||||||
f" {len(mothership.run_result.closed_log)} claims from "
|
f" driven to quiescence in {closed_report.steps} step(s); "
|
||||||
f"{len(honest_names)} honest agent(s): {', '.join(honest_names)}"
|
f"{closed_report.emissions} claims from "
|
||||||
|
f"{len(honest_names)} honest agent(s)."
|
||||||
)
|
)
|
||||||
print(f" Per-agent graphs: not yet allocated (Crisis is dormant).\n")
|
print(f" Per-agent graphs: not yet allocated (Crisis is dormant).\n")
|
||||||
|
|
||||||
|
|
@ -66,47 +67,42 @@ def _run_demo(args: argparse.Namespace) -> int:
|
||||||
print(f"--- Phase 2: boundary opens — {scenario.byzantine_joiner.name} joins ---")
|
print(f"--- Phase 2: boundary opens — {scenario.byzantine_joiner.name} joins ---")
|
||||||
mothership.open_boundary(scenario.byzantine_joiner)
|
mothership.open_boundary(scenario.byzantine_joiner)
|
||||||
print(f" Trust set is now {mothership.boundary.size()} agents.")
|
print(f" Trust set is now {mothership.boundary.size()} agents.")
|
||||||
print(f" Crisis is now ACTIVE for every subsequent emission.\n")
|
print(f" Crisis is now ACTIVE — agents emit asynchronously.\n")
|
||||||
|
|
||||||
# ---- Phase 3: Crisis-active rounds (emission + gossip) ----
|
# ---- Phase 3: async event loop to quiescence ----
|
||||||
print(f"--- Phase 3: emission + gossip "
|
print("--- Phase 3: asynchronous event loop (no clock) ---")
|
||||||
f"({scenario.crisis_phase_turns} turn(s)) ---")
|
report = mothership.run_until_quiescent()
|
||||||
mothership.run_crisis_phase(
|
print(
|
||||||
num_turns=scenario.crisis_phase_turns,
|
f" drove to quiescence in {report.steps} step(s):\n"
|
||||||
gossip_rounds_per_turn=1,
|
f" {report.emissions:3d} regular emissions\n"
|
||||||
|
f" {report.gossip_transfers:3d} gossip transfers\n"
|
||||||
|
f" {report.alarm_claims_emitted:3d} alarm claims emitted"
|
||||||
)
|
)
|
||||||
crisis_log = mothership.run_result.crisis_log
|
print(f" After convergence:")
|
||||||
print(f" {len(crisis_log)} Crisis messages emitted.")
|
|
||||||
print(f" After gossip:")
|
|
||||||
for name, agent in mothership.agents.items():
|
for name, agent in mothership.agents.items():
|
||||||
print(f" {name:14s} graph: {agent.graph.vertex_count():2d} vertices")
|
print(f" {name:14s} graph: {agent.graph.vertex_count():2d} vertices")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# ---- Phase 4: each agent independently detects ----
|
# ---- Phase 4: each agent's own detection result ----
|
||||||
print("--- Phase 4: decentralized detection (each agent's own brain) ---")
|
print("--- Phase 4: decentralized detection (each agent's own brain) ---")
|
||||||
local_alarms = {}
|
detected_by = []
|
||||||
for name, agent in mothership.agents.items():
|
for name, agent in mothership.agents.items():
|
||||||
alarms = agent.detect_mutations()
|
alarms = agent.detect_mutations()
|
||||||
local_alarms[name] = alarms
|
|
||||||
marker = "ALARM" if alarms else "ok "
|
marker = "ALARM" if alarms else "ok "
|
||||||
suffix = ""
|
suffix = ""
|
||||||
if alarms:
|
if alarms:
|
||||||
|
detected_by.append(name)
|
||||||
suffix = (f" — accuses {alarms[0].accused_process_id_hex[:16]}... "
|
suffix = (f" — accuses {alarms[0].accused_process_id_hex[:16]}... "
|
||||||
f"on {alarms[0].statement_id}")
|
f"on {alarms[0].statement_id}")
|
||||||
print(f" [{marker}] {name:14s}{suffix}")
|
print(f" [{marker}] {name:14s}{suffix}")
|
||||||
detector_count = sum(1 for a in local_alarms.values() if a)
|
print(f" {len(detected_by)} of {len(mothership.agents)} agents detected "
|
||||||
print(f" {detector_count} of {len(mothership.agents)} agents independently "
|
f"byzantine behavior independently.\n")
|
||||||
f"detected byzantine behavior.\n")
|
|
||||||
|
|
||||||
# ---- Phase 5: alarm emission + quorum voting ----
|
# ---- Phase 5: quorum tally ----
|
||||||
print("--- Phase 5: alarms emitted + gossiped + ratified by quorum ---")
|
print("--- Phase 5: ratification by quorum ---")
|
||||||
mothership.emit_alarms_from_detectors()
|
|
||||||
mothership.run_gossip_round()
|
|
||||||
threshold = quorum_for(mothership.boundary.size())
|
threshold = quorum_for(mothership.boundary.size())
|
||||||
print(f" Quorum threshold = ⌈2*{mothership.boundary.size()}/3⌉ = {threshold}")
|
print(f" Quorum threshold = ⌈2*{mothership.boundary.size()}/3⌉ = {threshold}")
|
||||||
|
|
||||||
# All honest agents should agree on the ratified set — show by querying
|
|
||||||
# each of them and confirming.
|
|
||||||
ratified_per_agent = {
|
ratified_per_agent = {
|
||||||
name: mothership.ratified_alarms_from(name)
|
name: mothership.ratified_alarms_from(name)
|
||||||
for name in mothership.agents
|
for name in mothership.agents
|
||||||
|
|
@ -118,10 +114,7 @@ def _run_demo(args: argparse.Namespace) -> int:
|
||||||
canonical = ratified_per_agent[name]
|
canonical = ratified_per_agent[name]
|
||||||
elif ratified_per_agent[name] != canonical:
|
elif ratified_per_agent[name] != canonical:
|
||||||
all_agree = False
|
all_agree = False
|
||||||
if all_agree:
|
marker = "✓" if all_agree else "✗"
|
||||||
marker = "✓"
|
|
||||||
else:
|
|
||||||
marker = "✗"
|
|
||||||
print(f" {marker} every honest agent's ratified set is identical "
|
print(f" {marker} every honest agent's ratified set is identical "
|
||||||
f"({'no chokepoint' if all_agree else 'DIVERGENCE'}).")
|
f"({'no chokepoint' if all_agree else 'DIVERGENCE'}).")
|
||||||
|
|
||||||
|
|
@ -141,7 +134,6 @@ def _run_demo(args: argparse.Namespace) -> int:
|
||||||
out_dir.mkdir(parents=True, exist_ok=True)
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
for r in canonical:
|
for r in canonical:
|
||||||
proof = build_proof(r)
|
proof = build_proof(r)
|
||||||
# Use a stable filename based on accused + statement
|
|
||||||
accused_short = r.accused_process_id_hex[:16]
|
accused_short = r.accused_process_id_hex[:16]
|
||||||
path = out_dir / f"proof_{accused_short}_{r.statement_id}.json"
|
path = out_dir / f"proof_{accused_short}_{r.statement_id}.json"
|
||||||
path.write_text(proof.to_json())
|
path.write_text(proof.to_json())
|
||||||
|
|
@ -173,7 +165,7 @@ def _run_verify(args: argparse.Namespace) -> int:
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="crisis-agents",
|
prog="crisis-agents",
|
||||||
description="Crisis-Agents — decentralized coordination for AI agent teams.",
|
description="Crisis-Agents — decentralized async coordination for AI agent teams.",
|
||||||
)
|
)
|
||||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
|
|
@ -184,8 +176,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
help="back the honest agents with real Claude API calls "
|
help="back the honest agents with real Claude API calls "
|
||||||
"(requires anthropic SDK + ANTHROPIC_API_KEY)")
|
"(requires anthropic SDK + ANTHROPIC_API_KEY)")
|
||||||
demo.add_argument("--model", default=None,
|
demo.add_argument("--model", default=None,
|
||||||
help="Anthropic model id for --live (default: "
|
help="Anthropic model id for --live")
|
||||||
"claude-haiku-4-5-20251001)")
|
|
||||||
demo.add_argument("--out-dir", default=".",
|
demo.add_argument("--out-dir", default=".",
|
||||||
help="where to write proof JSON files (default: cwd)")
|
help="where to write proof JSON files (default: cwd)")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,14 @@ class LiveClaudeAgent(CrisisAgent):
|
||||||
self._system_prompt = system_prompt or self._default_system_prompt()
|
self._system_prompt = system_prompt or self._default_system_prompt()
|
||||||
self._invocations = 0
|
self._invocations = 0
|
||||||
self._already_adjudicated: set[str] = set()
|
self._already_adjudicated: set[str] = set()
|
||||||
|
# Conversation context — populated via observe() callbacks during
|
||||||
|
# the closed phase, and supplemented by self.graph introspection
|
||||||
|
# during the Crisis phase.
|
||||||
|
self._observed_history: list[Claim] = []
|
||||||
|
|
||||||
|
def observe(self, claim: Claim) -> None:
|
||||||
|
"""Record a peer's claim into our conversation context."""
|
||||||
|
self._observed_history.append(claim)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _default_system_prompt() -> str:
|
def _default_system_prompt() -> str:
|
||||||
|
|
@ -115,8 +123,13 @@ class LiveClaudeAgent(CrisisAgent):
|
||||||
self._client = anthropic.Anthropic()
|
self._client = anthropic.Anthropic()
|
||||||
return self._client
|
return self._client
|
||||||
|
|
||||||
def next_turn(self, turn: int, received_claims: list[Claim]) -> list[AgentTurn]:
|
def try_emit(self) -> list[AgentTurn]:
|
||||||
"""Issue one API call, parse, return Claims as AgentTurns."""
|
"""Issue one API call, parse, return Claims as AgentTurns.
|
||||||
|
|
||||||
|
Context is built from both `self._observed_history` (closed-phase
|
||||||
|
observations) and any Claim payloads in `self.graph` (Crisis-phase
|
||||||
|
gossiped messages from peers).
|
||||||
|
"""
|
||||||
self._invocations += 1
|
self._invocations += 1
|
||||||
|
|
||||||
# Which statements still need a verdict from me?
|
# Which statements still need a verdict from me?
|
||||||
|
|
@ -125,7 +138,18 @@ class LiveClaudeAgent(CrisisAgent):
|
||||||
if not pending:
|
if not pending:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
user_message = self._render_user_message(pending, received_claims)
|
# Crisis-phase: also peek at the agent's own graph for peer claims.
|
||||||
|
graph_observations: list[Claim] = []
|
||||||
|
for v in self.graph.all_vertices():
|
||||||
|
if v.id == self.process_id:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
graph_observations.append(Claim.from_payload(v.payload))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
context = self._observed_history + graph_observations
|
||||||
|
|
||||||
|
user_message = self._render_user_message(pending, context)
|
||||||
|
|
||||||
client = self._get_client()
|
client = self._get_client()
|
||||||
response = client.messages.create(
|
response = client.messages.create(
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,27 @@
|
||||||
"""
|
"""
|
||||||
Mothership — bootstrap + clock, **not** an observer.
|
Mothership — bootstrap + asynchronous driver.
|
||||||
|
|
||||||
The mothership's only privileged role is starting the network: it knows the
|
Two roles, both unprivileged:
|
||||||
initial member set, it asks each agent to take its turn, and it routes the
|
|
||||||
first hop of each emission to the sender's chosen target subset. After that
|
|
||||||
first hop, gossip rounds propagate messages and each agent reaches its own
|
|
||||||
view of the network.
|
|
||||||
|
|
||||||
What the mothership deliberately does NOT do (which the previous version
|
1. Bootstrap. The mothership knows the initial member set, introduces a
|
||||||
did, and was correctly criticized for):
|
joining agent into the boundary, and offers convenience accessors for
|
||||||
- hold a dict of all agents' LamportGraphs
|
tests. It never reads any agent's internal state in ways that would
|
||||||
- wrap Claims into Crisis Messages on agents' behalf
|
bypass the protocol.
|
||||||
- scan any agent's graph for byzantine behavior
|
|
||||||
|
|
||||||
Those responsibilities belong to the agents.
|
2. Driver. The mothership runs an event-loop-like cycle that asks each
|
||||||
|
agent for any pending emissions, exchanges gossip between any pair,
|
||||||
|
and asks each agent for any pending alarm claims — all interleaved
|
||||||
|
in one loop until quiescent. There is no global clock; the driver
|
||||||
|
is the in-process analog of "agents run their gossip + emission
|
||||||
|
loops concurrently forever" that Section 5.9 of the paper describes.
|
||||||
|
|
||||||
|
What the mothership deliberately does NOT have:
|
||||||
|
- a synchronous turn counter exposed to agents
|
||||||
|
- a privileged graph store
|
||||||
|
- any post-hoc scan over per-agent state
|
||||||
|
|
||||||
|
Termination is by quiescence: when no agent emits, no gossip pair has new
|
||||||
|
information, and no fresh alarms appear, the loop exits.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -30,9 +38,13 @@ from crisis_agents.claim import Claim
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ClosedPhaseEntry:
|
class ClosedPhaseEntry:
|
||||||
"""One row in the closed-phase log: who said what, when."""
|
"""One row in the closed-phase log.
|
||||||
|
|
||||||
|
`step` is a local sequence number used only for log ordering — it is
|
||||||
|
NOT a global tick the agents observe.
|
||||||
|
"""
|
||||||
agent_name: str
|
agent_name: str
|
||||||
turn: int
|
step: int
|
||||||
claim: Claim
|
claim: Claim
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -40,12 +52,11 @@ class ClosedPhaseEntry:
|
||||||
class CrisisPhaseEntry:
|
class CrisisPhaseEntry:
|
||||||
"""Audit trail of an emission event during the Crisis phase.
|
"""Audit trail of an emission event during the Crisis phase.
|
||||||
|
|
||||||
Kept for proof generation and human-readable demos. Detection itself
|
`step` is a local sequence number. Detection itself does not consult
|
||||||
does NOT consult this log — that work happens in each agent's
|
this log — that work happens in each agent's own `detect_mutations()`.
|
||||||
`detect_mutations()` against its own graph.
|
|
||||||
"""
|
"""
|
||||||
agent_name: str
|
agent_name: str
|
||||||
turn: int
|
step: int
|
||||||
claim: Claim
|
claim: Claim
|
||||||
message_digest_hex: str
|
message_digest_hex: str
|
||||||
delivered_to: list[str]
|
delivered_to: list[str]
|
||||||
|
|
@ -57,16 +68,27 @@ class MothershipRunResult:
|
||||||
crisis_log: list[CrisisPhaseEntry] = field(default_factory=list)
|
crisis_log: list[CrisisPhaseEntry] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class QuiescenceReport:
|
||||||
|
"""How a driver loop terminated."""
|
||||||
|
steps: int
|
||||||
|
emissions: int
|
||||||
|
gossip_transfers: int
|
||||||
|
alarm_claims_emitted: int
|
||||||
|
reached_quiescence: bool # True iff loop exited because nothing changed
|
||||||
|
# (False iff max_steps was hit first)
|
||||||
|
|
||||||
|
|
||||||
class Mothership:
|
class Mothership:
|
||||||
"""Coordinator for a team of CrisisAgents.
|
"""Coordinator for a team of CrisisAgents.
|
||||||
|
|
||||||
Lifecycle:
|
Lifecycle:
|
||||||
m = Mothership()
|
m = Mothership()
|
||||||
m.add_agent(...); m.add_agent(...); m.add_agent(...)
|
m.add_agent(...); m.add_agent(...); m.add_agent(...)
|
||||||
m.run_closed_phase(num_turns=1)
|
m.run_closed_phase() # async until quiescent (no clock)
|
||||||
m.open_boundary(joining_agent)
|
m.open_boundary(joiner)
|
||||||
m.run_crisis_phase(num_turns=2, gossip_rounds_per_turn=1)
|
m.run_until_quiescent() # async until quiescent (no clock)
|
||||||
# detection is decentralized — each agent's .detect_mutations()
|
# detection is decentralized — m.ratified_alarms_from("agent_alpha")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, *, pow_zeros: int = 0):
|
def __init__(self, *, pow_zeros: int = 0):
|
||||||
|
|
@ -74,24 +96,16 @@ class Mothership:
|
||||||
self.boundary = Boundary()
|
self.boundary = Boundary()
|
||||||
self.run_result = MothershipRunResult()
|
self.run_result = MothershipRunResult()
|
||||||
|
|
||||||
# Shared weight system across the network — every agent's PoW must
|
# Shared weight system so every agent's PoW is verifiable by every
|
||||||
# be verifiable by every other agent's graph, so the threshold has
|
# other agent's graph.
|
||||||
# to match. Assigned to each agent's graph at registration time.
|
|
||||||
self._weight_system: WeightSystem = ProofOfWorkWeight(min_leading_zeros=pow_zeros)
|
self._weight_system: WeightSystem = ProofOfWorkWeight(min_leading_zeros=pow_zeros)
|
||||||
|
|
||||||
self._closed_turn_index = 0
|
|
||||||
self._crisis_turn_index = 0
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Setup
|
# Setup
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def add_agent(self, agent: CrisisAgent) -> None:
|
def add_agent(self, agent: CrisisAgent) -> None:
|
||||||
"""Register a trusted agent for the closed-phase team.
|
"""Register a trusted agent for the closed-phase team."""
|
||||||
|
|
||||||
Replaces the agent's weight system with the mothership's shared one
|
|
||||||
so PoW thresholds match across the network.
|
|
||||||
"""
|
|
||||||
if self.boundary.is_open:
|
if self.boundary.is_open:
|
||||||
raise RuntimeError("cannot add_agent after boundary opened; use open_boundary")
|
raise RuntimeError("cannot add_agent after boundary opened; use open_boundary")
|
||||||
if agent.name in self.agents:
|
if agent.name in self.agents:
|
||||||
|
|
@ -101,33 +115,6 @@ class Mothership:
|
||||||
self.agents[agent.name] = agent
|
self.agents[agent.name] = agent
|
||||||
self.boundary.add_trusted(agent.process_id)
|
self.boundary.add_trusted(agent.process_id)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Phase 1: closed
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def run_closed_phase(self, num_turns: int) -> MothershipRunResult:
|
|
||||||
"""Drive `num_turns` of plain agent communication. No Crisis."""
|
|
||||||
if self.boundary.is_open:
|
|
||||||
raise RuntimeError("boundary already open; closed phase is over")
|
|
||||||
|
|
||||||
observed: list[Claim] = [e.claim for e in self.run_result.closed_log]
|
|
||||||
for _ in range(num_turns):
|
|
||||||
turn = self._closed_turn_index
|
|
||||||
new_this_turn: list[Claim] = []
|
|
||||||
for agent in self.agents.values():
|
|
||||||
for at in agent.next_turn(turn, observed):
|
|
||||||
self.run_result.closed_log.append(
|
|
||||||
ClosedPhaseEntry(agent_name=agent.name, turn=turn, claim=at.claim)
|
|
||||||
)
|
|
||||||
new_this_turn.append(at.claim)
|
|
||||||
observed.extend(new_this_turn)
|
|
||||||
self._closed_turn_index += 1
|
|
||||||
return self.run_result
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Phase 2: boundary opens
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def open_boundary(self, new_agent: CrisisAgent) -> None:
|
def open_boundary(self, new_agent: CrisisAgent) -> None:
|
||||||
"""A new agent of unknown trust joins. Crisis activates."""
|
"""A new agent of unknown trust joins. Crisis activates."""
|
||||||
if new_agent.name in self.agents:
|
if new_agent.name in self.agents:
|
||||||
|
|
@ -138,100 +125,148 @@ class Mothership:
|
||||||
self.boundary.open(new_agent.process_id)
|
self.boundary.open(new_agent.process_id)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Crisis-phase mechanics: emission → gossip
|
# Closed phase — quiescence-driven, not turn-counted
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _crisis_received_view(self, agent: CrisisAgent) -> list[Claim]:
|
def run_closed_phase(self, max_steps: int = 50) -> QuiescenceReport:
|
||||||
"""Decode every non-self vertex in `agent`'s graph back to Claim form.
|
"""Drive the closed-phase conversation until quiescent.
|
||||||
|
|
||||||
Used to populate the `received_claims` argument of `next_turn()` so
|
Each iteration, each agent's `try_emit()` is called. Emitted claims
|
||||||
each agent sees what it has actually observed (not what the mothership
|
are appended to the closed log and broadcast (via `observe`) to
|
||||||
observed — they may differ if gossip has been partial).
|
every other agent for context. The loop exits when no agent emits.
|
||||||
"""
|
"""
|
||||||
out: list[Claim] = []
|
if self.boundary.is_open:
|
||||||
for v in agent.graph.all_vertices():
|
raise RuntimeError("boundary already open; closed phase is over")
|
||||||
if v.id == agent.process_id:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
out.append(Claim.from_payload(v.payload))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
continue # non-Claim payloads (e.g. AlarmClaim — phase 23)
|
|
||||||
return out
|
|
||||||
|
|
||||||
def run_crisis_phase(self, num_turns: int,
|
step = 0
|
||||||
*, gossip_rounds_per_turn: int = 1) -> MothershipRunResult:
|
emissions = 0
|
||||||
"""Drive `num_turns` of Crisis-active activity.
|
progress = True
|
||||||
|
while progress and step < max_steps:
|
||||||
|
progress = False
|
||||||
|
for agent in self.agents.values():
|
||||||
|
for at in agent.try_emit():
|
||||||
|
self.run_result.closed_log.append(
|
||||||
|
ClosedPhaseEntry(agent_name=agent.name, step=step, claim=at.claim)
|
||||||
|
)
|
||||||
|
emissions += 1
|
||||||
|
progress = True
|
||||||
|
# Share to every other agent's observation buffer
|
||||||
|
for peer_name, peer in self.agents.items():
|
||||||
|
if peer_name == agent.name:
|
||||||
|
continue
|
||||||
|
peer.observe(at.claim)
|
||||||
|
step += 1
|
||||||
|
|
||||||
Each turn:
|
return QuiescenceReport(
|
||||||
1. Every agent's `next_turn()` runs; emissions are routed first-hop
|
steps=step,
|
||||||
to their declared target_subset (or broadcast to everyone).
|
emissions=emissions,
|
||||||
2. `gossip_rounds_per_turn` rounds of all-pairs gossip propagate
|
gossip_transfers=0,
|
||||||
messages across the network.
|
alarm_claims_emitted=0,
|
||||||
|
reached_quiescence=(step < max_steps or not progress),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Crisis phase — fully asynchronous event loop
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def run_until_quiescent(self, max_steps: int = 200) -> QuiescenceReport:
|
||||||
|
"""Drive the Crisis-active network until nothing changes.
|
||||||
|
|
||||||
|
Each iteration of the loop interleaves three concerns:
|
||||||
|
|
||||||
|
1. **Emission.** For every agent, call `try_emit()`. Route any
|
||||||
|
returned emissions to their target subset.
|
||||||
|
2. **Gossip.** Run one all-pairs gossip round. Each receiver
|
||||||
|
accepts vertices that pass integrity checks.
|
||||||
|
3. **Alarm emission.** For every agent, call `pending_alarm_claims()`.
|
||||||
|
Wrap each into a Crisis Message and broadcast.
|
||||||
|
|
||||||
|
These are not phases — they're things the driver tries on each
|
||||||
|
step. The loop exits when none of them makes progress.
|
||||||
"""
|
"""
|
||||||
if not self.boundary.is_open:
|
if not self.boundary.is_open:
|
||||||
raise RuntimeError("boundary not yet open; call open_boundary() first")
|
raise RuntimeError("boundary not yet open; call open_boundary() first")
|
||||||
|
|
||||||
|
step = 0
|
||||||
|
emissions = 0
|
||||||
|
gossip_transfers = 0
|
||||||
|
alarms_emitted = 0
|
||||||
all_names = list(self.agents.keys())
|
all_names = list(self.agents.keys())
|
||||||
|
|
||||||
for _ in range(num_turns):
|
while step < max_steps:
|
||||||
turn = self._crisis_turn_index
|
progress = False
|
||||||
|
|
||||||
# (1) Emission phase — ask each agent what they want to say.
|
# 1. Emissions
|
||||||
# The agent builds the Crisis Message from its own graph.
|
|
||||||
# The mothership only handles the first-hop routing.
|
|
||||||
for agent in self.agents.values():
|
for agent in self.agents.values():
|
||||||
received = self._crisis_received_view(agent)
|
for at in agent.try_emit():
|
||||||
for at in agent.next_turn(turn, received):
|
self._route_emission(agent, step, at, all_names)
|
||||||
self._route_emission(agent, turn, at, all_names)
|
emissions += 1
|
||||||
|
progress = True
|
||||||
|
|
||||||
# (2) Gossip — each pair exchanges what they have until quiescent.
|
# 2. Gossip
|
||||||
for _ in range(gossip_rounds_per_turn):
|
transfers = self.run_gossip_round()
|
||||||
self.run_gossip_round()
|
if transfers:
|
||||||
|
gossip_transfers += sum(transfers.values())
|
||||||
|
progress = True
|
||||||
|
|
||||||
self._crisis_turn_index += 1
|
# 3. Alarm emissions
|
||||||
|
for agent in self.agents.values():
|
||||||
|
for ac in agent.pending_alarm_claims():
|
||||||
|
self._broadcast_alarm(agent, ac)
|
||||||
|
alarms_emitted += 1
|
||||||
|
progress = True
|
||||||
|
|
||||||
return self.run_result
|
step += 1
|
||||||
|
if not progress:
|
||||||
|
break
|
||||||
|
|
||||||
def _route_emission(self, sender: CrisisAgent, turn: int, at: AgentTurn,
|
return QuiescenceReport(
|
||||||
|
steps=step,
|
||||||
|
emissions=emissions,
|
||||||
|
gossip_transfers=gossip_transfers,
|
||||||
|
alarm_claims_emitted=alarms_emitted,
|
||||||
|
reached_quiescence=(step < max_steps),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _route_emission(self, sender: CrisisAgent, step: int, at: AgentTurn,
|
||||||
all_names: list[str]) -> None:
|
all_names: list[str]) -> None:
|
||||||
"""First-hop delivery + audit log entry.
|
"""First-hop delivery + audit log entry.
|
||||||
|
|
||||||
Delivery rule (same as before — kept for byzantine equivocation):
|
- target_subset=None ⇒ broadcast (every agent including sender)
|
||||||
- target_subset is None ⇒ broadcast (every agent including sender)
|
- target_subset=set ⇒ targeted; sender's own graph NOT auto-included
|
||||||
- target_subset is set ⇒ targeted; sender's own graph NOT auto-included
|
|
||||||
"""
|
"""
|
||||||
if at.target_subset is None:
|
if at.target_subset is None:
|
||||||
targets = list(all_names)
|
targets = list(all_names)
|
||||||
else:
|
else:
|
||||||
targets = [t for t in at.target_subset if t in self.agents]
|
targets = [t for t in at.target_subset if t in self.agents]
|
||||||
|
|
||||||
# The agent wraps the Claim using its own graph as the source of truth.
|
|
||||||
message = sender.emit_claim(at.claim)
|
message = sender.emit_claim(at.claim)
|
||||||
|
|
||||||
for tname in targets:
|
for tname in targets:
|
||||||
self.agents[tname].receive(message)
|
self.agents[tname].receive(message)
|
||||||
|
|
||||||
self.run_result.crisis_log.append(
|
self.run_result.crisis_log.append(
|
||||||
CrisisPhaseEntry(
|
CrisisPhaseEntry(
|
||||||
agent_name=sender.name,
|
agent_name=sender.name,
|
||||||
turn=turn,
|
step=step,
|
||||||
claim=at.claim,
|
claim=at.claim,
|
||||||
message_digest_hex=message.compute_digest().hex(),
|
message_digest_hex=message.compute_digest().hex(),
|
||||||
delivered_to=targets,
|
delivered_to=targets,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _broadcast_alarm(self, sender: CrisisAgent, alarm_claim) -> None:
|
||||||
|
"""Wrap an AlarmClaim into a Crisis Message and deliver to every
|
||||||
|
agent (including sender, so its own tally is consistent)."""
|
||||||
|
payload = alarm_claim.to_payload()
|
||||||
|
message = sender._build_message(payload)
|
||||||
|
for target in self.agents.values():
|
||||||
|
target.receive(message)
|
||||||
|
|
||||||
def run_gossip_round(self) -> dict[tuple[str, str], int]:
|
def run_gossip_round(self) -> dict[tuple[str, str], int]:
|
||||||
"""One all-pairs gossip round.
|
"""One all-pairs gossip round.
|
||||||
|
|
||||||
For every ordered pair (sender, receiver), the sender shares everything
|
For every ordered pair (sender, receiver), the sender shares
|
||||||
in its graph that the receiver doesn't yet have. Returns a dict mapping
|
everything in its graph that the receiver doesn't yet have.
|
||||||
(sender_name, receiver_name) -> number of newly-accepted vertices.
|
|
||||||
|
|
||||||
Order matters mildly: if A -> B propagates new info to B that B then
|
|
||||||
re-emits to C, that's covered in this same round only if A appears
|
|
||||||
before B in the iteration. We loop until no progress to avoid edge
|
|
||||||
cases. In practice one ordered pass is usually enough.
|
|
||||||
"""
|
"""
|
||||||
names = list(self.agents.keys())
|
names = list(self.agents.keys())
|
||||||
transfers: dict[tuple[str, str], int] = {}
|
transfers: dict[tuple[str, str], int] = {}
|
||||||
|
|
@ -245,110 +280,14 @@ class Mothership:
|
||||||
return transfers
|
return transfers
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Decentralized alarm flow — orchestration only; the work is per-agent
|
# Decentralized alarm tally — convenience method
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def emit_alarms_from_detectors(self,
|
|
||||||
*, accuse_self_ok: bool = False
|
|
||||||
) -> dict[str, list]:
|
|
||||||
"""Every agent independently runs `detect_mutations()` on its own
|
|
||||||
graph; any LocalAlarms it produces become AlarmClaims that the agent
|
|
||||||
emits into the gossip layer (broadcast).
|
|
||||||
|
|
||||||
Returns a dict mapping agent_name -> list[LocalAlarm] (what each
|
|
||||||
agent independently found). Callers can use this for diagnostics
|
|
||||||
without ever having read into an agent's graph directly.
|
|
||||||
|
|
||||||
The byzantine joiner will of course not emit alarms about itself.
|
|
||||||
If `accuse_self_ok` is False (the default), we additionally skip
|
|
||||||
any LocalAlarm whose `detector_process_id_hex` matches the
|
|
||||||
`accused_process_id_hex` — sanity guard against malformed cases.
|
|
||||||
"""
|
|
||||||
from crisis_agents.vote import AlarmClaim
|
|
||||||
|
|
||||||
all_local: dict[str, list] = {}
|
|
||||||
for agent in self.agents.values():
|
|
||||||
locals_for_agent = agent.detect_mutations()
|
|
||||||
if not accuse_self_ok:
|
|
||||||
locals_for_agent = [
|
|
||||||
a for a in locals_for_agent
|
|
||||||
if a.detector_process_id_hex != a.accused_process_id_hex
|
|
||||||
]
|
|
||||||
all_local[agent.name] = locals_for_agent
|
|
||||||
|
|
||||||
for local in locals_for_agent:
|
|
||||||
alarm_claim = AlarmClaim.from_local_alarm(
|
|
||||||
local, detected_at_turn=self._crisis_turn_index,
|
|
||||||
)
|
|
||||||
# Wrap the AlarmClaim's payload into a Crisis Message and
|
|
||||||
# broadcast it. We bypass the Claim/AgentTurn machinery
|
|
||||||
# because AlarmClaim is a different payload schema.
|
|
||||||
self._broadcast_alarm(agent, alarm_claim)
|
|
||||||
|
|
||||||
return all_local
|
|
||||||
|
|
||||||
def _broadcast_alarm(self, sender: CrisisAgent, alarm_claim) -> None:
|
|
||||||
"""Wrap an AlarmClaim into a Crisis Message via the sender's
|
|
||||||
`emit_claim` machinery (re-using the digest-build + PoW path)
|
|
||||||
but with the AlarmClaim payload, and broadcast it to all peers
|
|
||||||
(including the sender so its own ratified set is consistent)."""
|
|
||||||
# We can't pass a non-Claim into emit_claim directly because emit_claim
|
|
||||||
# types its argument as Claim. Hack-around: build the Message manually
|
|
||||||
# using the same chain/cross-ref logic as emit_claim. Cleaner
|
|
||||||
# alternative is to refactor emit_claim to accept any payload-bytes
|
|
||||||
# producer; for now this duplication is minor.
|
|
||||||
import os
|
|
||||||
from crisis.message import Message, NONCE_LENGTH
|
|
||||||
from crisis.weight import ProofOfWorkWeight
|
|
||||||
|
|
||||||
payload = alarm_claim.to_payload()
|
|
||||||
|
|
||||||
digests_list: list[bytes] = []
|
|
||||||
same_id = [v for v in sender.graph.all_vertices() if v.id == sender.process_id]
|
|
||||||
past_digests: set[bytes] = set()
|
|
||||||
if same_id:
|
|
||||||
referenced = set()
|
|
||||||
for v in same_id:
|
|
||||||
for d in v.digests:
|
|
||||||
ref = sender.graph.get_vertex(d)
|
|
||||||
if ref is not None and ref.id == sender.process_id:
|
|
||||||
referenced.add(d)
|
|
||||||
last = next(
|
|
||||||
(v for v in same_id if v.message_digest not in referenced),
|
|
||||||
same_id[-1],
|
|
||||||
)
|
|
||||||
digests_list.append(last.message_digest)
|
|
||||||
past_digests = {v.message_digest for v in sender.graph.past(last)}
|
|
||||||
|
|
||||||
seen_other_ids: set[bytes] = {sender.process_id}
|
|
||||||
for v in sender.graph.all_vertices():
|
|
||||||
if v.id in seen_other_ids:
|
|
||||||
continue
|
|
||||||
if v.message_digest in past_digests:
|
|
||||||
continue
|
|
||||||
digests_list.append(v.message_digest)
|
|
||||||
seen_other_ids.add(v.id)
|
|
||||||
|
|
||||||
if isinstance(sender.weight_system, ProofOfWorkWeight):
|
|
||||||
message = sender.weight_system.mine_nonce(
|
|
||||||
sender.process_id, tuple(digests_list), payload,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
message = Message(
|
|
||||||
nonce=os.urandom(NONCE_LENGTH),
|
|
||||||
id=sender.process_id,
|
|
||||||
digests=tuple(digests_list),
|
|
||||||
payload=payload,
|
|
||||||
)
|
|
||||||
|
|
||||||
for target in self.agents.values():
|
|
||||||
target.receive(message)
|
|
||||||
|
|
||||||
def ratified_alarms_from(self, agent_name: str):
|
def ratified_alarms_from(self, agent_name: str):
|
||||||
"""Get the ratified alarms as seen from one agent's graph.
|
"""Get the ratified alarms as seen from one agent's graph.
|
||||||
|
|
||||||
With sufficient gossip, every honest agent's graph produces the same
|
With sufficient gossip, every honest agent's graph produces the
|
||||||
ratified set — and the test in test_no_chokepoint.py asserts that.
|
same ratified set — asserted by `test_no_chokepoint.py`.
|
||||||
"""
|
"""
|
||||||
from crisis_agents.vote import quorum_for, tally_alarms
|
from crisis_agents.vote import quorum_for, tally_alarms
|
||||||
threshold = quorum_for(self.boundary.size())
|
threshold = quorum_for(self.boundary.size())
|
||||||
|
|
@ -360,17 +299,13 @@ class Mothership:
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def honest_agents(self) -> list[CrisisAgent]:
|
def honest_agents(self) -> list[CrisisAgent]:
|
||||||
"""The agents trusted at the start (closed-phase team) — i.e. every
|
"""The closed-phase team (every agent except the boundary-opener)."""
|
||||||
agent except the boundary-opener. Use only as a demo aid; in a real
|
|
||||||
network the mothership doesn't reliably know who's honest."""
|
|
||||||
if not self.boundary.is_open:
|
if not self.boundary.is_open:
|
||||||
return list(self.agents.values())
|
return list(self.agents.values())
|
||||||
# The boundary-opener is added last via open_boundary(); peel it off.
|
|
||||||
all_agents = list(self.agents.values())
|
all_agents = list(self.agents.values())
|
||||||
return all_agents[:-1]
|
return all_agents[:-1]
|
||||||
|
|
||||||
def joiner(self) -> Optional[CrisisAgent]:
|
def joiner(self) -> Optional[CrisisAgent]:
|
||||||
"""The boundary-opener, if any."""
|
|
||||||
if not self.boundary.is_open:
|
if not self.boundary.is_open:
|
||||||
return None
|
return None
|
||||||
return list(self.agents.values())[-1]
|
return list(self.agents.values())[-1]
|
||||||
|
|
|
||||||
|
|
@ -150,8 +150,6 @@ def build_byzantine_joiner() -> CrisisAgent:
|
||||||
class Scenario:
|
class Scenario:
|
||||||
name: str
|
name: str
|
||||||
description: str
|
description: str
|
||||||
closed_phase_turns: int
|
|
||||||
crisis_phase_turns: int
|
|
||||||
honest_agents: list[CrisisAgent]
|
honest_agents: list[CrisisAgent]
|
||||||
byzantine_joiner: CrisisAgent
|
byzantine_joiner: CrisisAgent
|
||||||
reference_doc: str
|
reference_doc: str
|
||||||
|
|
@ -178,12 +176,11 @@ def build_fact_check_scenario(*, live: bool = False,
|
||||||
"Three honest agents adjudicate six factual statements against "
|
"Three honest agents adjudicate six factual statements against "
|
||||||
"a small reference doc. A fourth agent joins the team after the "
|
"a small reference doc. A fourth agent joins the team after the "
|
||||||
"boundary opens and equivocates on statement s03. Crisis is "
|
"boundary opens and equivocates on statement s03. Crisis is "
|
||||||
"decentralized: every honest agent independently detects, emits "
|
"decentralized AND asynchronous: every honest agent "
|
||||||
"an AlarmClaim, and ratifies the alarm by quorum vote." + suffix
|
"independently detects, emits an AlarmClaim, and ratifies the "
|
||||||
|
"alarm by quorum vote. The mothership drives an event loop to "
|
||||||
|
"quiescence — no global clock." + suffix
|
||||||
),
|
),
|
||||||
closed_phase_turns=1,
|
|
||||||
# 2 Crisis turns: intro (turn 0) + equivocation (turn 1)
|
|
||||||
crisis_phase_turns=2,
|
|
||||||
honest_agents=honest,
|
honest_agents=honest,
|
||||||
byzantine_joiner=build_byzantine_joiner(),
|
byzantine_joiner=build_byzantine_joiner(),
|
||||||
reference_doc=load_reference_doc(),
|
reference_doc=load_reference_doc(),
|
||||||
|
|
|
||||||
|
|
@ -45,11 +45,14 @@ class AlarmClaim:
|
||||||
Serializes to JSON for the Crisis Message payload. Recognizable by
|
Serializes to JSON for the Crisis Message payload. Recognizable by
|
||||||
`kind == "alarm"`, distinguishing it from a regular `Claim` payload
|
`kind == "alarm"`, distinguishing it from a regular `Claim` payload
|
||||||
(which has `kind` absent or != "alarm" by convention).
|
(which has `kind` absent or != "alarm" by convention).
|
||||||
|
|
||||||
|
`emitted_at_step` is the agent's local sequence number for ordering;
|
||||||
|
it is NOT a global clock tick — Crisis is asynchronous.
|
||||||
"""
|
"""
|
||||||
accused_process_id_hex: str
|
accused_process_id_hex: str
|
||||||
statement_id: str
|
statement_id: str
|
||||||
witness_digests: tuple[str, str]
|
witness_digests: tuple[str, str]
|
||||||
detected_at_turn: int
|
emitted_at_step: int
|
||||||
kind: str = ALARM_KIND
|
kind: str = ALARM_KIND
|
||||||
|
|
||||||
def to_payload(self) -> bytes:
|
def to_payload(self) -> bytes:
|
||||||
|
|
@ -64,16 +67,16 @@ class AlarmClaim:
|
||||||
accused_process_id_hex=obj["accused_process_id_hex"],
|
accused_process_id_hex=obj["accused_process_id_hex"],
|
||||||
statement_id=obj["statement_id"],
|
statement_id=obj["statement_id"],
|
||||||
witness_digests=tuple(obj["witness_digests"]), # type: ignore[arg-type]
|
witness_digests=tuple(obj["witness_digests"]), # type: ignore[arg-type]
|
||||||
detected_at_turn=obj["detected_at_turn"],
|
emitted_at_step=obj["emitted_at_step"],
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_local_alarm(cls, alarm: LocalAlarm, detected_at_turn: int) -> "AlarmClaim":
|
def from_local_alarm(cls, alarm: LocalAlarm, emitted_at_step: int) -> "AlarmClaim":
|
||||||
return cls(
|
return cls(
|
||||||
accused_process_id_hex=alarm.accused_process_id_hex,
|
accused_process_id_hex=alarm.accused_process_id_hex,
|
||||||
statement_id=alarm.statement_id,
|
statement_id=alarm.statement_id,
|
||||||
witness_digests=alarm.witness_digests,
|
witness_digests=alarm.witness_digests,
|
||||||
detected_at_turn=detected_at_turn,
|
emitted_at_step=emitted_at_step,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ def _post_gossip_team() -> Mothership:
|
||||||
split_b={"b"},
|
split_b={"b"},
|
||||||
)
|
)
|
||||||
m.open_boundary(byz)
|
m.open_boundary(byz)
|
||||||
m.run_crisis_phase(num_turns=2, gossip_rounds_per_turn=1)
|
m.run_until_quiescent()
|
||||||
return m
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -49,7 +49,7 @@ class TestDecentralizedDetection:
|
||||||
m.add_agent(MockAgent("b", [[]]))
|
m.add_agent(MockAgent("b", [[]]))
|
||||||
joiner = MockByzantineAgent("d", _intro(), [], set(), set())
|
joiner = MockByzantineAgent("d", _intro(), [], set(), set())
|
||||||
m.open_boundary(joiner)
|
m.open_boundary(joiner)
|
||||||
m.run_crisis_phase(num_turns=1, gossip_rounds_per_turn=1)
|
m.run_until_quiescent()
|
||||||
|
|
||||||
# Every agent's own detection returns empty
|
# Every agent's own detection returns empty
|
||||||
for agent in m.agents.values():
|
for agent in m.agents.values():
|
||||||
|
|
|
||||||
114
tests/test_async_quiescence.py
Normal file
114
tests/test_async_quiescence.py
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
"""Async-quiescence properties — the new tests that protect the no-clock invariant.
|
||||||
|
|
||||||
|
If you accidentally bake a synchronous tick back into the driver, one of these
|
||||||
|
tests should fail.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from crisis_agents.agent import MockAgent, MockByzantineAgent
|
||||||
|
from crisis_agents.claim import Claim
|
||||||
|
from crisis_agents.mothership import Mothership
|
||||||
|
from crisis_agents.vote import quorum_for
|
||||||
|
|
||||||
|
|
||||||
|
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 _build_fresh_team() -> Mothership:
|
||||||
|
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)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
class TestAsyncQuiescence:
|
||||||
|
|
||||||
|
def test_run_until_quiescent_terminates(self):
|
||||||
|
"""The loop must terminate. If it doesn't, there's a logic bug
|
||||||
|
in the quiescence detection."""
|
||||||
|
m = _build_fresh_team()
|
||||||
|
report = m.run_until_quiescent(max_steps=200)
|
||||||
|
assert report.reached_quiescence
|
||||||
|
assert report.steps < 200
|
||||||
|
|
||||||
|
def test_two_runs_produce_identical_final_state(self):
|
||||||
|
"""Running the same scenario twice must produce the same ratified-set,
|
||||||
|
confirming there's no hidden non-deterministic ordering in the loop.
|
||||||
|
"""
|
||||||
|
m1 = _build_fresh_team()
|
||||||
|
m1.run_until_quiescent()
|
||||||
|
|
||||||
|
m2 = _build_fresh_team()
|
||||||
|
m2.run_until_quiescent()
|
||||||
|
|
||||||
|
for name in ("a", "b", "c"):
|
||||||
|
assert m1.ratified_alarms_from(name) == m2.ratified_alarms_from(name)
|
||||||
|
|
||||||
|
def test_max_steps_bound_caps_runtime(self):
|
||||||
|
"""If we set max_steps to 1, the loop must exit even though
|
||||||
|
quiescence wasn't reached. The QuiescenceReport must accurately
|
||||||
|
say so."""
|
||||||
|
m = _build_fresh_team()
|
||||||
|
report = m.run_until_quiescent(max_steps=1)
|
||||||
|
# With one step we won't have propagated alarms through gossip
|
||||||
|
assert report.steps == 1
|
||||||
|
# reached_quiescence might be False because we capped out
|
||||||
|
# (the byzantine has more emissions pending)
|
||||||
|
# The important property: the loop exited and reported honestly.
|
||||||
|
assert isinstance(report.reached_quiescence, bool)
|
||||||
|
|
||||||
|
def test_no_turn_argument_exposed_to_agents(self):
|
||||||
|
"""Regression guard: CrisisAgent.try_emit() takes no arguments.
|
||||||
|
If anyone re-adds a `turn` parameter, this fails at the type-check
|
||||||
|
level when MockAgent.try_emit is called."""
|
||||||
|
import inspect
|
||||||
|
from crisis_agents.agent import CrisisAgent
|
||||||
|
sig = inspect.signature(CrisisAgent.try_emit)
|
||||||
|
# self plus no other parameters
|
||||||
|
params = list(sig.parameters)
|
||||||
|
assert params == ["self"], f"try_emit grew arguments: {params}"
|
||||||
|
|
||||||
|
def test_no_turn_field_on_alarmclaim(self):
|
||||||
|
"""Regression guard: AlarmClaim no longer has a `detected_at_turn`
|
||||||
|
field. It has `emitted_at_step` — a sequence number, not a clock tick."""
|
||||||
|
from crisis_agents.vote import AlarmClaim
|
||||||
|
fields = AlarmClaim.__dataclass_fields__
|
||||||
|
assert "detected_at_turn" not in fields
|
||||||
|
assert "emitted_at_step" in fields
|
||||||
|
|
||||||
|
def test_alarms_propagate_through_async_loop_alone(self):
|
||||||
|
"""The async loop should detect, emit alarms, and ratify — all without
|
||||||
|
the caller having to invoke separate emit_alarms_from_detectors() or
|
||||||
|
run_gossip_round() steps.
|
||||||
|
"""
|
||||||
|
m = _build_fresh_team()
|
||||||
|
m.run_until_quiescent()
|
||||||
|
threshold = quorum_for(m.boundary.size())
|
||||||
|
for name in ("a", "b", "c"):
|
||||||
|
ratified = m.ratified_alarms_from(name)
|
||||||
|
assert len(ratified) == 1
|
||||||
|
r = ratified[0]
|
||||||
|
assert r.signer_count >= threshold
|
||||||
|
|
||||||
|
def test_quiescence_report_counts_match_logs(self):
|
||||||
|
"""Sanity: the report's emission count must equal the crisis log length."""
|
||||||
|
m = _build_fresh_team()
|
||||||
|
report = m.run_until_quiescent()
|
||||||
|
assert report.emissions == len(m.run_result.crisis_log)
|
||||||
|
|
@ -22,7 +22,6 @@ class TestFactCheckEndToEnd:
|
||||||
assert s.name == "fact_check"
|
assert s.name == "fact_check"
|
||||||
assert len(s.honest_agents) == 3
|
assert len(s.honest_agents) == 3
|
||||||
assert s.byzantine_joiner.name == "agent_delta"
|
assert s.byzantine_joiner.name == "agent_delta"
|
||||||
assert s.crisis_phase_turns == 2 # intro + equivocation
|
|
||||||
assert "Pluto" in s.reference_doc
|
assert "Pluto" in s.reference_doc
|
||||||
|
|
||||||
def test_runs_through_all_phases(self):
|
def test_runs_through_all_phases(self):
|
||||||
|
|
@ -30,14 +29,13 @@ class TestFactCheckEndToEnd:
|
||||||
m = Mothership()
|
m = Mothership()
|
||||||
for a in s.honest_agents:
|
for a in s.honest_agents:
|
||||||
m.add_agent(a)
|
m.add_agent(a)
|
||||||
m.run_closed_phase(num_turns=s.closed_phase_turns)
|
m.run_closed_phase()
|
||||||
m.open_boundary(s.byzantine_joiner)
|
m.open_boundary(s.byzantine_joiner)
|
||||||
m.run_crisis_phase(num_turns=s.crisis_phase_turns,
|
# One async run, no clock — alarms emit and propagate inside the loop
|
||||||
gossip_rounds_per_turn=1)
|
report = m.run_until_quiescent()
|
||||||
m.emit_alarms_from_detectors()
|
assert report.reached_quiescence
|
||||||
m.run_gossip_round()
|
assert report.alarm_claims_emitted >= 3
|
||||||
|
|
||||||
# Every honest agent ratifies the same single alarm.
|
|
||||||
threshold = quorum_for(m.boundary.size())
|
threshold = quorum_for(m.boundary.size())
|
||||||
ratified_sets = [
|
ratified_sets = [
|
||||||
m.ratified_alarms_from(name)
|
m.ratified_alarms_from(name)
|
||||||
|
|
@ -55,12 +53,9 @@ class TestFactCheckEndToEnd:
|
||||||
m = Mothership()
|
m = Mothership()
|
||||||
for a in s.honest_agents:
|
for a in s.honest_agents:
|
||||||
m.add_agent(a)
|
m.add_agent(a)
|
||||||
m.run_closed_phase(num_turns=s.closed_phase_turns)
|
m.run_closed_phase()
|
||||||
m.open_boundary(s.byzantine_joiner)
|
m.open_boundary(s.byzantine_joiner)
|
||||||
m.run_crisis_phase(num_turns=s.crisis_phase_turns,
|
m.run_until_quiescent()
|
||||||
gossip_rounds_per_turn=1)
|
|
||||||
m.emit_alarms_from_detectors()
|
|
||||||
m.run_gossip_round()
|
|
||||||
|
|
||||||
r = m.ratified_alarms_from("agent_alpha")[0]
|
r = m.ratified_alarms_from("agent_alpha")[0]
|
||||||
proof = build_proof(r)
|
proof = build_proof(r)
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ class TestLiveClaudeAgent:
|
||||||
"agent_alpha", reference_doc=_REF,
|
"agent_alpha", reference_doc=_REF,
|
||||||
statements=_STATEMENTS, client=client,
|
statements=_STATEMENTS, client=client,
|
||||||
)
|
)
|
||||||
turns = agent.next_turn(turn=0, received_claims=[])
|
turns = agent.try_emit()
|
||||||
assert len(turns) == 2
|
assert len(turns) == 2
|
||||||
assert {t.claim.statement_id for t in turns} == {"s01", "s02"}
|
assert {t.claim.statement_id for t in turns} == {"s01", "s02"}
|
||||||
verdicts = {t.claim.statement_id: t.claim.verdict for t in turns}
|
verdicts = {t.claim.statement_id: t.claim.verdict for t in turns}
|
||||||
|
|
@ -84,7 +84,7 @@ class TestLiveClaudeAgent:
|
||||||
"agent_alpha", reference_doc=_REF,
|
"agent_alpha", reference_doc=_REF,
|
||||||
statements=_STATEMENTS, client=client,
|
statements=_STATEMENTS, client=client,
|
||||||
)
|
)
|
||||||
turns = agent.next_turn(turn=0, received_claims=[])
|
turns = agent.try_emit()
|
||||||
assert len(turns) == 1
|
assert len(turns) == 1
|
||||||
assert turns[0].claim.statement_id == "s01"
|
assert turns[0].claim.statement_id == "s01"
|
||||||
|
|
||||||
|
|
@ -94,7 +94,7 @@ class TestLiveClaudeAgent:
|
||||||
"agent_alpha", reference_doc=_REF,
|
"agent_alpha", reference_doc=_REF,
|
||||||
statements=_STATEMENTS, client=client,
|
statements=_STATEMENTS, client=client,
|
||||||
)
|
)
|
||||||
turns = agent.next_turn(turn=0, received_claims=[])
|
turns = agent.try_emit()
|
||||||
assert turns == []
|
assert turns == []
|
||||||
|
|
||||||
def test_skips_invalid_claim_objects_in_response(self):
|
def test_skips_invalid_claim_objects_in_response(self):
|
||||||
|
|
@ -108,7 +108,7 @@ class TestLiveClaudeAgent:
|
||||||
"agent_alpha", reference_doc=_REF,
|
"agent_alpha", reference_doc=_REF,
|
||||||
statements=_STATEMENTS, client=client,
|
statements=_STATEMENTS, client=client,
|
||||||
)
|
)
|
||||||
turns = agent.next_turn(turn=0, received_claims=[])
|
turns = agent.try_emit()
|
||||||
# Only the first item passes validation: bogus verdict and non-dict get skipped.
|
# Only the first item passes validation: bogus verdict and non-dict get skipped.
|
||||||
assert len(turns) == 1
|
assert len(turns) == 1
|
||||||
assert turns[0].claim.statement_id == "s01"
|
assert turns[0].claim.statement_id == "s01"
|
||||||
|
|
@ -122,11 +122,11 @@ class TestLiveClaudeAgent:
|
||||||
statements=_STATEMENTS, client=client,
|
statements=_STATEMENTS, client=client,
|
||||||
)
|
)
|
||||||
# First call adjudicates s01
|
# First call adjudicates s01
|
||||||
first = agent.next_turn(turn=0, received_claims=[])
|
first = agent.try_emit()
|
||||||
assert {t.claim.statement_id for t in first} == {"s01"}
|
assert {t.claim.statement_id for t in first} == {"s01"}
|
||||||
|
|
||||||
# Second call should only ask about s02 (s01 is already done)
|
# Second call should only ask about s02 (s01 is already done)
|
||||||
second = agent.next_turn(turn=1, received_claims=[])
|
second = agent.try_emit()
|
||||||
assert {t.claim.statement_id for t in second} == {"s02"}
|
assert {t.claim.statement_id for t in second} == {"s02"}
|
||||||
|
|
||||||
# The prompt sent for the second call should NOT mention s01
|
# The prompt sent for the second call should NOT mention s01
|
||||||
|
|
@ -151,6 +151,6 @@ class TestLiveClaudeAgent:
|
||||||
"agent_alpha", reference_doc=_REF,
|
"agent_alpha", reference_doc=_REF,
|
||||||
statements=_STATEMENTS, client=client,
|
statements=_STATEMENTS, client=client,
|
||||||
)
|
)
|
||||||
turns = agent.next_turn(turn=0, received_claims=[])
|
turns = agent.try_emit()
|
||||||
assert len(turns) == 1
|
assert len(turns) == 1
|
||||||
assert len(turns[0].claim.evidence) == Claim.EVIDENCE_MAX_LEN
|
assert len(turns[0].claim.evidence) == Claim.EVIDENCE_MAX_LEN
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,13 @@ class TestClosedPhase:
|
||||||
m = Mothership()
|
m = Mothership()
|
||||||
m.add_agent(MockAgent("a", [[_claim("s01")]]))
|
m.add_agent(MockAgent("a", [[_claim("s01")]]))
|
||||||
m.add_agent(MockAgent("b", [[_claim("s01")]]))
|
m.add_agent(MockAgent("b", [[_claim("s01")]]))
|
||||||
result = m.run_closed_phase(num_turns=1)
|
report = m.run_closed_phase()
|
||||||
|
|
||||||
# Two agents emitted one claim each via the closed-phase log
|
# Two agents emitted one claim each via the closed-phase log
|
||||||
assert len(result.closed_log) == 2
|
assert len(m.run_result.closed_log) == 2
|
||||||
|
# The async loop reached quiescence within the step budget
|
||||||
|
assert report.reached_quiescence
|
||||||
|
assert report.emissions == 2
|
||||||
|
|
||||||
# No Crisis messages sent yet, so per-agent graphs are still empty
|
# No Crisis messages sent yet, so per-agent graphs are still empty
|
||||||
for agent in m.agents.values():
|
for agent in m.agents.values():
|
||||||
|
|
@ -69,20 +72,26 @@ class TestCrisisPhaseAgentOwnership:
|
||||||
# Joiner with a single broadcast intro, no equivocation script
|
# Joiner with a single broadcast intro, no equivocation script
|
||||||
joiner = MockByzantineAgent("d", _intro(), [], set(), set())
|
joiner = MockByzantineAgent("d", _intro(), [], set(), set())
|
||||||
m.open_boundary(joiner)
|
m.open_boundary(joiner)
|
||||||
m.run_crisis_phase(num_turns=1, gossip_rounds_per_turn=0)
|
m.run_until_quiescent()
|
||||||
|
|
||||||
for name, agent in m.agents.items():
|
for name, agent in m.agents.items():
|
||||||
assert agent.graph.vertex_count() == 1, (
|
assert agent.graph.vertex_count() == 1, (
|
||||||
f"agent {name!r} should have received the intro broadcast"
|
f"agent {name!r} should have received the intro broadcast"
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_targeted_emission_skips_non_targets(self):
|
def test_targeted_emission_seeds_disjoint_views(self):
|
||||||
"""A target_subset emission only reaches its named peers."""
|
"""After the async loop with gossip, every honest agent sees both
|
||||||
|
variants — but the byzantine itself never has both in its own graph
|
||||||
|
(it never re-receives its own targeted emissions, and gossip from
|
||||||
|
honest peers may or may not feed them back).
|
||||||
|
|
||||||
|
The protocol-level invariant: the byzantine's two contradictory
|
||||||
|
vertices end up reachable to every honest agent. THAT is what
|
||||||
|
decentralized detection depends on.
|
||||||
|
"""
|
||||||
m = Mothership()
|
m = Mothership()
|
||||||
m.add_agent(MockAgent("a", [[]]))
|
m.add_agent(MockAgent("a", [[]]))
|
||||||
m.add_agent(MockAgent("b", [[]]))
|
m.add_agent(MockAgent("b", [[]]))
|
||||||
# Byzantine: emits intro to everyone (turn 0), then equivocation
|
|
||||||
# to {a} vs {b} (turn 1).
|
|
||||||
byz = MockByzantineAgent(
|
byz = MockByzantineAgent(
|
||||||
"d", _intro(),
|
"d", _intro(),
|
||||||
scripted_pairs=[(
|
scripted_pairs=[(
|
||||||
|
|
@ -93,19 +102,18 @@ class TestCrisisPhaseAgentOwnership:
|
||||||
split_b={"b"},
|
split_b={"b"},
|
||||||
)
|
)
|
||||||
m.open_boundary(byz)
|
m.open_boundary(byz)
|
||||||
m.run_crisis_phase(num_turns=2, gossip_rounds_per_turn=0)
|
m.run_until_quiescent()
|
||||||
|
|
||||||
# a has: intro + variant-true; b has: intro + variant-false; d has: intro
|
# Every honest agent's graph has both variants of the equivocation
|
||||||
graphs = {n: a.graph for n, a in m.agents.items()}
|
# (the post-condition that lets decentralized detection work).
|
||||||
assert graphs["a"].vertex_count() == 2
|
for name in ("a", "b"):
|
||||||
assert graphs["b"].vertex_count() == 2
|
payloads = [v.payload for v in m.agents[name].graph.all_vertices()]
|
||||||
assert graphs["d"].vertex_count() == 1 # targeted emissions skip sender
|
assert any(b'"verdict":"true"' in p for p in payloads), (
|
||||||
|
f"agent {name!r} missing the true-variant"
|
||||||
# The variant payloads are distinct between a and b
|
)
|
||||||
a_payloads = [v.payload for v in graphs["a"].all_vertices()]
|
assert any(b'"verdict":"false"' in p for p in payloads), (
|
||||||
b_payloads = [v.payload for v in graphs["b"].all_vertices()]
|
f"agent {name!r} missing the false-variant"
|
||||||
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:
|
class TestGossipRound:
|
||||||
|
|
@ -128,7 +136,7 @@ class TestGossipRound:
|
||||||
)
|
)
|
||||||
m.open_boundary(byz)
|
m.open_boundary(byz)
|
||||||
# Two turns (intro + equivocation), then gossip
|
# Two turns (intro + equivocation), then gossip
|
||||||
m.run_crisis_phase(num_turns=2, gossip_rounds_per_turn=1)
|
m.run_until_quiescent()
|
||||||
|
|
||||||
# After gossip, every honest agent should have both byzantine variants
|
# After gossip, every honest agent should have both byzantine variants
|
||||||
# (intro + 2 equivocations = 3 vertices minimum). The byzantine itself
|
# (intro + 2 equivocations = 3 vertices minimum). The byzantine itself
|
||||||
|
|
@ -155,4 +163,4 @@ class TestGossipRound:
|
||||||
m = Mothership()
|
m = Mothership()
|
||||||
m.add_agent(MockAgent("a", [[_claim("s01")]]))
|
m.add_agent(MockAgent("a", [[_claim("s01")]]))
|
||||||
with pytest.raises(RuntimeError, match="boundary not yet open"):
|
with pytest.raises(RuntimeError, match="boundary not yet open"):
|
||||||
m.run_crisis_phase(num_turns=1)
|
m.run_until_quiescent()
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,7 @@ def test_all_honest_agents_agree_on_ratified_alarms():
|
||||||
split_a={"a", "c"},
|
split_a={"a", "c"},
|
||||||
split_b={"b"},
|
split_b={"b"},
|
||||||
))
|
))
|
||||||
m.run_crisis_phase(num_turns=2, gossip_rounds_per_turn=1)
|
m.run_until_quiescent()
|
||||||
m.emit_alarms_from_detectors()
|
|
||||||
m.run_gossip_round()
|
|
||||||
|
|
||||||
# The headline assertion: three independent vantage points; same result.
|
# The headline assertion: three independent vantage points; same result.
|
||||||
ratified_per_agent = {
|
ratified_per_agent = {
|
||||||
|
|
@ -73,9 +71,7 @@ def test_byzantine_alone_cannot_ratify():
|
||||||
m.add_agent(MockAgent("c", [[]]))
|
m.add_agent(MockAgent("c", [[]]))
|
||||||
# No equivocation script — boundary opens cleanly.
|
# No equivocation script — boundary opens cleanly.
|
||||||
m.open_boundary(MockByzantineAgent("d", _intro(), [], set(), set()))
|
m.open_boundary(MockByzantineAgent("d", _intro(), [], set(), set()))
|
||||||
m.run_crisis_phase(num_turns=1, gossip_rounds_per_turn=1)
|
m.run_until_quiescent()
|
||||||
m.emit_alarms_from_detectors()
|
|
||||||
m.run_gossip_round()
|
|
||||||
|
|
||||||
# No honest agent should have ratified anything.
|
# No honest agent should have ratified anything.
|
||||||
for name in ("a", "b", "c"):
|
for name in ("a", "b", "c"):
|
||||||
|
|
|
||||||
|
|
@ -42,11 +42,9 @@ def _full_run() -> Mothership:
|
||||||
split_b={"b"},
|
split_b={"b"},
|
||||||
)
|
)
|
||||||
m.open_boundary(byz)
|
m.open_boundary(byz)
|
||||||
m.run_crisis_phase(num_turns=2, gossip_rounds_per_turn=1)
|
m.run_until_quiescent()
|
||||||
# Honest agents emit AlarmClaims based on what they observed.
|
# 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.
|
# One more gossip round so every honest agent sees all AlarmClaims.
|
||||||
m.run_gossip_round()
|
|
||||||
return m
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -69,7 +67,7 @@ class TestAlarmClaimRoundtrip:
|
||||||
accused_process_id_hex="76468f93",
|
accused_process_id_hex="76468f93",
|
||||||
statement_id="s03",
|
statement_id="s03",
|
||||||
witness_digests=("aaaa", "bbbb"),
|
witness_digests=("aaaa", "bbbb"),
|
||||||
detected_at_turn=1,
|
emitted_at_step=1,
|
||||||
)
|
)
|
||||||
roundtrip = AlarmClaim.from_payload(ac.to_payload())
|
roundtrip = AlarmClaim.from_payload(ac.to_payload())
|
||||||
assert roundtrip == ac
|
assert roundtrip == ac
|
||||||
|
|
@ -82,11 +80,11 @@ class TestAlarmClaimRoundtrip:
|
||||||
statement_id="s03",
|
statement_id="s03",
|
||||||
witness_digests=("aa", "bb"),
|
witness_digests=("aa", "bb"),
|
||||||
)
|
)
|
||||||
ac = AlarmClaim.from_local_alarm(la, detected_at_turn=5)
|
ac = AlarmClaim.from_local_alarm(la, emitted_at_step=5)
|
||||||
assert ac.accused_process_id_hex == "22"
|
assert ac.accused_process_id_hex == "22"
|
||||||
assert ac.statement_id == "s03"
|
assert ac.statement_id == "s03"
|
||||||
assert ac.witness_digests == ("aa", "bb")
|
assert ac.witness_digests == ("aa", "bb")
|
||||||
assert ac.detected_at_turn == 5
|
assert ac.emitted_at_step == 5
|
||||||
|
|
||||||
def test_rejects_non_alarm_payload(self):
|
def test_rejects_non_alarm_payload(self):
|
||||||
regular_claim = Claim(
|
regular_claim = Claim(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue