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/models.py ADDED
@@ -0,0 +1,395 @@
1
+ """
2
+ AetherMesh — Core data models.
3
+
4
+ This module defines the fundamental data classes used across the
5
+ AetherMesh reference implementation:
6
+
7
+ - Delta: A signed, content-addressed change record
8
+ - Replica: A local replica state (set of admitted deltas)
9
+ - PeerView: A local view of the peer set
10
+ - AdmissionVerdict: Outcome of the admission pipeline
11
+ - DeltaKind: Operation kind for semantic compatibility
12
+ - Signature: A cryptographic signature
13
+ - Verdict: Admission decision (accept / reject / defer / quarantine)
14
+
15
+ Reference:
16
+ Baladi, S. (2026). AetherMesh: Decentralized Peer-to-Peer Agent
17
+ Synchronization and State CRDTs for Autonomous Multi-Agent Systems.
18
+ Zenodo. https://doi.org/10.5281/zenodo.24038038
19
+
20
+ Definitions 1–4 (Section 4 of the paper).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+ import json
27
+ from dataclasses import dataclass, field
28
+ from datetime import datetime, timezone
29
+ from enum import Enum
30
+ from typing import Any, Dict, FrozenSet, List, Optional, Set, Tuple
31
+
32
+
33
+ # =============================================================================
34
+ # Enums
35
+ # =============================================================================
36
+
37
+ class Verdict(str, Enum):
38
+ """Admission verdict for a remote delta (Section 3.3.4 of the paper)."""
39
+ ACCEPT = "accept"
40
+ REJECT = "reject"
41
+ DEFER = "defer"
42
+ QUARANTINE = "quarantine"
43
+
44
+
45
+ class DeltaKind(str, Enum):
46
+ """
47
+ Operation kind for semantic compatibility matrix (Table 3.1 of the paper).
48
+ """
49
+ EDIT_BODY = "edit_body"
50
+ RENAME_SYMBOL = "rename_symbol"
51
+ DELETE_SYMBOL = "delete_symbol"
52
+ CHANGE_SIGNATURE = "change_signature"
53
+ EDIT_METADATA = "edit_metadata"
54
+
55
+
56
+ class AdmissionStage(str, Enum):
57
+ """The four admission pipeline stages (Figure 7 of the paper)."""
58
+ SIGNATURE = "signature"
59
+ DAG_INTEGRITY = "dag_integrity"
60
+ SCHEMA = "schema"
61
+ SEMANTICS = "semantics"
62
+
63
+
64
+ # =============================================================================
65
+ # Signature
66
+ # =============================================================================
67
+
68
+ @dataclass(frozen=True)
69
+ class Signature:
70
+ """
71
+ A cryptographic signature over a delta's content hash.
72
+
73
+ In the reference implementation we use Ed25519 (via `cryptography`),
74
+ but the architecture is agnostic to the specific scheme
75
+ (assumption A5).
76
+
77
+ Attributes:
78
+ origin: Agent identifier of the signer.
79
+ key_id: Identifier of the public key used.
80
+ value: Raw signature bytes (hex-encoded in serialization).
81
+ """
82
+ origin: str
83
+ key_id: str
84
+ value: bytes
85
+
86
+ def to_dict(self) -> Dict[str, Any]:
87
+ return {
88
+ "origin": self.origin,
89
+ "key_id": self.key_id,
90
+ "value": self.value.hex(),
91
+ }
92
+
93
+ @classmethod
94
+ def from_dict(cls, d: Dict[str, Any]) -> "Signature":
95
+ return cls(
96
+ origin=d["origin"],
97
+ key_id=d["key_id"],
98
+ value=bytes.fromhex(d["value"]),
99
+ )
100
+
101
+
102
+ # =============================================================================
103
+ # Delta
104
+ # =============================================================================
105
+
106
+ @dataclass(frozen=True)
107
+ class Delta:
108
+ """
109
+ A signed, content-addressed change record.
110
+
111
+ Definition 1 (Section 4.1 of the paper):
112
+ delta_i = (body, parents, timestamp, origin, kind, signature)
113
+
114
+ The delta's identifier is the hash of its canonical content
115
+ (excluding the signature, which is over that hash).
116
+
117
+ Attributes:
118
+ body: The change payload (opaque JSON-serializable dict).
119
+ parents: Identifiers of parent deltas (empty for genesis).
120
+ timestamp: ISO-8601 timestamp of creation.
121
+ origin: Agent identifier of the creator.
122
+ kind: Operation kind (for semantic compatibility).
123
+ signature: Signature by the origin over the content hash.
124
+ """
125
+ body: Dict[str, Any]
126
+ parents: FrozenSet[str]
127
+ timestamp: str
128
+ origin: str
129
+ kind: DeltaKind
130
+ signature: Optional[Signature] = None
131
+
132
+ @property
133
+ def id(self) -> str:
134
+ """
135
+ Content-addressed identifier: SHA-256 of the canonical body,
136
+ parents, timestamp, origin, and kind (excluding signature).
137
+ """
138
+ canonical = {
139
+ "body": self.body,
140
+ "parents": sorted(self.parents),
141
+ "timestamp": self.timestamp,
142
+ "origin": self.origin,
143
+ "kind": self.kind.value,
144
+ }
145
+ raw = json.dumps(canonical, sort_keys=True, separators=(",", ":")).encode("utf-8")
146
+ return hashlib.sha256(raw).hexdigest()
147
+
148
+ @property
149
+ def content_hash(self) -> bytes:
150
+ """Raw SHA-256 of the canonical content (for signature verification)."""
151
+ return bytes.fromhex(self.id)
152
+
153
+ def to_dict(self) -> Dict[str, Any]:
154
+ return {
155
+ "body": self.body,
156
+ "parents": sorted(self.parents),
157
+ "timestamp": self.timestamp,
158
+ "origin": self.origin,
159
+ "kind": self.kind.value,
160
+ "signature": self.signature.to_dict() if self.signature else None,
161
+ }
162
+
163
+ @classmethod
164
+ def from_dict(cls, d: Dict[str, Any]) -> "Delta":
165
+ return cls(
166
+ body=d["body"],
167
+ parents=frozenset(d["parents"]),
168
+ timestamp=d["timestamp"],
169
+ origin=d["origin"],
170
+ kind=DeltaKind(d["kind"]),
171
+ signature=Signature.from_dict(d["signature"]) if d.get("signature") else None,
172
+ )
173
+
174
+ @classmethod
175
+ def create(
176
+ cls,
177
+ body: Dict[str, Any],
178
+ origin: str,
179
+ kind: DeltaKind,
180
+ parents: Optional[Set[str]] = None,
181
+ ) -> "Delta":
182
+ """Create a new unsigned delta with current timestamp."""
183
+ return cls(
184
+ body=body,
185
+ parents=frozenset(parents or []),
186
+ timestamp=datetime.now(timezone.utc).isoformat(),
187
+ origin=origin,
188
+ kind=kind,
189
+ signature=None,
190
+ )
191
+
192
+ def with_signature(self, signature: Signature) -> "Delta":
193
+ """Return a copy of this delta with the given signature attached."""
194
+ return Delta(
195
+ body=self.body,
196
+ parents=self.parents,
197
+ timestamp=self.timestamp,
198
+ origin=self.origin,
199
+ kind=self.kind,
200
+ signature=signature,
201
+ )
202
+
203
+
204
+ # =============================================================================
205
+ # AdmissionVerVerdict
206
+ # =============================================================================
207
+
208
+ @dataclass(frozen=True)
209
+ class AdmissionVerdict:
210
+ """
211
+ Outcome of the admission pipeline for a single delta.
212
+
213
+ Attributes:
214
+ delta_id: The identifier of the delta that was evaluated.
215
+ verdict: The final verdict (accept / reject / defer / quarantine).
216
+ stage: The stage at which a non-accept verdict was reached.
217
+ reason: Human-readable explanation.
218
+ """
219
+ delta_id: str
220
+ verdict: Verdict
221
+ stage: Optional[AdmissionStage] = None
222
+ reason: str = ""
223
+
224
+ @property
225
+ def accepted(self) -> bool:
226
+ return self.verdict == Verdict.ACCEPT
227
+
228
+ def to_dict(self) -> Dict[str, Any]:
229
+ return {
230
+ "delta_id": self.delta_id,
231
+ "verdict": self.verdict.value,
232
+ "stage": self.stage.value if self.stage else None,
233
+ "reason": self.reason,
234
+ }
235
+
236
+
237
+ # =============================================================================
238
+ # Replica
239
+ # =============================================================================
240
+
241
+ @dataclass
242
+ class Replica:
243
+ """
244
+ A local replica state — the set of admitted deltas (Definition 1).
245
+
246
+ The join of two replicas is simply set union (the grow-only set CRDT
247
+ lifted to deltas; Section 3.1.1 of the paper).
248
+
249
+ Attributes:
250
+ admitted: Map from delta id -> Delta (admitted deltas).
251
+ origin: Agent identifier of this replica's owner.
252
+ deferred: Deltas awaiting missing parents.
253
+ """
254
+ admitted: Dict[str, Delta] = field(default_factory=dict)
255
+ origin: str = "anonymous"
256
+ deferred: Dict[str, Delta] = field(default_factory=dict)
257
+
258
+ def join(self, delta: Delta) -> "Replica":
259
+ """
260
+ Join a single admitted delta into this replica.
261
+
262
+ Returns self (mutating in place) for chaining.
263
+ Idempotent: joining the same delta twice has no additional effect.
264
+ """
265
+ self.admitted[delta.id] = delta
266
+ return self
267
+
268
+ def join_many(self, deltas: List[Delta]) -> "Replica":
269
+ """Join multiple admitted deltas."""
270
+ for d in deltas:
271
+ self.join(d)
272
+ return self
273
+
274
+ def merge(self, other: "Replica") -> "Replica":
275
+ """
276
+ Merge another replica into this one (set union).
277
+
278
+ The join operation is commutative, associative, and idempotent
279
+ (Section 3.1.2 of the paper).
280
+ """
281
+ self.admitted.update(other.admitted)
282
+ return self
283
+
284
+ def __contains__(self, delta_id: str) -> bool:
285
+ return delta_id in self.admitted
286
+
287
+ def __len__(self) -> int:
288
+ return len(self.admitted)
289
+
290
+ def ids(self) -> FrozenSet[str]:
291
+ """The set of admitted delta identifiers."""
292
+ return frozenset(self.admitted.keys())
293
+
294
+ def has_parents(self, delta: Delta) -> bool:
295
+ """Check whether all parents of a delta are already admitted."""
296
+ return all(p in self.admitted for p in delta.parents)
297
+
298
+ def materialize(self) -> List[Delta]:
299
+ """
300
+ Deterministic linearization of the delta set (Section 3.1.1).
301
+
302
+ Topological order of the parent DAG, ties broken by delta id.
303
+ """
304
+ # Kahn's algorithm with deterministic tie-breaking by id.
305
+ in_degree: Dict[str, int] = {}
306
+ children: Dict[str, List[str]] = {}
307
+
308
+ for did, delta in self.admitted.items():
309
+ in_degree.setdefault(did, 0)
310
+ children.setdefault(did, [])
311
+ for parent in delta.parents:
312
+ if parent in self.admitted:
313
+ in_degree[did] += 1
314
+ children.setdefault(parent, []).append(did)
315
+
316
+ # Start with deltas that have zero in-degree among admitted parents.
317
+ ready = sorted([d for d, deg in in_degree.items() if deg == 0])
318
+ result: List[Delta] = []
319
+
320
+ while ready:
321
+ did = ready.pop(0)
322
+ result.append(self.admitted[did])
323
+ for child in sorted(children.get(did, [])):
324
+ in_degree[child] -= 1
325
+ if in_degree[child] == 0:
326
+ ready.append(child)
327
+ ready.sort()
328
+
329
+ return result
330
+
331
+
332
+ # =============================================================================
333
+ # PeerView
334
+ # =============================================================================
335
+
336
+ @dataclass
337
+ class PeerView:
338
+ """
339
+ A local, partial view of the peer set (Section 3.2.2).
340
+
341
+ Each node maintains a bounded sample of the total peer population,
342
+ refreshed continuously. Selection for gossip draws from this view.
343
+
344
+ Attributes:
345
+ peers: Current known peer identifiers.
346
+ capacity: Maximum number of peers to retain.
347
+ """
348
+ peers: Set[str] = field(default_factory=set)
349
+ capacity: int = 32
350
+
351
+ def add(self, peer_id: str) -> None:
352
+ """Add a peer to the view, evicting if over capacity (FIFO for simplicity)."""
353
+ if peer_id in self.peers:
354
+ return
355
+ if len(self.peers) >= self.capacity:
356
+ # Deterministic eviction: remove the lexicographically smallest.
357
+ # (In production, use random or least-recently-used.)
358
+ self.peers.discard(min(self.peers))
359
+ self.peers.add(peer_id)
360
+
361
+ def remove(self, peer_id: str) -> None:
362
+ self.peers.discard(peer_id)
363
+
364
+ def sample(self, k: int) -> List[str]:
365
+ """
366
+ Select up to k peers uniformly at random.
367
+
368
+ Uses Python's random.sample for uniform selection over the
369
+ current view (assumption A2).
370
+ """
371
+ import random
372
+ k = min(k, len(self.peers))
373
+ if k == 0:
374
+ return []
375
+ return random.sample(sorted(self.peers), k)
376
+
377
+ def __len__(self) -> int:
378
+ return len(self.peers)
379
+
380
+ def __contains__(self, peer_id: str) -> bool:
381
+ return peer_id in self.peers
382
+
383
+
384
+ # =============================================================================
385
+ # Serialization helpers
386
+ # =============================================================================
387
+
388
+ def serialize_delta(delta: Delta) -> str:
389
+ """Serialize a delta to canonical JSON (for network transmission)."""
390
+ return json.dumps(delta.to_dict(), sort_keys=True, separators=(",", ":"))
391
+
392
+
393
+ def deserialize_delta(s: str) -> Delta:
394
+ """Deserialize a delta from canonical JSON."""
395
+ return Delta.from_dict(json.loads(s))
aethermesh/py.typed ADDED
File without changes
@@ -0,0 +1,25 @@
1
+ """AetherMesh — Simulation module (P2P testbed)."""
2
+
3
+ from aethermesh.simulation.network import (
4
+ Network,
5
+ PartitionEvent,
6
+ PartitionSchedule,
7
+ )
8
+ from aethermesh.simulation.node import (
9
+ Node,
10
+ create_cluster,
11
+ )
12
+ from aethermesh.simulation.testbed import (
13
+ Testbed,
14
+ TestbedConfig,
15
+ )
16
+
17
+ __all__ = [
18
+ "Network",
19
+ "PartitionEvent",
20
+ "PartitionSchedule",
21
+ "Node",
22
+ "create_cluster",
23
+ "Testbed",
24
+ "TestbedConfig",
25
+ ]
@@ -0,0 +1,240 @@
1
+ """
2
+ AetherMesh — Simulated network model (fair-lossy channels).
3
+
4
+ Implements the fair-lossy channel model from Section 4.0 of the paper
5
+ (assumption A1):
6
+
7
+ A message sent infinitely often between two non-faulty nodes is
8
+ delivered infinitely often.
9
+
10
+ In the simulator, the network is a discrete event loop that delivers
11
+ messages between nodes with configurable:
12
+ - latency (uniform in [min_latency, max_latency])
13
+ - drop probability (0.0 = no drops; 1.0 = every message dropped)
14
+ - partition schedule (optional, list of (time, partition_set))
15
+
16
+ Reference:
17
+ Baladi, S. (2026). AetherMesh v1.0.0. Section 4.0, assumption A1.
18
+ Zenodo. https://doi.org/10.5281/zenodo.24038038
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import heapq
24
+ import random
25
+ from dataclasses import dataclass, field
26
+ from enum import Enum
27
+ from typing import Any, Callable, Dict, List, Optional, Set, Tuple
28
+
29
+
30
+ # =============================================================================
31
+ # Message
32
+ # =============================================================================
33
+
34
+ @dataclass(order=True)
35
+ class _ScheduledMessage:
36
+ """Internal: a message scheduled for delivery at a future time."""
37
+ delivery_time: float
38
+ sender: str = field(compare=False)
39
+ recipient: str = field(compare=False)
40
+ payload: Any = field(compare=False)
41
+
42
+
43
+ # =============================================================================
44
+ # Network
45
+ # =============================================================================
46
+
47
+ class Network:
48
+ """
49
+ A simulated fair-lossy network.
50
+
51
+ The network maintains a priority queue of scheduled messages,
52
+ ordered by delivery time. It also maintains a partition state:
53
+ a set of node ids that cannot communicate with the rest.
54
+
55
+ Attributes:
56
+ min_latency: Minimum one-way latency (seconds).
57
+ max_latency: Maximum one-way latency (seconds).
58
+ drop_probability: Probability that any given message is dropped.
59
+ rng: Random source (seeded for reproducibility).
60
+ """
61
+
62
+ def __init__(
63
+ self,
64
+ min_latency: float = 0.01,
65
+ max_latency: float = 0.05,
66
+ drop_probability: float = 0.0,
67
+ seed: Optional[int] = None,
68
+ ):
69
+ self.min_latency = min_latency
70
+ self.max_latency = max_latency
71
+ self.drop_probability = drop_probability
72
+ self.rng = random.Random(seed)
73
+
74
+ # Message queue: (delivery_time, _ScheduledMessage)
75
+ self._queue: List[_ScheduledMessage] = []
76
+
77
+ # Partition state: set of nodes currently partitioned from the rest
78
+ self._partitioned: Set[str] = set()
79
+
80
+ # Statistics
81
+ self.messages_sent = 0
82
+ self.messages_delivered = 0
83
+ self.messages_dropped = 0
84
+ self.messages_blocked_by_partition = 0
85
+
86
+ # -------------------------------------------------------------------------
87
+ # Sending
88
+ # -------------------------------------------------------------------------
89
+
90
+ def send(
91
+ self,
92
+ sender: str,
93
+ recipient: str,
94
+ payload: Any,
95
+ send_time: float,
96
+ ) -> bool:
97
+ """
98
+ Schedule a message for delivery. Returns True if it will be
99
+ delivered (subject to drop probability and partition state).
100
+
101
+ A message is dropped with probability `drop_probability`.
102
+ A message is blocked if sender and recipient are on opposite
103
+ sides of a partition.
104
+ """
105
+ self.messages_sent += 1
106
+
107
+ # Partition check.
108
+ if self._partition_blocks(sender, recipient):
109
+ self.messages_blocked_by_partition += 1
110
+ return False
111
+
112
+ # Drop check.
113
+ if self.rng.random() < self.drop_probability:
114
+ self.messages_dropped += 1
115
+ return False
116
+
117
+ # Schedule delivery.
118
+ latency = self.rng.uniform(self.min_latency, self.max_latency)
119
+ delivery_time = send_time + latency
120
+ msg = _ScheduledMessage(
121
+ delivery_time=delivery_time,
122
+ sender=sender,
123
+ recipient=recipient,
124
+ payload=payload,
125
+ )
126
+ heapq.heappush(self._queue, msg)
127
+ return True
128
+
129
+ # -------------------------------------------------------------------------
130
+ # Delivery
131
+ # -------------------------------------------------------------------------
132
+
133
+ def next_delivery_time(self) -> Optional[float]:
134
+ """The time of the next scheduled message, or None if empty."""
135
+ if not self._queue:
136
+ return None
137
+ return self._queue[0].delivery_time
138
+
139
+ def deliver_until(
140
+ self,
141
+ current_time: float,
142
+ on_delivery: Callable[[str, str, Any], None],
143
+ ) -> float:
144
+ """
145
+ Deliver all messages scheduled at or before `current_time`.
146
+
147
+ Calls `on_delivery(sender, recipient, payload)` for each.
148
+
149
+ Returns the time of the next scheduled message, or `current_time`
150
+ if the queue is empty.
151
+ """
152
+ while self._queue and self._queue[0].delivery_time <= current_time:
153
+ msg = heapq.heappop(self._queue)
154
+ self.messages_delivered += 1
155
+ on_delivery(msg.sender, msg.recipient, msg.payload)
156
+ return self.next_delivery_time() or current_time
157
+
158
+ # -------------------------------------------------------------------------
159
+ # Partitions
160
+ # -------------------------------------------------------------------------
161
+
162
+ def set_partition(self, partitioned_nodes: Set[str]) -> None:
163
+ """
164
+ Set the current partition: nodes in `partitioned_nodes` cannot
165
+ communicate with nodes outside it.
166
+ """
167
+ self._partitioned = set(partitioned_nodes)
168
+
169
+ def heal_partition(self) -> None:
170
+ """Clear the partition state."""
171
+ self._partitioned = set()
172
+
173
+ def _partition_blocks(self, sender: str, recipient: str) -> bool:
174
+ """Check if a partition blocks communication between two nodes."""
175
+ if not self._partitioned:
176
+ return False
177
+ sender_in = sender in self._partitioned
178
+ recipient_in = recipient in self._partitioned
179
+ return sender_in != recipient_in
180
+
181
+ # -------------------------------------------------------------------------
182
+ # Statistics
183
+ # -------------------------------------------------------------------------
184
+
185
+ def stats(self) -> Dict[str, int]:
186
+ return {
187
+ "messages_sent": self.messages_sent,
188
+ "messages_delivered": self.messages_delivered,
189
+ "messages_dropped": self.messages_dropped,
190
+ "messages_blocked_by_partition": self.messages_blocked_by_partition,
191
+ "messages_in_flight": len(self._queue),
192
+ }
193
+
194
+ def reset_stats(self) -> None:
195
+ self.messages_sent = 0
196
+ self.messages_delivered = 0
197
+ self.messages_dropped = 0
198
+ self.messages_blocked_by_partition = 0
199
+
200
+
201
+ # =============================================================================
202
+ # Partition schedules
203
+ # =============================================================================
204
+
205
+ @dataclass
206
+ class PartitionEvent:
207
+ """A scheduled partition event: at `time`, set `partitioned`."""
208
+ time: float
209
+ partitioned: Set[str] = field(default_factory=set)
210
+
211
+
212
+ class PartitionSchedule:
213
+ """
214
+ A schedule of partition events over simulated time.
215
+
216
+ Used by the testbed to model network partitions (Section 3.2.3 of
217
+ the paper: partition healing).
218
+
219
+ Example:
220
+ >>> schedule = PartitionSchedule([
221
+ ... PartitionEvent(time=1.0, partitioned={"n1", "n2"}),
222
+ ... PartitionEvent(time=2.0, partitioned=set()), # heal
223
+ ... ])
224
+ """
225
+
226
+ def __init__(self, events: List[PartitionEvent]):
227
+ self.events = sorted(events, key=lambda e: e.time)
228
+ self._applied_index = 0
229
+
230
+ def apply_until(self, current_time: float, network: Network) -> None:
231
+ """Apply all partition events with time <= current_time."""
232
+ while self._applied_index < len(self.events):
233
+ event = self.events[self._applied_index]
234
+ if event.time > current_time:
235
+ break
236
+ network.set_partition(event.partitioned)
237
+ self._applied_index += 1
238
+
239
+ def reset(self) -> None:
240
+ self._applied_index = 0