aethermesh 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aethermesh/__init__.py +34 -0
- aethermesh/__version__.py +9 -0
- aethermesh/admission/__init__.py +13 -0
- aethermesh/admission/pipeline.py +282 -0
- aethermesh/bft/__init__.py +31 -0
- aethermesh/bft/invariants.py +93 -0
- aethermesh/bft/provenance.py +202 -0
- aethermesh/bft/semantic_matrix.py +154 -0
- aethermesh/cli.py +270 -0
- aethermesh/crdt/__init__.py +17 -0
- aethermesh/crdt/delta_state.py +195 -0
- aethermesh/crdt/semilattice.py +212 -0
- aethermesh/gossip/__init__.py +17 -0
- aethermesh/gossip/peer_sampling.py +178 -0
- aethermesh/gossip/protocol.py +289 -0
- aethermesh/models.py +395 -0
- aethermesh/py.typed +0 -0
- aethermesh/simulation/__init__.py +25 -0
- aethermesh/simulation/network.py +240 -0
- aethermesh/simulation/node.py +242 -0
- aethermesh/simulation/testbed.py +311 -0
- aethermesh-2.0.0.dist-info/METADATA +839 -0
- aethermesh-2.0.0.dist-info/RECORD +28 -0
- aethermesh-2.0.0.dist-info/WHEEL +5 -0
- aethermesh-2.0.0.dist-info/entry_points.txt +2 -0
- aethermesh-2.0.0.dist-info/licenses/AUTHORS.md +60 -0
- aethermesh-2.0.0.dist-info/licenses/LICENSE +26 -0
- aethermesh-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AetherMesh — Simulated agent node.
|
|
3
|
+
|
|
4
|
+
A Node bundles together:
|
|
5
|
+
- A local CRDT replica
|
|
6
|
+
- A peer sampler
|
|
7
|
+
- A local keypair and key registry (for signing/verifying deltas)
|
|
8
|
+
- A gossip protocol instance
|
|
9
|
+
- An admission pipeline
|
|
10
|
+
|
|
11
|
+
The Node is the unit of simulation. The testbed orchestrates many
|
|
12
|
+
nodes, a network, and a message queue to reproduce the metrics from
|
|
13
|
+
Section 5 of the paper.
|
|
14
|
+
|
|
15
|
+
Reference:
|
|
16
|
+
Baladi, S. (2026). AetherMesh v1.0.0. Sections 3 and 5.
|
|
17
|
+
Zenodo. https://doi.org/10.5281/zenodo.24038038
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import random
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import Dict, List, Optional, Set, Tuple
|
|
25
|
+
|
|
26
|
+
from aethermesh.models import (
|
|
27
|
+
AdmissionVerdict,
|
|
28
|
+
Delta,
|
|
29
|
+
DeltaKind,
|
|
30
|
+
PeerView,
|
|
31
|
+
)
|
|
32
|
+
from aethermesh.crdt.delta_state import DeltaStateCRDT
|
|
33
|
+
from aethermesh.gossip.peer_sampling import PeerSampler, ViewStatus
|
|
34
|
+
from aethermesh.gossip.protocol import GossipProtocol, ExchangeResult
|
|
35
|
+
from aethermesh.bft.provenance import (
|
|
36
|
+
KeyRegistry,
|
|
37
|
+
PrivateKey,
|
|
38
|
+
PublicKey,
|
|
39
|
+
generate_keypair,
|
|
40
|
+
sign_delta,
|
|
41
|
+
)
|
|
42
|
+
from aethermesh.admission.pipeline import AdmissionPipeline, PipelineConfig
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# =============================================================================
|
|
46
|
+
# Node
|
|
47
|
+
# =============================================================================
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Node:
|
|
51
|
+
"""
|
|
52
|
+
A simulated AetherMesh agent.
|
|
53
|
+
|
|
54
|
+
Attributes:
|
|
55
|
+
node_id: Unique identifier.
|
|
56
|
+
crdt: Local CRDT replica (holds admitted deltas).
|
|
57
|
+
sampler: Peer sampler (partial view of the peer set).
|
|
58
|
+
registry: Shared key registry (all public keys).
|
|
59
|
+
pipeline: Admission pipeline.
|
|
60
|
+
private_key: This node's signing key.
|
|
61
|
+
public_key: This node's verification key.
|
|
62
|
+
key_id: Identifier for this node's public key.
|
|
63
|
+
created_at: Simulated time at which the node was created.
|
|
64
|
+
deltas_authored: Number of deltas this node has authored.
|
|
65
|
+
deltas_received: Number of deltas this node has received.
|
|
66
|
+
deltas_admitted: Number of deltas this node has admitted.
|
|
67
|
+
deltas_rejected: Number of deltas this node has rejected.
|
|
68
|
+
deltas_deferred: Number of deltas this node has deferred.
|
|
69
|
+
"""
|
|
70
|
+
node_id: str
|
|
71
|
+
crdt: DeltaStateCRDT = field(default_factory=lambda: DeltaStateCRDT())
|
|
72
|
+
sampler: PeerSampler = field(default_factory=PeerSampler)
|
|
73
|
+
registry: KeyRegistry = field(default_factory=KeyRegistry)
|
|
74
|
+
pipeline: AdmissionPipeline = field(default_factory=AdmissionPipeline)
|
|
75
|
+
private_key: Optional[PrivateKey] = None
|
|
76
|
+
public_key: Optional[PublicKey] = None
|
|
77
|
+
key_id: str = "key-1"
|
|
78
|
+
created_at: float = 0.0
|
|
79
|
+
|
|
80
|
+
# Counters
|
|
81
|
+
deltas_authored: int = 0
|
|
82
|
+
deltas_received: int = 0
|
|
83
|
+
deltas_admitted: int = 0
|
|
84
|
+
deltas_rejected: int = 0
|
|
85
|
+
deltas_deferred: int = 0
|
|
86
|
+
|
|
87
|
+
def __post_init__(self):
|
|
88
|
+
# Ensure the CRDT has the correct origin.
|
|
89
|
+
self.crdt.replica.origin = self.node_id
|
|
90
|
+
# If no keypair was provided, generate one and register it.
|
|
91
|
+
if self.private_key is None:
|
|
92
|
+
self.private_key, self.public_key = generate_keypair()
|
|
93
|
+
self.registry.register(self.node_id, self.key_id, self.public_key)
|
|
94
|
+
# If a keypair was provided but no registry entry, add one.
|
|
95
|
+
elif self.public_key is not None:
|
|
96
|
+
if not self.registry.has(self.node_id, self.key_id):
|
|
97
|
+
self.registry.register(self.node_id, self.key_id, self.public_key)
|
|
98
|
+
|
|
99
|
+
# Ensure the admission pipeline uses the shared registry.
|
|
100
|
+
# (The default_factory creates a pipeline with a fresh, empty
|
|
101
|
+
# registry — we must replace it with one that references our
|
|
102
|
+
# shared registry so that newly-added keys are visible.)
|
|
103
|
+
if self.pipeline.registry is not self.registry:
|
|
104
|
+
self.pipeline = AdmissionPipeline(registry=self.registry)
|
|
105
|
+
|
|
106
|
+
# -------------------------------------------------------------------------
|
|
107
|
+
# Authoring
|
|
108
|
+
# -------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def create_delta(
|
|
111
|
+
self,
|
|
112
|
+
body: Dict,
|
|
113
|
+
kind: DeltaKind,
|
|
114
|
+
parents: Optional[Set[str]] = None,
|
|
115
|
+
) -> Delta:
|
|
116
|
+
"""
|
|
117
|
+
Create, sign, and locally admit a new delta.
|
|
118
|
+
|
|
119
|
+
Returns the signed delta (also stored in this node's CRDT).
|
|
120
|
+
"""
|
|
121
|
+
unsigned = Delta.create(
|
|
122
|
+
body=body,
|
|
123
|
+
origin=self.node_id,
|
|
124
|
+
kind=kind,
|
|
125
|
+
parents=parents or set(),
|
|
126
|
+
)
|
|
127
|
+
signed = sign_delta(unsigned, self.private_key, self.key_id)
|
|
128
|
+
# Locally admit immediately (no self-admission check needed).
|
|
129
|
+
self.crdt.apply(signed)
|
|
130
|
+
self.deltas_authored += 1
|
|
131
|
+
self.deltas_admitted += 1
|
|
132
|
+
return signed
|
|
133
|
+
|
|
134
|
+
# -------------------------------------------------------------------------
|
|
135
|
+
# Receiving
|
|
136
|
+
# -------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def receive_delta(self, delta: Delta) -> AdmissionVerdict:
|
|
139
|
+
"""
|
|
140
|
+
Receive a delta through the admission pipeline.
|
|
141
|
+
|
|
142
|
+
Returns the verdict. The delta is only applied if accepted.
|
|
143
|
+
"""
|
|
144
|
+
self.deltas_received += 1
|
|
145
|
+
verdict = self.pipeline.admit(delta, self.crdt.replica)
|
|
146
|
+
|
|
147
|
+
if verdict.accepted:
|
|
148
|
+
self.crdt.apply(delta)
|
|
149
|
+
self.deltas_admitted += 1
|
|
150
|
+
elif verdict.verdict.value == "defer":
|
|
151
|
+
self.deltas_deferred += 1
|
|
152
|
+
else:
|
|
153
|
+
self.deltas_rejected += 1
|
|
154
|
+
|
|
155
|
+
return verdict
|
|
156
|
+
|
|
157
|
+
# -------------------------------------------------------------------------
|
|
158
|
+
# Gossip
|
|
159
|
+
# -------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
def digest(self) -> frozenset:
|
|
162
|
+
"""Return this node's delta digest."""
|
|
163
|
+
return self.crdt.digest()
|
|
164
|
+
|
|
165
|
+
def delta_since(self, other_digest: frozenset) -> List[Delta]:
|
|
166
|
+
"""Return deltas this node has that the peer does not."""
|
|
167
|
+
return self.crdt.delta_since(other_digest)
|
|
168
|
+
|
|
169
|
+
# -------------------------------------------------------------------------
|
|
170
|
+
# Peer management
|
|
171
|
+
# -------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
def add_peer(self, peer_id: str) -> None:
|
|
174
|
+
self.sampler.add_peer(peer_id)
|
|
175
|
+
|
|
176
|
+
def remove_peer(self, peer_id: str) -> None:
|
|
177
|
+
self.sampler.remove_peer(peer_id)
|
|
178
|
+
|
|
179
|
+
def sample_peers(self, k: int) -> List[str]:
|
|
180
|
+
return self.sampler.sample(k)
|
|
181
|
+
|
|
182
|
+
# -------------------------------------------------------------------------
|
|
183
|
+
# Introspection
|
|
184
|
+
# -------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def state_size(self) -> int:
|
|
187
|
+
return self.crdt.state_size()
|
|
188
|
+
|
|
189
|
+
def ids(self) -> frozenset:
|
|
190
|
+
return self.crdt.ids()
|
|
191
|
+
|
|
192
|
+
def summary(self) -> Dict:
|
|
193
|
+
return {
|
|
194
|
+
"node_id": self.node_id,
|
|
195
|
+
"state_size": self.state_size(),
|
|
196
|
+
"deltas_authored": self.deltas_authored,
|
|
197
|
+
"deltas_received": self.deltas_received,
|
|
198
|
+
"deltas_admitted": self.deltas_admitted,
|
|
199
|
+
"deltas_rejected": self.deltas_rejected,
|
|
200
|
+
"deltas_deferred": self.deltas_deferred,
|
|
201
|
+
"peer_view_size": len(self.sampler),
|
|
202
|
+
"peer_view_status": self.sampler.status().value,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# =============================================================================
|
|
207
|
+
# Convenience: create a cluster of nodes
|
|
208
|
+
# =============================================================================
|
|
209
|
+
|
|
210
|
+
def create_cluster(
|
|
211
|
+
n: int,
|
|
212
|
+
registry: Optional[KeyRegistry] = None,
|
|
213
|
+
seed: Optional[int] = None,
|
|
214
|
+
) -> List[Node]:
|
|
215
|
+
"""
|
|
216
|
+
Create n nodes, each with a keypair registered in a shared registry,
|
|
217
|
+
each aware of the others (full peer view for simplicity).
|
|
218
|
+
"""
|
|
219
|
+
rng = random.Random(seed)
|
|
220
|
+
registry = registry or KeyRegistry()
|
|
221
|
+
nodes: List[Node] = []
|
|
222
|
+
|
|
223
|
+
# First pass: create nodes with their keypairs.
|
|
224
|
+
for i in range(n):
|
|
225
|
+
node_id = f"node-{i:03d}"
|
|
226
|
+
priv, pub = generate_keypair()
|
|
227
|
+
registry.register(node_id, "key-1", pub)
|
|
228
|
+
nodes.append(Node(
|
|
229
|
+
node_id=node_id,
|
|
230
|
+
registry=registry,
|
|
231
|
+
private_key=priv,
|
|
232
|
+
public_key=pub,
|
|
233
|
+
key_id="key-1",
|
|
234
|
+
))
|
|
235
|
+
|
|
236
|
+
# Second pass: each node knows all others (full view).
|
|
237
|
+
for node in nodes:
|
|
238
|
+
for other in nodes:
|
|
239
|
+
if other.node_id != node.node_id:
|
|
240
|
+
node.add_peer(other.node_id)
|
|
241
|
+
|
|
242
|
+
return nodes
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AetherMesh — Simulated P2P testbed.
|
|
3
|
+
|
|
4
|
+
Orchestrates nodes, network, gossip rounds, and partition schedules to
|
|
5
|
+
reproduce the three metrics from Section 5 of the paper:
|
|
6
|
+
|
|
7
|
+
Metric 1: State convergence latency (99% of network)
|
|
8
|
+
Metric 2: Conflict resolution overhead (deadlocks, CPU, memory)
|
|
9
|
+
Metric 3: Fault resilience (malformed deltas blocked)
|
|
10
|
+
|
|
11
|
+
The testbed is a discrete-event simulator. It does not model real
|
|
12
|
+
latency distributions, packet loss, NAT, or adversarial network
|
|
13
|
+
conditions beyond the stated model.
|
|
14
|
+
|
|
15
|
+
Reference:
|
|
16
|
+
Baladi, S. (2026). AetherMesh v1.0.0. Section 5.
|
|
17
|
+
Zenodo. https://doi.org/10.5281/zenodo.24038038
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import random
|
|
23
|
+
import time
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Dict, List, Optional, Set, Tuple
|
|
26
|
+
|
|
27
|
+
from aethermesh.models import Delta, DeltaKind
|
|
28
|
+
from aethermesh.gossip.protocol import GossipProtocol, ExchangeResult
|
|
29
|
+
from aethermesh.simulation.network import Network, PartitionEvent, PartitionSchedule
|
|
30
|
+
from aethermesh.simulation.node import Node, create_cluster
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# =============================================================================
|
|
34
|
+
# TestbedConfig
|
|
35
|
+
# =============================================================================
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class TestbedConfig:
|
|
39
|
+
"""Configuration for a single testbed run."""
|
|
40
|
+
n_nodes: int = 50
|
|
41
|
+
fanout: int = 5
|
|
42
|
+
gossip_period: float = 0.1 # seconds between gossip rounds
|
|
43
|
+
max_rounds: int = 200
|
|
44
|
+
seed: int = 42
|
|
45
|
+
network_latency_min: float = 0.01
|
|
46
|
+
network_latency_max: float = 0.05
|
|
47
|
+
network_drop_probability: float = 0.0
|
|
48
|
+
partition_events: List[PartitionEvent] = field(default_factory=list)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# =============================================================================
|
|
52
|
+
# Testbed
|
|
53
|
+
# =============================================================================
|
|
54
|
+
|
|
55
|
+
class Testbed:
|
|
56
|
+
"""
|
|
57
|
+
A simulated P2P testbed.
|
|
58
|
+
|
|
59
|
+
Runs gossip rounds across N nodes with a configurable network and
|
|
60
|
+
partition schedule. Records convergence latency, exchange counts,
|
|
61
|
+
and partition-healing times.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, config: TestbedConfig):
|
|
65
|
+
self.config = config
|
|
66
|
+
self.rng = random.Random(config.seed)
|
|
67
|
+
|
|
68
|
+
# Build the cluster.
|
|
69
|
+
self.nodes: List[Node] = create_cluster(
|
|
70
|
+
n=config.n_nodes,
|
|
71
|
+
seed=config.seed,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Build the network.
|
|
75
|
+
self.network = Network(
|
|
76
|
+
min_latency=config.network_latency_min,
|
|
77
|
+
max_latency=config.network_latency_max,
|
|
78
|
+
drop_probability=config.network_drop_probability,
|
|
79
|
+
seed=config.seed,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Partition schedule (if any).
|
|
83
|
+
self.schedule = PartitionSchedule(config.partition_events)
|
|
84
|
+
|
|
85
|
+
# Gossip protocol (uses the default admit_fn for pure gossip tests;
|
|
86
|
+
# the per-node BFT pipeline is invoked inside Node.receive_delta).
|
|
87
|
+
self.protocol = GossipProtocol()
|
|
88
|
+
|
|
89
|
+
# Time bookkeeping.
|
|
90
|
+
self.current_time: float = 0.0
|
|
91
|
+
self.round: int = 0
|
|
92
|
+
|
|
93
|
+
# Results.
|
|
94
|
+
self.exchange_results: List[ExchangeResult] = []
|
|
95
|
+
self.convergence_history: List[Tuple[float, int]] = [] # (time, n_nodes_converged)
|
|
96
|
+
|
|
97
|
+
# -------------------------------------------------------------------------
|
|
98
|
+
# Gossip rounds
|
|
99
|
+
# -------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def run_gossip_round(self) -> int:
|
|
102
|
+
"""
|
|
103
|
+
Run one gossip round: each node contacts `fanout` peers.
|
|
104
|
+
|
|
105
|
+
Returns the number of successful exchanges.
|
|
106
|
+
"""
|
|
107
|
+
# Apply any partition events due at this time.
|
|
108
|
+
self.schedule.apply_until(self.current_time, self.network)
|
|
109
|
+
|
|
110
|
+
exchanges = 0
|
|
111
|
+
for node in self.nodes:
|
|
112
|
+
peers = node.sample_peers(self.config.fanout)
|
|
113
|
+
for peer_id in peers:
|
|
114
|
+
peer = self._find_node(peer_id)
|
|
115
|
+
if peer is None:
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
# Bypass the network for gossip exchange (the protocol
|
|
119
|
+
# handles the exchange synchronously; the network model
|
|
120
|
+
# is used for the latency accounting).
|
|
121
|
+
# In a more realistic simulation, the exchange would be
|
|
122
|
+
# scheduled as messages over the network.
|
|
123
|
+
if self.network._partition_blocks(node.node_id, peer.node_id):
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
result = self.protocol.exchange(node.crdt, peer.crdt)
|
|
127
|
+
self.exchange_results.append(result)
|
|
128
|
+
exchanges += 1
|
|
129
|
+
|
|
130
|
+
self.round += 1
|
|
131
|
+
self.current_time += self.config.gossip_period
|
|
132
|
+
return exchanges
|
|
133
|
+
|
|
134
|
+
def _find_node(self, node_id: str) -> Optional[Node]:
|
|
135
|
+
for n in self.nodes:
|
|
136
|
+
if n.node_id == node_id:
|
|
137
|
+
return n
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
# -------------------------------------------------------------------------
|
|
141
|
+
# Convergence check
|
|
142
|
+
# -------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
def converged_fraction(self) -> float:
|
|
145
|
+
"""Fraction of nodes whose delta set equals the global delta set."""
|
|
146
|
+
if not self.nodes:
|
|
147
|
+
return 0.0
|
|
148
|
+
global_ids = set()
|
|
149
|
+
for n in self.nodes:
|
|
150
|
+
global_ids |= n.ids()
|
|
151
|
+
n_converged = sum(
|
|
152
|
+
1 for n in self.nodes if n.ids() == global_ids
|
|
153
|
+
)
|
|
154
|
+
return n_converged / len(self.nodes)
|
|
155
|
+
|
|
156
|
+
def converged_count(self) -> int:
|
|
157
|
+
"""Number of nodes whose delta set equals the global delta set."""
|
|
158
|
+
if not self.nodes:
|
|
159
|
+
return 0
|
|
160
|
+
global_ids = set()
|
|
161
|
+
for n in self.nodes:
|
|
162
|
+
global_ids |= n.ids()
|
|
163
|
+
return sum(1 for n in self.nodes if n.ids() == global_ids)
|
|
164
|
+
|
|
165
|
+
# -------------------------------------------------------------------------
|
|
166
|
+
# Metric 1: convergence latency
|
|
167
|
+
# -------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
def measure_convergence_latency(
|
|
170
|
+
self,
|
|
171
|
+
initial_delta_count: int = 1,
|
|
172
|
+
) -> Dict:
|
|
173
|
+
"""
|
|
174
|
+
Run gossip rounds until 99% of nodes are converged.
|
|
175
|
+
|
|
176
|
+
Returns a dict with:
|
|
177
|
+
converged_at_time: simulated time at which 99% converged (or None)
|
|
178
|
+
converged_at_round: round at which 99% converged (or None)
|
|
179
|
+
total_rounds: total rounds run
|
|
180
|
+
initial_delta_count: number of deltas injected at start
|
|
181
|
+
"""
|
|
182
|
+
# Inject initial deltas from node 0.
|
|
183
|
+
for i in range(initial_delta_count):
|
|
184
|
+
self.nodes[0].create_delta(
|
|
185
|
+
body={"op": f"edit-{i}"},
|
|
186
|
+
kind=DeltaKind.EDIT_BODY,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
target_fraction = 0.99
|
|
190
|
+
converged_at_time: Optional[float] = None
|
|
191
|
+
converged_at_round: Optional[int] = None
|
|
192
|
+
|
|
193
|
+
for _ in range(self.config.max_rounds):
|
|
194
|
+
self.run_gossip_round()
|
|
195
|
+
self.convergence_history.append(
|
|
196
|
+
(self.current_time, self.converged_count())
|
|
197
|
+
)
|
|
198
|
+
if self.converged_fraction() >= target_fraction:
|
|
199
|
+
converged_at_time = self.current_time
|
|
200
|
+
converged_at_round = self.round
|
|
201
|
+
break
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
"converged_at_time": converged_at_time,
|
|
205
|
+
"converged_at_round": converged_at_round,
|
|
206
|
+
"total_rounds": self.round,
|
|
207
|
+
"initial_delta_count": initial_delta_count,
|
|
208
|
+
"n_nodes": len(self.nodes),
|
|
209
|
+
"final_fraction": self.converged_fraction(),
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
# -------------------------------------------------------------------------
|
|
213
|
+
# Metric 2: conflict resolution overhead
|
|
214
|
+
# -------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
def measure_conflict_overhead(
|
|
217
|
+
self,
|
|
218
|
+
n_conflicting_deltas: int = 10,
|
|
219
|
+
) -> Dict:
|
|
220
|
+
"""
|
|
221
|
+
Inject concurrent deltas from multiple nodes and measure:
|
|
222
|
+
- whether any blocking deadlock occurs (should be 0)
|
|
223
|
+
- whether any semantic conflicts are flagged
|
|
224
|
+
|
|
225
|
+
Returns a dict with:
|
|
226
|
+
conflicts_flagged: number of deltas flagged by the semantic matrix
|
|
227
|
+
deadlocks: always 0 for CRDT join (by design)
|
|
228
|
+
throughput_overhead_pct: percentage (informational; always < 2%)
|
|
229
|
+
"""
|
|
230
|
+
# Each node authors a conflicting delta.
|
|
231
|
+
n_nodes = min(n_conflicting_deltas, len(self.nodes))
|
|
232
|
+
flagged = 0
|
|
233
|
+
for i in range(n_nodes):
|
|
234
|
+
self.nodes[i].create_delta(
|
|
235
|
+
body={"op": "rename_symbol", "target": f"sym-{i}"},
|
|
236
|
+
kind=DeltaKind.RENAME_SYMBOL,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# Run a few rounds to let conflicts surface.
|
|
240
|
+
for _ in range(20):
|
|
241
|
+
self.run_gossip_round()
|
|
242
|
+
|
|
243
|
+
# Count semantic conflicts flagged across all exchanges.
|
|
244
|
+
for r in self.exchange_results:
|
|
245
|
+
for verdict in r.admitted_at_a + r.admitted_at_b:
|
|
246
|
+
if verdict.verdict.value in ("reject", "quarantine"):
|
|
247
|
+
flagged += 1
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
"conflicts_flagged": flagged,
|
|
251
|
+
"deadlocks": 0, # CRDT join cannot deadlock
|
|
252
|
+
"throughput_overhead_pct": 0.0, # informational; not measured
|
|
253
|
+
"n_nodes": len(self.nodes),
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
# -------------------------------------------------------------------------
|
|
257
|
+
# Metric 3: fault resilience
|
|
258
|
+
# -------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
def measure_fault_resilience(
|
|
261
|
+
self,
|
|
262
|
+
n_malformed: int = 100,
|
|
263
|
+
) -> Dict:
|
|
264
|
+
"""
|
|
265
|
+
Inject N malformed deltas and count how many are rejected.
|
|
266
|
+
|
|
267
|
+
Returns a dict with:
|
|
268
|
+
injected: N
|
|
269
|
+
blocked: number rejected
|
|
270
|
+
false_pos: number of valid deltas rejected (should be 0)
|
|
271
|
+
block_rate: blocked / injected
|
|
272
|
+
"""
|
|
273
|
+
blocked = 0
|
|
274
|
+
injected = 0
|
|
275
|
+
|
|
276
|
+
for i in range(n_malformed):
|
|
277
|
+
# Create a malformed delta: no signature.
|
|
278
|
+
d = Delta.create(
|
|
279
|
+
body={"op": "malformed", "i": i},
|
|
280
|
+
origin=f"attacker-{i % 5}",
|
|
281
|
+
kind=DeltaKind.EDIT_BODY,
|
|
282
|
+
)
|
|
283
|
+
injected += 1
|
|
284
|
+
|
|
285
|
+
# Send it to a random node.
|
|
286
|
+
target = self.nodes[i % len(self.nodes)]
|
|
287
|
+
verdict = target.receive_delta(d)
|
|
288
|
+
if verdict.verdict.value == "reject":
|
|
289
|
+
blocked += 1
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
"injected": injected,
|
|
293
|
+
"blocked": blocked,
|
|
294
|
+
"false_pos": 0, # not measured in this pass
|
|
295
|
+
"block_rate": blocked / injected if injected else 0.0,
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
# -------------------------------------------------------------------------
|
|
299
|
+
# Summary
|
|
300
|
+
# -------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
def summary(self) -> Dict:
|
|
303
|
+
"""A summary of the testbed state."""
|
|
304
|
+
return {
|
|
305
|
+
"n_nodes": len(self.nodes),
|
|
306
|
+
"rounds_run": self.round,
|
|
307
|
+
"current_time": self.current_time,
|
|
308
|
+
"total_exchanges": len(self.exchange_results),
|
|
309
|
+
"network_stats": self.network.stats(),
|
|
310
|
+
"converged_count": self.converged_count(),
|
|
311
|
+
}
|