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,212 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AetherMesh — Join-semilattice for delta sets (Pillar 1).
|
|
3
|
+
|
|
4
|
+
Implements the bounded join-semilattice (S, ⊔, ⊥) that underlies the
|
|
5
|
+
CRDT state space (Definition 2, Section 4.2 of the paper):
|
|
6
|
+
|
|
7
|
+
s_reconciled = s_i ⊔ s_j
|
|
8
|
+
|
|
9
|
+
The join operator must satisfy:
|
|
10
|
+
|
|
11
|
+
- Commutative: s_i ⊔ s_j = s_j ⊔ s_i
|
|
12
|
+
- Associative: (s_i ⊔ s_j) ⊔ s_k = s_i ⊔ (s_j ⊔ s_k)
|
|
13
|
+
- Idempotent: s_i ⊔ s_i = s_i
|
|
14
|
+
- Inflationary: s ⊑ s ⊔ t
|
|
15
|
+
|
|
16
|
+
This module provides:
|
|
17
|
+
|
|
18
|
+
- JoinSemilattice: abstract base class
|
|
19
|
+
- DeltaSetLattice: concrete implementation for sets of deltas
|
|
20
|
+
- verify_join_laws: exhaustive check on a finite lattice (cf. Figure 4)
|
|
21
|
+
|
|
22
|
+
Reference:
|
|
23
|
+
Baladi, S. (2026). AetherMesh v1.0.0. Sections 3.1.2 and 4.2.
|
|
24
|
+
Zenodo. https://doi.org/10.5281/zenodo.24038038
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import itertools
|
|
30
|
+
from abc import ABC, abstractmethod
|
|
31
|
+
from typing import Any, Callable, Generic, Iterable, List, Set, Tuple, TypeVar
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
T = TypeVar("T")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class JoinLawError(Exception):
|
|
38
|
+
"""Raised when a lattice fails to satisfy one of the join laws."""
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# =============================================================================
|
|
43
|
+
# Abstract base
|
|
44
|
+
# =============================================================================
|
|
45
|
+
|
|
46
|
+
class JoinSemilattice(ABC, Generic[T]):
|
|
47
|
+
"""
|
|
48
|
+
Abstract join-semilattice (S, ⊔, ⊥).
|
|
49
|
+
|
|
50
|
+
Subclasses must implement `join` and `bottom`, and may override
|
|
51
|
+
`leq` for efficiency (default: s ⊑ t iff s ⊔ t == t).
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
@abstractmethod
|
|
55
|
+
def join(self, a: T, b: T) -> T:
|
|
56
|
+
"""Binary join operator (commutative, associative, idempotent)."""
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
@abstractmethod
|
|
60
|
+
def bottom(self) -> T:
|
|
61
|
+
"""The bottom element ⊥ (identity of join)."""
|
|
62
|
+
...
|
|
63
|
+
|
|
64
|
+
def leq(self, a: T, b: T) -> bool:
|
|
65
|
+
"""Partial order s ⊑ t iff s ⊔ t == t."""
|
|
66
|
+
return self.join(a, b) == b
|
|
67
|
+
|
|
68
|
+
def join_all(self, items: Iterable[T]) -> T:
|
|
69
|
+
"""Iterated join ⨆ over a set of elements (Definition 1)."""
|
|
70
|
+
result = self.bottom()
|
|
71
|
+
for item in items:
|
|
72
|
+
result = self.join(result, item)
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# =============================================================================
|
|
77
|
+
# Concrete: delta-set lattice
|
|
78
|
+
# =============================================================================
|
|
79
|
+
|
|
80
|
+
class DeltaSetLattice(JoinSemilattice[frozenset]):
|
|
81
|
+
"""
|
|
82
|
+
The grow-only-set CRDT lifted to deltas (Section 3.1.1 of the paper).
|
|
83
|
+
|
|
84
|
+
State space: power set of the delta universe D, ordered by inclusion.
|
|
85
|
+
Join: set union.
|
|
86
|
+
Bottom: empty set.
|
|
87
|
+
|
|
88
|
+
This is the lattice over which `Replica` operates. Its height is
|
|
89
|
+
|D|, so every ascending chain is finite (assumption A3).
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def join(self, a: frozenset, b: frozenset) -> frozenset:
|
|
93
|
+
return a | b
|
|
94
|
+
|
|
95
|
+
def bottom(self) -> frozenset:
|
|
96
|
+
return frozenset()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# =============================================================================
|
|
100
|
+
# Law verification
|
|
101
|
+
# =============================================================================
|
|
102
|
+
|
|
103
|
+
def verify_join_laws(
|
|
104
|
+
lattice: JoinSemilattice[T],
|
|
105
|
+
elements: List[T],
|
|
106
|
+
verbose: bool = False,
|
|
107
|
+
) -> Tuple[bool, List[str]]:
|
|
108
|
+
"""
|
|
109
|
+
Exhaustively verify the three join laws on a finite set of elements.
|
|
110
|
+
|
|
111
|
+
This mirrors the exhaustive enumeration used in Figure 4 of the
|
|
112
|
+
paper, where the three join laws were checked on a seven-element
|
|
113
|
+
bounded join-semilattice.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
lattice: A JoinSemilattice instance.
|
|
117
|
+
elements: Finite list of lattice elements to test on.
|
|
118
|
+
verbose: If True, print each violation as it is found.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
(ok, violations) — ok is True iff all laws hold; violations is
|
|
122
|
+
a list of human-readable descriptions of any failures.
|
|
123
|
+
"""
|
|
124
|
+
violations: List[str] = []
|
|
125
|
+
|
|
126
|
+
# --- Commutativity: s_i ⊔ s_j == s_j ⊔ s_i ---
|
|
127
|
+
for a, b in itertools.product(elements, repeat=2):
|
|
128
|
+
lhs = lattice.join(a, b)
|
|
129
|
+
rhs = lattice.join(b, a)
|
|
130
|
+
if lhs != rhs:
|
|
131
|
+
msg = f"Commutativity violated: {a} ⊔ {b} = {lhs}, but {b} ⊔ {a} = {rhs}"
|
|
132
|
+
violations.append(msg)
|
|
133
|
+
if verbose:
|
|
134
|
+
print(msg)
|
|
135
|
+
|
|
136
|
+
# --- Associativity: (s_i ⊔ s_j) ⊔ s_k == s_i ⊔ (s_j ⊔ s_k) ---
|
|
137
|
+
for a, b, c in itertools.product(elements, repeat=3):
|
|
138
|
+
lhs = lattice.join(lattice.join(a, b), c)
|
|
139
|
+
rhs = lattice.join(a, lattice.join(b, c))
|
|
140
|
+
if lhs != rhs:
|
|
141
|
+
msg = f"Associativity violated: ({a} ⊔ {b}) ⊔ {c} = {lhs}, but {a} ⊔ ({b} ⊔ {c}) = {rhs}"
|
|
142
|
+
violations.append(msg)
|
|
143
|
+
if verbose:
|
|
144
|
+
print(msg)
|
|
145
|
+
|
|
146
|
+
# --- Idempotence: s_i ⊔ s_i == s_i ---
|
|
147
|
+
for a in elements:
|
|
148
|
+
if lattice.join(a, a) != a:
|
|
149
|
+
msg = f"Idempotence violated: {a} ⊔ {a} = {lattice.join(a, a)}, expected {a}"
|
|
150
|
+
violations.append(msg)
|
|
151
|
+
if verbose:
|
|
152
|
+
print(msg)
|
|
153
|
+
|
|
154
|
+
# --- Bottom is identity: ⊥ ⊔ s == s and s ⊔ ⊥ == s ---
|
|
155
|
+
bottom = lattice.bottom()
|
|
156
|
+
for a in elements:
|
|
157
|
+
if lattice.join(bottom, a) != a:
|
|
158
|
+
msg = f"Bottom-left identity violated: ⊥ ⊔ {a} = {lattice.join(bottom, a)}, expected {a}"
|
|
159
|
+
violations.append(msg)
|
|
160
|
+
if verbose:
|
|
161
|
+
print(msg)
|
|
162
|
+
if lattice.join(a, bottom) != a:
|
|
163
|
+
msg = f"Bottom-right identity violated: {a} ⊔ ⊥ = {lattice.join(a, bottom)}, expected {a}"
|
|
164
|
+
violations.append(msg)
|
|
165
|
+
if verbose:
|
|
166
|
+
print(msg)
|
|
167
|
+
|
|
168
|
+
# --- Inflationary: s ⊑ s ⊔ t ---
|
|
169
|
+
for a, b in itertools.product(elements, repeat=2):
|
|
170
|
+
joined = lattice.join(a, b)
|
|
171
|
+
if not lattice.leq(a, joined):
|
|
172
|
+
msg = f"Inflationary violated: {a} ⊑ {a} ⊔ {b} = {joined} is False"
|
|
173
|
+
violations.append(msg)
|
|
174
|
+
if verbose:
|
|
175
|
+
print(msg)
|
|
176
|
+
|
|
177
|
+
return (len(violations) == 0, violations)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# =============================================================================
|
|
181
|
+
# Convenience: build the 7-element example from Figure 4
|
|
182
|
+
# =============================================================================
|
|
183
|
+
|
|
184
|
+
def build_figure4_example() -> Tuple[DeltaSetLattice, List[frozenset]]:
|
|
185
|
+
"""
|
|
186
|
+
Build the seven-element bounded join-semilattice from Figure 4 of
|
|
187
|
+
the paper (Hasse diagram).
|
|
188
|
+
|
|
189
|
+
Elements:
|
|
190
|
+
⊥ (bottom) = {}
|
|
191
|
+
s1 = {a}
|
|
192
|
+
s2 = {b}
|
|
193
|
+
s3 = {c}
|
|
194
|
+
s12 = {a, b}
|
|
195
|
+
s23 = {b, c}
|
|
196
|
+
⊤ (top) = {a, b, c}
|
|
197
|
+
|
|
198
|
+
Note: In the paper's figure, s1 ⊔ s3 = ⊤ because the only common
|
|
199
|
+
upper bound is ⊤. This is exactly what DeltaSetLattice does.
|
|
200
|
+
"""
|
|
201
|
+
lattice = DeltaSetLattice()
|
|
202
|
+
bottom: frozenset = lattice.bottom()
|
|
203
|
+
a, b, c = "a", "b", "c"
|
|
204
|
+
s1 = frozenset([a])
|
|
205
|
+
s2 = frozenset([b])
|
|
206
|
+
s3 = frozenset([c])
|
|
207
|
+
s12 = lattice.join(s1, s2)
|
|
208
|
+
s23 = lattice.join(s2, s3)
|
|
209
|
+
top = lattice.join(lattice.join(s1, s2), s3)
|
|
210
|
+
|
|
211
|
+
elements = [bottom, s1, s2, s3, s12, s23, top]
|
|
212
|
+
return lattice, elements
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""AetherMesh — Gossip module (Pillar 2)."""
|
|
2
|
+
|
|
3
|
+
from aethermesh.gossip.protocol import (
|
|
4
|
+
GossipMessage,
|
|
5
|
+
GossipProtocol,
|
|
6
|
+
GossipStep,
|
|
7
|
+
ExchangeResult,
|
|
8
|
+
)
|
|
9
|
+
from aethermesh.gossip.peer_sampling import PeerSampler
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"GossipMessage",
|
|
13
|
+
"GossipProtocol",
|
|
14
|
+
"GossipStep",
|
|
15
|
+
"ExchangeResult",
|
|
16
|
+
"PeerSampler",
|
|
17
|
+
]
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AetherMesh — Peer sampling for gossip (Pillar 2, Section 3.2.2).
|
|
3
|
+
|
|
4
|
+
Each gossip period, a node selects k peers (the fanout) uniformly at
|
|
5
|
+
random from its current peer sample. In a large network, nodes cannot
|
|
6
|
+
hold the full membership list, so peer selection draws from a
|
|
7
|
+
continuously refreshed partial view.
|
|
8
|
+
|
|
9
|
+
This module provides:
|
|
10
|
+
|
|
11
|
+
- PeerSampler: maintains a bounded partial view of the peer set
|
|
12
|
+
and samples k peers uniformly at random.
|
|
13
|
+
- ViewStatus: a small enum for the sampler's state.
|
|
14
|
+
|
|
15
|
+
Reference:
|
|
16
|
+
Baladi, S. (2026). AetherMesh v1.0.0. Section 3.2.2.
|
|
17
|
+
Zenodo. https://doi.org/10.5281/zenodo.24038038
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import random
|
|
23
|
+
from collections import deque
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from enum import Enum
|
|
26
|
+
from typing import Deque, Dict, Iterable, List, Optional, Set
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# =============================================================================
|
|
30
|
+
# ViewStatus
|
|
31
|
+
# =============================================================================
|
|
32
|
+
|
|
33
|
+
class ViewStatus(str, Enum):
|
|
34
|
+
"""Status of a peer sampler's view."""
|
|
35
|
+
EMPTY = "empty"
|
|
36
|
+
PARTIAL = "partial" # has some peers, fewer than capacity
|
|
37
|
+
FULL = "full" # at capacity
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# =============================================================================
|
|
41
|
+
# PeerSampler
|
|
42
|
+
# =============================================================================
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class PeerSampler:
|
|
46
|
+
"""
|
|
47
|
+
Maintains a bounded partial view of the peer set and samples k
|
|
48
|
+
peers uniformly at random (Section 3.2.2 of the paper).
|
|
49
|
+
|
|
50
|
+
The sampler enforces:
|
|
51
|
+
|
|
52
|
+
- Bounded capacity (O(capacity) memory).
|
|
53
|
+
- Uniform random selection (assumption A2).
|
|
54
|
+
- Continuous refresh via `add_peer` and `refresh`.
|
|
55
|
+
|
|
56
|
+
Attributes:
|
|
57
|
+
capacity: Maximum number of peers retained in the view.
|
|
58
|
+
view: Current set of peer identifiers.
|
|
59
|
+
_rng: Random source (seeded for reproducibility if provided).
|
|
60
|
+
"""
|
|
61
|
+
capacity: int = 32
|
|
62
|
+
view: Set[str] = field(default_factory=set)
|
|
63
|
+
_rng: Optional[random.Random] = field(default=None, repr=False)
|
|
64
|
+
|
|
65
|
+
def __post_init__(self):
|
|
66
|
+
if self._rng is None:
|
|
67
|
+
self._rng = random.Random()
|
|
68
|
+
|
|
69
|
+
# -------------------------------------------------------------------------
|
|
70
|
+
# View management
|
|
71
|
+
# -------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def add_peer(self, peer_id: str) -> bool:
|
|
74
|
+
"""
|
|
75
|
+
Add a peer to the view.
|
|
76
|
+
|
|
77
|
+
If the view is at capacity, a peer is evicted uniformly at random
|
|
78
|
+
to make room. This implements a simple uniform sampling policy
|
|
79
|
+
(see Jelasity et al., 2005 for more sophisticated alternatives).
|
|
80
|
+
|
|
81
|
+
Returns True if the view changed.
|
|
82
|
+
"""
|
|
83
|
+
if peer_id in self.view:
|
|
84
|
+
return False
|
|
85
|
+
if len(self.view) >= self.capacity:
|
|
86
|
+
evict = self._rng.choice(sorted(self.view))
|
|
87
|
+
self.view.discard(evict)
|
|
88
|
+
self.view.add(peer_id)
|
|
89
|
+
return True
|
|
90
|
+
|
|
91
|
+
def add_peers(self, peer_ids: Iterable[str]) -> int:
|
|
92
|
+
"""Add multiple peers. Returns the number of changes."""
|
|
93
|
+
return sum(1 for pid in peer_ids if self.add_peer(pid))
|
|
94
|
+
|
|
95
|
+
def remove_peer(self, peer_id: str) -> bool:
|
|
96
|
+
"""Remove a peer. Returns True if it was present."""
|
|
97
|
+
if peer_id in self.view:
|
|
98
|
+
self.view.discard(peer_id)
|
|
99
|
+
return True
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
def refresh(self, new_peers: Iterable[str]) -> None:
|
|
103
|
+
"""
|
|
104
|
+
Merge new peers into the view and drop some old ones to keep
|
|
105
|
+
the view fresh.
|
|
106
|
+
|
|
107
|
+
Simple policy: add all new peers (each may evict a random one),
|
|
108
|
+
then optionally drop up to 25% of the remaining to avoid staleness.
|
|
109
|
+
"""
|
|
110
|
+
self.add_peers(new_peers)
|
|
111
|
+
|
|
112
|
+
# -------------------------------------------------------------------------
|
|
113
|
+
# Sampling
|
|
114
|
+
# -------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def sample(self, k: int) -> List[str]:
|
|
117
|
+
"""
|
|
118
|
+
Select up to k peers uniformly at random from the view.
|
|
119
|
+
|
|
120
|
+
Uniform selection is what the dissemination analysis assumes
|
|
121
|
+
(assumption A2). Biased selection may violate the analysis.
|
|
122
|
+
"""
|
|
123
|
+
k = min(k, len(self.view))
|
|
124
|
+
if k == 0:
|
|
125
|
+
return []
|
|
126
|
+
return self._rng.sample(sorted(self.view), k)
|
|
127
|
+
|
|
128
|
+
# -------------------------------------------------------------------------
|
|
129
|
+
# Introspection
|
|
130
|
+
# -------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
def status(self) -> ViewStatus:
|
|
133
|
+
if len(self.view) == 0:
|
|
134
|
+
return ViewStatus.EMPTY
|
|
135
|
+
if len(self.view) < self.capacity:
|
|
136
|
+
return ViewStatus.PARTIAL
|
|
137
|
+
return ViewStatus.FULL
|
|
138
|
+
|
|
139
|
+
def __len__(self) -> int:
|
|
140
|
+
return len(self.view)
|
|
141
|
+
|
|
142
|
+
def __contains__(self, peer_id: str) -> bool:
|
|
143
|
+
return peer_id in self.view
|
|
144
|
+
|
|
145
|
+
def peers(self) -> Set[str]:
|
|
146
|
+
"""Return a copy of the current view."""
|
|
147
|
+
return set(self.view)
|
|
148
|
+
|
|
149
|
+
def clear(self) -> None:
|
|
150
|
+
self.view.clear()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# =============================================================================
|
|
154
|
+
# Convenience: build a sampler pre-populated with peers
|
|
155
|
+
# =============================================================================
|
|
156
|
+
|
|
157
|
+
def build_sampler(
|
|
158
|
+
peer_ids: Iterable[str],
|
|
159
|
+
capacity: int = 32,
|
|
160
|
+
seed: Optional[int] = None,
|
|
161
|
+
) -> PeerSampler:
|
|
162
|
+
"""
|
|
163
|
+
Build a PeerSampler pre-populated with the given peer ids.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
peer_ids: Identifiers to seed the view with.
|
|
167
|
+
capacity: Maximum view size.
|
|
168
|
+
seed: Optional RNG seed for reproducibility.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
A PeerSampler instance.
|
|
172
|
+
"""
|
|
173
|
+
sampler = PeerSampler(
|
|
174
|
+
capacity=capacity,
|
|
175
|
+
_rng=random.Random(seed),
|
|
176
|
+
)
|
|
177
|
+
sampler.add_peers(peer_ids)
|
|
178
|
+
return sampler
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AetherMesh — Epidemic gossip protocol (Pillar 2).
|
|
3
|
+
|
|
4
|
+
Implements the five-step anti-entropy exchange from Figure 5 of the
|
|
5
|
+
paper (Section 3.2.1):
|
|
6
|
+
|
|
7
|
+
Step 1: DIGEST(A) — A sends its version summary
|
|
8
|
+
Step 2: DIGEST(B) + missing-list — B replies with digest + deltas A lacks
|
|
9
|
+
Step 3: DELTA-REQUEST(ids) — A requests the deltas (or pushes if B lacks)
|
|
10
|
+
Step 4: DELTA-BATCH — B sends requested deltas, each signed
|
|
11
|
+
Step 5: local verification + join — A admits and joins each delta
|
|
12
|
+
|
|
13
|
+
The exchange is symmetric: the same five steps apply in the reverse
|
|
14
|
+
direction, so each side ends up with the deltas the other had.
|
|
15
|
+
|
|
16
|
+
Reference:
|
|
17
|
+
Baladi, S. (2026). AetherMesh v1.0.0. Section 3.2.
|
|
18
|
+
Zenodo. https://doi.org/10.5281/zenodo.24038038
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import random
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from enum import Enum
|
|
26
|
+
from typing import Callable, Dict, FrozenSet, List, Optional
|
|
27
|
+
|
|
28
|
+
from aethermesh.crdt.delta_state import DeltaStateCRDT
|
|
29
|
+
from aethermesh.models import AdmissionVerdict, Delta
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# =============================================================================
|
|
33
|
+
# Enums and message types
|
|
34
|
+
# =============================================================================
|
|
35
|
+
|
|
36
|
+
class GossipStep(str, Enum):
|
|
37
|
+
"""The five anti-entropy exchange steps (Figure 5 of the paper)."""
|
|
38
|
+
INITIATOR_DIGEST = "1_initiator_digest"
|
|
39
|
+
RESPONDER_DIGEST_AND_DIFF = "2_responder_digest_and_diff"
|
|
40
|
+
DELTA_REQUEST = "3_delta_request"
|
|
41
|
+
DELTA_BATCH = "4_delta_batch"
|
|
42
|
+
VERIFY_AND_JOIN = "5_verify_and_join"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class GossipMessage:
|
|
47
|
+
"""
|
|
48
|
+
A single gossip message in the exchange.
|
|
49
|
+
|
|
50
|
+
Attributes:
|
|
51
|
+
step: Which step this message belongs to.
|
|
52
|
+
sender: Origin agent identifier.
|
|
53
|
+
payload: Message-specific data (digest, delta ids, or deltas).
|
|
54
|
+
"""
|
|
55
|
+
step: GossipStep
|
|
56
|
+
sender: str
|
|
57
|
+
payload: Dict
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class ExchangeResult:
|
|
62
|
+
"""
|
|
63
|
+
Outcome of one complete anti-entropy exchange between two peers.
|
|
64
|
+
|
|
65
|
+
Attributes:
|
|
66
|
+
peer_a: Agent id of the initiating peer.
|
|
67
|
+
peer_b: Agent id of the responding peer.
|
|
68
|
+
deltas_a_to_b: Deltas A sent to B (B was missing).
|
|
69
|
+
deltas_b_to_a: Deltas B sent to A (A was missing).
|
|
70
|
+
admitted_at_a: Verdicts for deltas A received.
|
|
71
|
+
admitted_at_b: Verdicts for deltas B received.
|
|
72
|
+
bytes_sent: Approximate payload size (informational).
|
|
73
|
+
steps_completed: Number of steps that completed successfully.
|
|
74
|
+
"""
|
|
75
|
+
peer_a: str
|
|
76
|
+
peer_b: str
|
|
77
|
+
deltas_a_to_b: int = 0
|
|
78
|
+
deltas_b_to_a: int = 0
|
|
79
|
+
admitted_at_a: List[AdmissionVerdict] = field(default_factory=list)
|
|
80
|
+
admitted_at_b: List[AdmissionVerdict] = field(default_factory=list)
|
|
81
|
+
bytes_sent: int = 0
|
|
82
|
+
steps_completed: int = 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# =============================================================================
|
|
86
|
+
# Admission function type
|
|
87
|
+
# =============================================================================
|
|
88
|
+
|
|
89
|
+
AdmissionFn = Callable[[Delta, DeltaStateCRDT], AdmissionVerdict]
|
|
90
|
+
"""A function that takes a delta and a local CRDT and returns a verdict."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _default_admit(delta: Delta, crdt: DeltaStateCRDT) -> AdmissionVerdict:
|
|
94
|
+
"""Default admission: accept everything (used for pure gossip tests)."""
|
|
95
|
+
from aethermesh.models import Verdict
|
|
96
|
+
return AdmissionVerdict(
|
|
97
|
+
delta_id=delta.id,
|
|
98
|
+
verdict=Verdict.ACCEPT,
|
|
99
|
+
reason="default admit (no BFT layer attached)",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# =============================================================================
|
|
104
|
+
# GossipProtocol
|
|
105
|
+
# =============================================================================
|
|
106
|
+
|
|
107
|
+
class GossipProtocol:
|
|
108
|
+
"""
|
|
109
|
+
Runs the five-step anti-entropy exchange between two peers.
|
|
110
|
+
|
|
111
|
+
The protocol is stateless with respect to the peers themselves;
|
|
112
|
+
the caller is responsible for providing the two DeltaStateCRDT
|
|
113
|
+
instances and (optionally) an admission function.
|
|
114
|
+
|
|
115
|
+
If an admission function is not provided, all deltas are admitted
|
|
116
|
+
(which is useful for testing pure gossip convergence without BFT).
|
|
117
|
+
|
|
118
|
+
Reference: Section 3.2 of the paper.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(
|
|
122
|
+
self,
|
|
123
|
+
admit_fn: Optional[AdmissionFn] = None,
|
|
124
|
+
digest_size_bytes: int = 32, # informational only
|
|
125
|
+
):
|
|
126
|
+
self.admit_fn = admit_fn or _default_admit
|
|
127
|
+
self.digest_size_bytes = digest_size_bytes
|
|
128
|
+
|
|
129
|
+
# -------------------------------------------------------------------------
|
|
130
|
+
# The 5-step exchange
|
|
131
|
+
# -------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def exchange(
|
|
134
|
+
self,
|
|
135
|
+
crdt_a: DeltaStateCRDT,
|
|
136
|
+
crdt_b: DeltaStateCRDT,
|
|
137
|
+
) -> ExchangeResult:
|
|
138
|
+
"""
|
|
139
|
+
Run one complete anti-entropy exchange between peers A and B.
|
|
140
|
+
|
|
141
|
+
The exchange is symmetric: after the exchange, both peers hold
|
|
142
|
+
the union of their previously admitted deltas (subject to the
|
|
143
|
+
admission policy).
|
|
144
|
+
|
|
145
|
+
Returns an ExchangeResult summarizing what was exchanged.
|
|
146
|
+
"""
|
|
147
|
+
result = ExchangeResult(
|
|
148
|
+
peer_a=crdt_a.replica.origin,
|
|
149
|
+
peer_b=crdt_b.replica.origin,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# --- Step 1: initiator digest (A -> B) ---
|
|
153
|
+
digest_a: FrozenSet[str] = crdt_a.digest()
|
|
154
|
+
result.bytes_sent += len(digest_a) * self.digest_size_bytes
|
|
155
|
+
result.steps_completed += 1
|
|
156
|
+
|
|
157
|
+
# --- Step 2: responder digest + difference list (B -> A) ---
|
|
158
|
+
digest_b: FrozenSet[str] = crdt_b.digest()
|
|
159
|
+
missing_at_a_ids: FrozenSet[str] = digest_b - digest_a
|
|
160
|
+
missing_at_b_ids: FrozenSet[str] = digest_a - digest_b
|
|
161
|
+
result.bytes_sent += len(digest_b) * self.digest_size_bytes
|
|
162
|
+
result.steps_completed += 1
|
|
163
|
+
|
|
164
|
+
# --- Step 3: delta request (A -> B for missing at A; B -> A for missing at B) ---
|
|
165
|
+
# In practice, both directions are requested simultaneously.
|
|
166
|
+
# We serialize the exchange for clarity.
|
|
167
|
+
result.steps_completed += 1
|
|
168
|
+
|
|
169
|
+
# --- Step 4: delta batch (B -> A) ---
|
|
170
|
+
deltas_for_a: List[Delta] = [
|
|
171
|
+
crdt_b.replica.admitted[did]
|
|
172
|
+
for did in missing_at_a_ids
|
|
173
|
+
]
|
|
174
|
+
# And (A -> B)
|
|
175
|
+
deltas_for_b: List[Delta] = [
|
|
176
|
+
crdt_a.replica.admitted[did]
|
|
177
|
+
for did in missing_at_b_ids
|
|
178
|
+
]
|
|
179
|
+
result.bytes_sent += _estimate_bytes(deltas_for_a) + _estimate_bytes(deltas_for_b)
|
|
180
|
+
result.deltas_b_to_a = len(deltas_for_a)
|
|
181
|
+
result.deltas_a_to_b = len(deltas_for_b)
|
|
182
|
+
result.steps_completed += 1
|
|
183
|
+
|
|
184
|
+
# --- Step 5a: A receives deltas from B, verifies, joins ---
|
|
185
|
+
for delta in deltas_for_a:
|
|
186
|
+
verdict = self.admit_fn(delta, crdt_a)
|
|
187
|
+
result.admitted_at_a.append(verdict)
|
|
188
|
+
if verdict.accepted:
|
|
189
|
+
crdt_a.apply(delta)
|
|
190
|
+
|
|
191
|
+
# --- Step 5b: B receives deltas from A, verifies, joins ---
|
|
192
|
+
for delta in deltas_for_b:
|
|
193
|
+
verdict = self.admit_fn(delta, crdt_b)
|
|
194
|
+
result.admitted_at_b.append(verdict)
|
|
195
|
+
if verdict.accepted:
|
|
196
|
+
crdt_b.apply(delta)
|
|
197
|
+
|
|
198
|
+
result.steps_completed += 1
|
|
199
|
+
|
|
200
|
+
return result
|
|
201
|
+
|
|
202
|
+
# -------------------------------------------------------------------------
|
|
203
|
+
# Multi-peer round
|
|
204
|
+
# -------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
def gossip_round(
|
|
207
|
+
self,
|
|
208
|
+
crdt: DeltaStateCRDT,
|
|
209
|
+
peers: Dict[str, DeltaStateCRDT],
|
|
210
|
+
fanout: int,
|
|
211
|
+
rng: Optional[random.Random] = None,
|
|
212
|
+
) -> List[ExchangeResult]:
|
|
213
|
+
"""
|
|
214
|
+
Run one gossip round from `crdt` to `fanout` random peers.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
crdt: The local replica initiating the round.
|
|
218
|
+
peers: Map from peer id -> peer's DeltaStateCRDT.
|
|
219
|
+
fanout: Number of peers to contact (k in the paper).
|
|
220
|
+
rng: Optional random.Random for reproducibility.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
List of ExchangeResult (one per contacted peer).
|
|
224
|
+
"""
|
|
225
|
+
if fanout <= 0 or not peers:
|
|
226
|
+
return []
|
|
227
|
+
|
|
228
|
+
rng = rng or random.Random()
|
|
229
|
+
peer_ids = sorted(peers.keys())
|
|
230
|
+
k = min(fanout, len(peer_ids))
|
|
231
|
+
selected = rng.sample(peer_ids, k)
|
|
232
|
+
|
|
233
|
+
results = []
|
|
234
|
+
for peer_id in selected:
|
|
235
|
+
result = self.exchange(crdt, peers[peer_id])
|
|
236
|
+
results.append(result)
|
|
237
|
+
return results
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# =============================================================================
|
|
241
|
+
# Helpers
|
|
242
|
+
# =============================================================================
|
|
243
|
+
|
|
244
|
+
def _estimate_bytes(deltas: List[Delta]) -> int:
|
|
245
|
+
"""
|
|
246
|
+
Rough estimate of serialized delta size (informational only).
|
|
247
|
+
|
|
248
|
+
Actual serialization is done by `models.serialize_delta`, but for
|
|
249
|
+
the exchange-result accounting we use a simple heuristic:
|
|
250
|
+
~32 bytes for the hash + ~64 bytes for the body + overhead.
|
|
251
|
+
"""
|
|
252
|
+
return len(deltas) * 96
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def build_exchange_trace(result: ExchangeResult) -> List[GossipMessage]:
|
|
256
|
+
"""
|
|
257
|
+
Reconstruct an ordered message trace from an ExchangeResult.
|
|
258
|
+
|
|
259
|
+
Useful for logging and for testing. The trace is a simplified
|
|
260
|
+
reconstruction — it does not carry the actual delta payloads.
|
|
261
|
+
"""
|
|
262
|
+
trace: List[GossipMessage] = [
|
|
263
|
+
GossipMessage(
|
|
264
|
+
step=GossipStep.INITIATOR_DIGEST,
|
|
265
|
+
sender=result.peer_a,
|
|
266
|
+
payload={"digest_from": result.peer_a},
|
|
267
|
+
),
|
|
268
|
+
GossipMessage(
|
|
269
|
+
step=GossipStep.RESPONDER_DIGEST_AND_DIFF,
|
|
270
|
+
sender=result.peer_b,
|
|
271
|
+
payload={"digest_from": result.peer_b, "missing_at_a": result.deltas_b_to_a},
|
|
272
|
+
),
|
|
273
|
+
GossipMessage(
|
|
274
|
+
step=GossipStep.DELTA_REQUEST,
|
|
275
|
+
sender=result.peer_a,
|
|
276
|
+
payload={"requested": result.deltas_b_to_a},
|
|
277
|
+
),
|
|
278
|
+
GossipMessage(
|
|
279
|
+
step=GossipStep.DELTA_BATCH,
|
|
280
|
+
sender=result.peer_b,
|
|
281
|
+
payload={"count": result.deltas_b_to_a},
|
|
282
|
+
),
|
|
283
|
+
GossipMessage(
|
|
284
|
+
step=GossipStep.VERIFY_AND_JOIN,
|
|
285
|
+
sender=result.peer_a,
|
|
286
|
+
payload={"admitted": sum(1 for v in result.admitted_at_a if v.accepted)},
|
|
287
|
+
),
|
|
288
|
+
]
|
|
289
|
+
return trace
|