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.
@@ -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