hypermind 0.11.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.
Files changed (181) hide show
  1. hmctl/__init__.py +7 -0
  2. hmctl/cli.py +947 -0
  3. hypermind/__init__.py +208 -0
  4. hypermind/_deprecations.py +108 -0
  5. hypermind/agent.py +2896 -0
  6. hypermind/agent_card.py +127 -0
  7. hypermind/api/__init__.py +32 -0
  8. hypermind/api/app.py +686 -0
  9. hypermind/capability.py +923 -0
  10. hypermind/consult.py +493 -0
  11. hypermind/consult_bias.py +112 -0
  12. hypermind/contract_net.py +219 -0
  13. hypermind/crypto/__init__.py +21 -0
  14. hypermind/crypto/cose_encrypt.py +126 -0
  15. hypermind/crypto/dkg.py +246 -0
  16. hypermind/crypto/domain.py +59 -0
  17. hypermind/crypto/frost.py +771 -0
  18. hypermind/crypto/hashing.py +38 -0
  19. hypermind/crypto/hlc.py +163 -0
  20. hypermind/crypto/hpke.py +277 -0
  21. hypermind/crypto/kms.py +196 -0
  22. hypermind/crypto/kms_backends/__init__.py +56 -0
  23. hypermind/crypto/kms_backends/aws.py +128 -0
  24. hypermind/crypto/kms_backends/azure.py +114 -0
  25. hypermind/crypto/kms_backends/gcp.py +97 -0
  26. hypermind/crypto/kms_backends/vault.py +120 -0
  27. hypermind/crypto/namespace_root.py +204 -0
  28. hypermind/crypto/pq.py +152 -0
  29. hypermind/crypto/room_epoch.py +97 -0
  30. hypermind/crypto/signing.py +245 -0
  31. hypermind/deliberation.py +355 -0
  32. hypermind/did_web.py +256 -0
  33. hypermind/dispute.py +858 -0
  34. hypermind/errors.py +218 -0
  35. hypermind/eval/__init__.py +49 -0
  36. hypermind/eval/calibration.py +307 -0
  37. hypermind/eval/comparison.py +251 -0
  38. hypermind/eval/replay.py +202 -0
  39. hypermind/eval/swarm_iq.py +718 -0
  40. hypermind/knowledge/__init__.py +17 -0
  41. hypermind/knowledge/citation_graph.py +141 -0
  42. hypermind/knowledge/correlated_agreement.py +167 -0
  43. hypermind/knowledge/embedding_registry.py +115 -0
  44. hypermind/knowledge/kernel_store.py +197 -0
  45. hypermind/knowledge/rekor.py +173 -0
  46. hypermind/knowledge/reputation.py +364 -0
  47. hypermind/knowledge/swarm_memory.py +523 -0
  48. hypermind/knowledge/transparency.py +409 -0
  49. hypermind/mcp/__init__.py +60 -0
  50. hypermind/mcp/bridge.py +151 -0
  51. hypermind/mcp/client.py +142 -0
  52. hypermind/mcp/provenance.py +214 -0
  53. hypermind/mcp/relata_tools.py +152 -0
  54. hypermind/mcp/server.py +248 -0
  55. hypermind/mcp/transport.py +206 -0
  56. hypermind/mind/__init__.py +150 -0
  57. hypermind/mind/active.py +234 -0
  58. hypermind/mind/belief.py +369 -0
  59. hypermind/mind/deliberator.py +625 -0
  60. hypermind/mind/diversity.py +144 -0
  61. hypermind/mind/evolve.py +184 -0
  62. hypermind/mind/federation.py +154 -0
  63. hypermind/mind/goals.py +117 -0
  64. hypermind/mind/integration.py +190 -0
  65. hypermind/mind/mediator.py +74 -0
  66. hypermind/mind/memory_store.py +129 -0
  67. hypermind/mind/policy.py +307 -0
  68. hypermind/mind/records.py +260 -0
  69. hypermind/mind/roles.py +190 -0
  70. hypermind/mind/sybil_guard.py +41 -0
  71. hypermind/mind/world_model.py +166 -0
  72. hypermind/namespace.py +515 -0
  73. hypermind/namespace_policy.py +254 -0
  74. hypermind/observability/__init__.py +25 -0
  75. hypermind/observability/asgi.py +214 -0
  76. hypermind/observability/cost.py +277 -0
  77. hypermind/observability/otel_export.py +200 -0
  78. hypermind/observability/recorder.py +344 -0
  79. hypermind/observability/swarm_trace.py +438 -0
  80. hypermind/pin_policy.py +116 -0
  81. hypermind/py.typed +0 -0
  82. hypermind/responders/__init__.py +87 -0
  83. hypermind/responders/anthropic.py +159 -0
  84. hypermind/responders/base.py +162 -0
  85. hypermind/responders/openai.py +199 -0
  86. hypermind/responders/router.py +254 -0
  87. hypermind/responders/structured.py +287 -0
  88. hypermind/responders/tools.py +444 -0
  89. hypermind/revocation.py +270 -0
  90. hypermind/rule_ids.py +135 -0
  91. hypermind/schemas/encrypted_statement.cddl +28 -0
  92. hypermind/schemas/namespace_accept.cddl +20 -0
  93. hypermind/schemas/namespace_create.cddl +25 -0
  94. hypermind/schemas/namespace_founding_attest.cddl +30 -0
  95. hypermind/schemas/namespace_invite.cddl +22 -0
  96. hypermind/schemas/wire-v0.1.cddl +73 -0
  97. hypermind/simlab/__init__.py +15 -0
  98. hypermind/simlab/_persona_registry.py +93 -0
  99. hypermind/simlab/_scenario_impl.py +2778 -0
  100. hypermind/simlab/app.py +2538 -0
  101. hypermind/simlab/backends/__init__.py +27 -0
  102. hypermind/simlab/backends/anchor.py +88 -0
  103. hypermind/simlab/backends/local.py +32 -0
  104. hypermind/simlab/backends/server.py +79 -0
  105. hypermind/simlab/cli_command.py +108 -0
  106. hypermind/simlab/config.py +106 -0
  107. hypermind/simlab/deployment.py +26 -0
  108. hypermind/simlab/knowledge.py +716 -0
  109. hypermind/simlab/learning.py +295 -0
  110. hypermind/simlab/modes/__init__.py +115 -0
  111. hypermind/simlab/modes/_eval_real_llm.py +373 -0
  112. hypermind/simlab/modes/aar.py +611 -0
  113. hypermind/simlab/modes/backtest.py +610 -0
  114. hypermind/simlab/modes/binary_forecast.py +69 -0
  115. hypermind/simlab/modes/conformance.py +1869 -0
  116. hypermind/simlab/modes/evaluation.py +2271 -0
  117. hypermind/simlab/modes/governance.py +648 -0
  118. hypermind/simlab/modes/redteam.py +534 -0
  119. hypermind/simlab/modes/tabletop.py +797 -0
  120. hypermind/simlab/modes/tournament.py +448 -0
  121. hypermind/simlab/modes/whatif.py +523 -0
  122. hypermind/simlab/namespace.py +125 -0
  123. hypermind/simlab/personas.py +477 -0
  124. hypermind/simlab/prompt_generator.py +172 -0
  125. hypermind/simlab/question_sets/geopolitics_q20.json +122 -0
  126. hypermind/simlab/question_sets/governance_q5.json +67 -0
  127. hypermind/simlab/question_sets/msft_outlook_q10.json +62 -0
  128. hypermind/simlab/question_sets/whatif_q3.json +38 -0
  129. hypermind/simlab/registry.py +325 -0
  130. hypermind/simlab/runner.py +239 -0
  131. hypermind/simlab/scenario.py +147 -0
  132. hypermind/simlab/swarms.py +180 -0
  133. hypermind/simlab/tool_handlers/__init__.py +4 -0
  134. hypermind/simlab/tool_handlers/alpha_vantage.py +39 -0
  135. hypermind/simlab/tool_handlers/brave.py +46 -0
  136. hypermind/simlab/tool_handlers/newsapi.py +50 -0
  137. hypermind/simlab/tool_handlers/openweather.py +46 -0
  138. hypermind/simlab/tool_handlers/pinecone.py +52 -0
  139. hypermind/simlab/tool_handlers/qdrant.py +57 -0
  140. hypermind/simlab/tool_handlers/query_knowledge_base.py +21 -0
  141. hypermind/simlab/tool_handlers/relata_recall.py +61 -0
  142. hypermind/simlab/tool_handlers/retrieve_document.py +21 -0
  143. hypermind/simlab/tool_handlers/serper.py +46 -0
  144. hypermind/simlab/tool_handlers/tavily.py +53 -0
  145. hypermind/simlab/tool_handlers/weaviate.py +65 -0
  146. hypermind/simlab/tool_handlers/wikipedia.py +64 -0
  147. hypermind/simlab/tool_handlers/wolfram.py +48 -0
  148. hypermind/simlab/tool_handlers/yahoo_finance.py +49 -0
  149. hypermind/simlab/tool_registry.py +568 -0
  150. hypermind/simlab/topic_adapter.py +338 -0
  151. hypermind/simlab/topic_questions.py +259 -0
  152. hypermind/storage/__init__.py +69 -0
  153. hypermind/storage/backend.py +71 -0
  154. hypermind/storage/relata.py +245 -0
  155. hypermind/storage/sqlite.py +258 -0
  156. hypermind/sync.py +191 -0
  157. hypermind/tal.py +175 -0
  158. hypermind/tasks.py +334 -0
  159. hypermind/testing.py +16 -0
  160. hypermind/tools/__init__.py +32 -0
  161. hypermind/tools/registry.py +61 -0
  162. hypermind/transport/__init__.py +22 -0
  163. hypermind/transport/anti_entropy.py +708 -0
  164. hypermind/transport/bus.py +206 -0
  165. hypermind/transport/knows_delta.py +204 -0
  166. hypermind/transport/libp2p.py +298 -0
  167. hypermind/transport/placement.py +58 -0
  168. hypermind/transport/tcp.py +797 -0
  169. hypermind/transport/tls_profile.py +417 -0
  170. hypermind/types.py +59 -0
  171. hypermind/uncertainty.py +222 -0
  172. hypermind/wire.py +897 -0
  173. hypermind/workflow/__init__.py +72 -0
  174. hypermind/workflow/engine.py +655 -0
  175. hypermind/workflow/plan.py +182 -0
  176. hypermind/workflow/repair.py +203 -0
  177. hypermind-0.11.0.dist-info/METADATA +524 -0
  178. hypermind-0.11.0.dist-info/RECORD +181 -0
  179. hypermind-0.11.0.dist-info/WHEEL +4 -0
  180. hypermind-0.11.0.dist-info/entry_points.txt +2 -0
  181. hypermind-0.11.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,708 @@
1
+ """Anti-entropy / set reconciliation.
2
+
3
+ After a network partition, two peers may hold disjoint subsets of the
4
+ sealed-claim universe. Naive O(n) full-set exchange is wasteful; a
5
+ Bloom filter sketch lets each peer compute the symmetric difference
6
+ in O(δ) where δ = |A ⊖ B|.
7
+
8
+ Algorithm (multi-round, WS-G v0.7):
9
+ Round 1 — Bloom diff:
10
+ Peer A computes ``B_A`` over its known kids, sends it to B with
11
+ ``|A|`` and chunk cursor 0.
12
+ Peer B walks its kids, picks the first 1024 not in ``B_A`` and
13
+ forwards them as the round-1 candidate set + an ``ack_set`` of
14
+ kids it already had (closes the false-positive gap).
15
+ A then drains the next 1024 of its own ``B_B``-misses; the loop
16
+ continues until both peers' cursors hit end-of-set.
17
+
18
+ Round 2 — explicit ack-set:
19
+ Each peer responds with kids it now has (after round-1 transfer).
20
+ A kid that appears in neither side's ack-set after round 1 is
21
+ a Bloom false-positive — ferried in round 2 directly.
22
+
23
+ Round 3 — cosigned (root, tree_size) agreement:
24
+ Each side computes the Merkle root of its full set; both root
25
+ + tree_size are exchanged + cosigned. A mismatch flags the
26
+ session as ``UNRESOLVED_PARTITION`` for the dispute FSM.
27
+
28
+ IBLT fallback:
29
+ When the round-1 Bloom diff exceeds ``IBLT_THRESHOLD`` items,
30
+ peers switch to an Invertible Bloom Lookup Table sketch
31
+ (parameters d=64 cells, k=4 hashes). IBLT supports symmetric
32
+ difference decoding in O(δ) communication WITHOUT iterating over
33
+ one peer's entire local set, which collapses huge δ exchanges.
34
+
35
+ This is BIP 330's PinSketch idea simplified, with resumability via
36
+ ``ae_session_id`` (closes spec/06-conformance.md gap #3). At ~1%
37
+ false-positive rate, the expected bandwidth is δ × (1 + ε) kids per
38
+ round.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import asyncio
44
+ import hashlib
45
+ import logging
46
+ import math
47
+ import secrets
48
+ import uuid
49
+ from collections import OrderedDict
50
+ from collections.abc import Awaitable, Callable, Iterable
51
+ from dataclasses import dataclass, field
52
+
53
+ logger = logging.getLogger(__name__)
54
+
55
+ # WS-G tunables.
56
+ AE_CHUNK_SIZE = 1024 # max items per round trip per direction.
57
+ IBLT_THRESHOLD = 256 # round-1 Bloom-diff size that triggers IBLT.
58
+ IBLT_DEFAULT_CELLS = 64
59
+ IBLT_DEFAULT_HASHES = 4
60
+
61
+
62
+ def _optimal_bloom_size(n: int, p: float = 0.01) -> tuple[int, int]:
63
+ """(m, k) minimising filter size for n items at false-positive rate p.
64
+
65
+ m = -n*ln(p) / (ln(2)^2); k = (m/n) * ln(2)
66
+ """
67
+ if n == 0:
68
+ return 64, 2
69
+ m = max(64, int(-n * math.log(p) / (math.log(2) ** 2)))
70
+ # round to multiple of 8 for byte-aligned storage
71
+ m = ((m + 7) // 8) * 8
72
+ k = max(1, round((m / n) * math.log(2)))
73
+ return m, k
74
+
75
+
76
+ @dataclass
77
+ class BloomFilter:
78
+ bits: bytearray
79
+ m: int # number of bits
80
+ k: int # number of hash functions
81
+
82
+ @classmethod
83
+ def empty(cls, n_expected: int, p: float = 0.01) -> BloomFilter:
84
+ m, k = _optimal_bloom_size(n_expected, p)
85
+ return cls(bits=bytearray(m // 8), m=m, k=k)
86
+
87
+ def add(self, item: bytes) -> None:
88
+ for h in self._hashes(item):
89
+ self.bits[h // 8] |= 1 << (h % 8)
90
+
91
+ def __contains__(self, item: bytes) -> bool:
92
+ return all(self.bits[h // 8] & (1 << (h % 8)) for h in self._hashes(item))
93
+
94
+ def _hashes(self, item: bytes) -> list[int]:
95
+ # Double-hashing trick: hash_i = (h1 + i * h2) mod m
96
+ digest = hashlib.sha256(item).digest()
97
+ h1 = int.from_bytes(digest[:8], "big")
98
+ h2 = int.from_bytes(digest[8:16], "big") | 1 # ensure odd
99
+ return [(h1 + i * h2) % self.m for i in range(self.k)]
100
+
101
+ def to_cbor(self) -> dict:
102
+ return {"bits": bytes(self.bits), "m": self.m, "k": self.k}
103
+
104
+ # Hard caps for `from_cbor` — defends against a malicious peer
105
+ # sending huge `m` (memory DoS) or huge `k` (CPU DoS via per-lookup
106
+ # hash count). 16 Mbit ≈ 2 MB / filter, 32 hashes is generous.
107
+ MAX_M_BITS = 16 * 1024 * 1024
108
+ MAX_K = 32
109
+
110
+ @classmethod
111
+ def from_cbor(cls, m: dict) -> BloomFilter:
112
+ m_val = int(m["m"])
113
+ k_val = int(m["k"])
114
+ if m_val <= 0 or k_val <= 0:
115
+ raise ValueError("bloom m and k must be positive")
116
+ if m_val > cls.MAX_M_BITS:
117
+ raise ValueError(f"bloom m too large: {m_val} > {cls.MAX_M_BITS}")
118
+ if k_val > cls.MAX_K:
119
+ raise ValueError(f"bloom k too large: {k_val} > {cls.MAX_K}")
120
+ # Strict type + length gate BEFORE allocation. CBOR allows `bits`
121
+ # to be an integer; bytearray(int) allocates that many zero bytes,
122
+ # so a 30-byte CBOR frame with bits=2**32 would trigger a multi-GB
123
+ # allocation before the length-mismatch check fires. Refuse
124
+ # anything that isn't a byte string of the exact length implied by
125
+ # m_val (closes self-heal Cycle-3 Finding #15).
126
+ raw_bits = m["bits"]
127
+ if not isinstance(raw_bits, (bytes, bytearray, memoryview)):
128
+ raise ValueError(
129
+ f"bloom bits field must be a byte string, got {type(raw_bits).__name__}"
130
+ )
131
+ expected_len = (m_val + 7) // 8
132
+ if len(raw_bits) != expected_len:
133
+ raise ValueError(
134
+ f"bloom bit-array length {len(raw_bits)} does not match m={m_val} "
135
+ f"(expected {expected_len} bytes)"
136
+ )
137
+ bits = bytearray(raw_bits)
138
+ return cls(bits=bits, m=m_val, k=k_val)
139
+
140
+
141
+ @dataclass
142
+ class AESession:
143
+ """Anti-entropy session state.
144
+
145
+ Resumability: ``session_id`` lets a peer continue a partial
146
+ exchange after a transient connection drop. ``cursor`` tracks how
147
+ many of THIS peer's diff items have already been emitted; the
148
+ multi-round driver in ``reconcile()`` uses it as the paging token.
149
+ ``round`` is the protocol round number (1=Bloom, 2=ack-set,
150
+ 3=root-cosign). ``acked`` accumulates kids acknowledged by the
151
+ remote across rounds. ``finished`` flips True after round 3
152
+ converges. TTL (15 min default) bounds how long state is kept.
153
+ """
154
+
155
+ session_id: bytes = field(default_factory=lambda: secrets.token_bytes(16))
156
+ cursor: int = 0
157
+ round: int = 1
158
+ acked: set[bytes] = field(default_factory=set)
159
+ finished: bool = False
160
+ ttl_ms: int = 15 * 60 * 1000
161
+
162
+ def advance_cursor(self, n: int) -> None:
163
+ self.cursor += n
164
+
165
+
166
+ def build_filter(items: Iterable[bytes], *, p: float = 0.01) -> BloomFilter:
167
+ items_list = list(items)
168
+ bf = BloomFilter.empty(len(items_list), p=p)
169
+ for it in items_list:
170
+ bf.add(it)
171
+ return bf
172
+
173
+
174
+ def diff(local: Iterable[bytes], remote_filter: BloomFilter) -> list[bytes]:
175
+ """Return items local has that remote_filter probably doesn't.
176
+
177
+ False-positives may keep some items the peer already has; the peer
178
+ will dedup on receive (kids are content-addressed).
179
+ """
180
+ return [item for item in local if item not in remote_filter]
181
+
182
+
183
+ # ---------------------------------------------------------------------------
184
+ # IBLT (Invertible Bloom Lookup Table) — symmetric-difference fallback.
185
+ # ---------------------------------------------------------------------------
186
+
187
+
188
+ @dataclass
189
+ class _IBLTCell:
190
+ count: int = 0
191
+ key_xor: bytes = b"\x00" * 32
192
+ hash_xor: int = 0 # SHA-256(key)[:8] folded as int
193
+
194
+ def add(self, key: bytes, *, sign: int = 1) -> None:
195
+ self.count += sign
196
+ self.key_xor = _xor_bytes(self.key_xor, key)
197
+ self.hash_xor ^= _key_hash(key)
198
+
199
+ def is_pure(self) -> bool:
200
+ """A cell with |count|==1 whose stored key hashes to its
201
+ recorded hash_xor is "pure" — the one key XOR-ed in is
202
+ recoverable directly."""
203
+ if abs(self.count) != 1:
204
+ return False
205
+ return _key_hash(self.key_xor) == self.hash_xor
206
+
207
+ def is_empty(self) -> bool:
208
+ return self.count == 0 and self.key_xor == b"\x00" * 32 and self.hash_xor == 0
209
+
210
+
211
+ def _xor_bytes(a: bytes, b: bytes) -> bytes:
212
+ if len(a) != len(b):
213
+ raise ValueError("xor length mismatch")
214
+ return bytes(x ^ y for x, y in zip(a, b, strict=False))
215
+
216
+
217
+ def _key_hash(key: bytes) -> int:
218
+ """Stable 64-bit hash of a key for IBLT pure-cell verification."""
219
+ return int.from_bytes(hashlib.sha256(b"iblt-keyhash" + key).digest()[:8], "big")
220
+
221
+
222
+ # DoS gate: a malicious peer cannot force an OOM by advertising a huge
223
+ # cell count. 8 KiB cells × 32 bytes/cell = 256 KiB maximum IBLT payload,
224
+ # well above the largest legitimate δ (IBLT_THRESHOLD × 4 = 1024 cells).
225
+ IBLT_MAX_CELLS = 8192
226
+
227
+
228
+ class IBLTOversizeError(ValueError):
229
+ """Raised by IBLT.from_cbor() when the cell count exceeds IBLT_MAX_CELLS."""
230
+
231
+
232
+ @dataclass
233
+ class IBLT:
234
+ """Invertible Bloom Lookup Table for set reconciliation.
235
+
236
+ Encodes a set of fixed-width keys (32 bytes; we use kids).
237
+ Symmetric difference of two IBLTs is just per-cell XOR; ``decode()``
238
+ extracts the differing keys via repeated peeling of pure cells.
239
+
240
+ Parameters chosen for δ ≈ 256: 64 cells × 4 hashes ≈ 4× overhead
241
+ above δ, comfortable peeling success at ~98%+. For larger δ, scale
242
+ cells linearly.
243
+ """
244
+
245
+ cells: list[_IBLTCell]
246
+ k: int # number of hash functions
247
+
248
+ @classmethod
249
+ def empty(cls, *, cells: int = IBLT_DEFAULT_CELLS, k: int = IBLT_DEFAULT_HASHES) -> IBLT:
250
+ if cells < k * 2:
251
+ raise ValueError("IBLT needs cells >= 2*k for peeling to converge")
252
+ return cls(cells=[_IBLTCell() for _ in range(cells)], k=k)
253
+
254
+ def _indices(self, key: bytes) -> list[int]:
255
+ digest = hashlib.sha256(b"iblt-idx" + key).digest()
256
+ h1 = int.from_bytes(digest[:8], "big")
257
+ h2 = int.from_bytes(digest[8:16], "big") | 1
258
+ n = len(self.cells)
259
+ # Use distinct indices to avoid wasting hashes — collisions
260
+ # within one key destroy peeling.
261
+ out: list[int] = []
262
+ seen: set[int] = set()
263
+ i = 0
264
+ while len(out) < self.k and i < self.k * 8:
265
+ idx = (h1 + i * h2) % n
266
+ if idx not in seen:
267
+ seen.add(idx)
268
+ out.append(idx)
269
+ i += 1
270
+ # Fall through if degenerate: pad with whatever we have (should
271
+ # not happen for n >= 2*k, but guards a malicious tiny IBLT).
272
+ while len(out) < self.k:
273
+ out.append(out[-1])
274
+ return out
275
+
276
+ def add(self, key: bytes) -> None:
277
+ if len(key) != 32:
278
+ raise ValueError("IBLT keys must be 32 bytes")
279
+ for idx in self._indices(key):
280
+ self.cells[idx].add(key, sign=1)
281
+
282
+ def subtract(self, other: IBLT) -> IBLT:
283
+ """Return ``self - other``: a new IBLT whose pure cells expose
284
+ keys present in exactly one of the two operands."""
285
+ if len(self.cells) != len(other.cells) or self.k != other.k:
286
+ raise ValueError("IBLT shape mismatch")
287
+ result = IBLT.empty(cells=len(self.cells), k=self.k)
288
+ for i, (a, b) in enumerate(zip(self.cells, other.cells, strict=False)):
289
+ result.cells[i].count = a.count - b.count
290
+ result.cells[i].key_xor = _xor_bytes(a.key_xor, b.key_xor)
291
+ result.cells[i].hash_xor = a.hash_xor ^ b.hash_xor
292
+ return result
293
+
294
+ def decode(self) -> tuple[set[bytes], set[bytes], bool]:
295
+ """Peel until stable. Returns (only_in_self, only_in_other, ok).
296
+
297
+ ``only_in_self`` — keys with positive count after peeling.
298
+ ``only_in_other`` — keys with negative count after peeling.
299
+ ``ok`` — True iff every cell is empty after peeling, i.e.
300
+ the symmetric difference was within the IBLT's capacity.
301
+ """
302
+ a_only: set[bytes] = set()
303
+ b_only: set[bytes] = set()
304
+ # Work on copies so the caller can retry / inspect.
305
+ cells = [_IBLTCell(c.count, c.key_xor, c.hash_xor) for c in self.cells]
306
+ progressed = True
307
+ while progressed:
308
+ progressed = False
309
+ for _i, cell in enumerate(cells):
310
+ if cell.is_pure():
311
+ key = cell.key_xor
312
+ sign = cell.count # ±1
313
+ if sign == 1:
314
+ a_only.add(key)
315
+ else:
316
+ b_only.add(key)
317
+ # Remove this key from all of its cells.
318
+ for idx in self._indices(key):
319
+ cells[idx].count -= sign
320
+ cells[idx].key_xor = _xor_bytes(cells[idx].key_xor, key)
321
+ cells[idx].hash_xor ^= _key_hash(key)
322
+ progressed = True
323
+ ok = all(c.is_empty() for c in cells)
324
+ return a_only, b_only, ok
325
+
326
+ def to_cbor(self) -> bytes:
327
+ """Serialise to canonical CBOR for wire transport."""
328
+ import cbor2
329
+
330
+ return cbor2.dumps(
331
+ {
332
+ "m": len(self.cells),
333
+ "k": self.k,
334
+ "cells": [
335
+ {"count": c.count, "key_xor": c.key_xor, "hash_xor": c.hash_xor}
336
+ for c in self.cells
337
+ ],
338
+ },
339
+ canonical=True,
340
+ )
341
+
342
+ @classmethod
343
+ def from_cbor(cls, data: bytes) -> IBLT:
344
+ """Deserialise from CBOR.
345
+
346
+ DoS gate: rejects ``m`` (cell count) above ``IBLT_MAX_CELLS`` and
347
+ ``k`` above ``BloomFilter.MAX_K`` before allocating any cell list,
348
+ closing the OOM amplification attack class.
349
+ """
350
+ import cbor2
351
+
352
+ try:
353
+ obj = cbor2.loads(data)
354
+ except Exception as e:
355
+ raise ValueError(f"IBLT CBOR decode failed: {e}") from e
356
+ if not isinstance(obj, dict):
357
+ raise ValueError("IBLT payload must be a CBOR map")
358
+ m = int(obj["m"])
359
+ k = int(obj["k"])
360
+ if m > IBLT_MAX_CELLS:
361
+ raise IBLTOversizeError(f"IBLT cell count {m} exceeds maximum {IBLT_MAX_CELLS}")
362
+ if m <= 0 or k <= 0:
363
+ raise ValueError("IBLT m and k must be positive")
364
+ if k > BloomFilter.MAX_K:
365
+ raise ValueError(f"IBLT k too large: {k} > {BloomFilter.MAX_K}")
366
+ raw_cells = obj.get("cells", [])
367
+ if not isinstance(raw_cells, list) or len(raw_cells) != m:
368
+ raise ValueError(
369
+ f"IBLT cells length {len(raw_cells) if isinstance(raw_cells, list) else '?'} "
370
+ f"does not match m={m}"
371
+ )
372
+ cells = []
373
+ for cell_dict in raw_cells:
374
+ kx = bytes(cell_dict["key_xor"])
375
+ if len(kx) != 32:
376
+ raise ValueError(f"IBLT cell key_xor must be 32 bytes, got {len(kx)}")
377
+ cells.append(
378
+ _IBLTCell(
379
+ count=int(cell_dict["count"]),
380
+ key_xor=kx,
381
+ hash_xor=int(cell_dict["hash_xor"]),
382
+ )
383
+ )
384
+ return cls(cells=cells, k=k)
385
+
386
+
387
+ def build_iblt(
388
+ items: Iterable[bytes],
389
+ *,
390
+ cells: int = IBLT_DEFAULT_CELLS,
391
+ k: int = IBLT_DEFAULT_HASHES,
392
+ ) -> IBLT:
393
+ table = IBLT.empty(cells=cells, k=k)
394
+ for it in items:
395
+ table.add(it)
396
+ return table
397
+
398
+
399
+ def iblt_diff(local: Iterable[bytes], remote: IBLT) -> tuple[set[bytes], set[bytes], bool]:
400
+ """Compute symmetric difference via IBLT subtraction + peeling.
401
+
402
+ ``cells`` and ``k`` of the two tables must match. Returns
403
+ ``(local_only, remote_only, ok)``; ``ok=False`` means the IBLT was
404
+ undersized and the caller should fall back to a larger sketch.
405
+ """
406
+ local_table = build_iblt(local, cells=len(remote.cells), k=remote.k)
407
+ return local_table.subtract(remote).decode()
408
+
409
+
410
+ # ---------------------------------------------------------------------------
411
+ # Multi-round reconciliation driver.
412
+ # ---------------------------------------------------------------------------
413
+
414
+
415
+ @dataclass
416
+ class ReconciliationResult:
417
+ """Outcome of a multi-round reconcile() exchange."""
418
+
419
+ rounds: int
420
+ local_only: set[bytes]
421
+ remote_only: set[bytes]
422
+ used_iblt: bool
423
+ converged: bool # True iff round-3 root agreement holds.
424
+
425
+
426
+ def _set_root(items: Iterable[bytes]) -> bytes:
427
+ """Stable Merkle-ish root of a set of fixed-width keys.
428
+
429
+ Used for the round-3 cosigned-root agreement check. We sort then
430
+ SHA-256 over the concatenation; that's enough for "did we agree
431
+ on the same set?" and avoids depending on the full transparency
432
+ tree at this layer.
433
+ """
434
+ return hashlib.sha256(b"".join(sorted(items))).digest()
435
+
436
+
437
+ def reconcile(
438
+ local: Iterable[bytes],
439
+ remote: Iterable[bytes],
440
+ *,
441
+ chunk_size: int = AE_CHUNK_SIZE,
442
+ iblt_threshold: int = IBLT_THRESHOLD,
443
+ max_rounds: int = 8,
444
+ ) -> ReconciliationResult:
445
+ """Run the full multi-round protocol against an in-process peer.
446
+
447
+ This driver is the byte-exact reference for the wire-side dance —
448
+ a real peer pair would split the body into round-by-round frames
449
+ over the gossip bus. Tests pin convergence at byte-equal sets
450
+ after a bounded number of rounds for δ ∈ {10, 100, 1000, 5000}.
451
+ """
452
+ local_set = set(local)
453
+ remote_set = set(remote)
454
+
455
+ rounds = 0
456
+ used_iblt = False
457
+
458
+ # ---- Round 1: Bloom diff (chunked) ----
459
+ rounds += 1
460
+ bloom_remote = build_filter(remote_set)
461
+ bloom_local = build_filter(local_set)
462
+
463
+ bloom_local_only = [k for k in local_set if k not in bloom_remote]
464
+ bloom_remote_only = [k for k in remote_set if k not in bloom_local]
465
+
466
+ estimated_diff = len(bloom_local_only) + len(bloom_remote_only)
467
+
468
+ if estimated_diff > iblt_threshold:
469
+ # Fallback to IBLT sized for the observed delta.
470
+ used_iblt = True
471
+ cells = max(IBLT_DEFAULT_CELLS, estimated_diff * 4)
472
+ iblt_remote = build_iblt(remote_set, cells=cells)
473
+ only_local, only_remote, ok = iblt_diff(local_set, iblt_remote)
474
+ if ok:
475
+ # IBLT decoded fully — we have an exact symmetric diff.
476
+ return ReconciliationResult(
477
+ rounds=rounds,
478
+ local_only=only_local,
479
+ remote_only=only_remote,
480
+ used_iblt=True,
481
+ converged=_set_root(local_set | only_remote) == _set_root(remote_set | only_local),
482
+ )
483
+ # Decoding failed — fall through to chunked Bloom rounds.
484
+
485
+ # Chunked round-1 transfer (cursor paging).
486
+ sess_local = AESession()
487
+ sess_remote = AESession()
488
+ transferred_l: list[bytes] = []
489
+ transferred_r: list[bytes] = []
490
+ while sess_local.cursor < len(bloom_local_only) or sess_remote.cursor < len(bloom_remote_only):
491
+ if rounds >= max_rounds:
492
+ break
493
+ chunk_l = bloom_local_only[sess_local.cursor : sess_local.cursor + chunk_size]
494
+ chunk_r = bloom_remote_only[sess_remote.cursor : sess_remote.cursor + chunk_size]
495
+ transferred_l.extend(chunk_l)
496
+ transferred_r.extend(chunk_r)
497
+ sess_local.advance_cursor(len(chunk_l))
498
+ sess_remote.advance_cursor(len(chunk_r))
499
+ rounds += 1
500
+
501
+ # ---- Round 2: explicit ack-set (false-positive close-out) ----
502
+ rounds += 1
503
+ # After round 1, each side has "received" the chunked transfers.
504
+ # In a real wire, each peer would reply with the kids it knows
505
+ # (i.e. sent back to confirm). Here we model that by computing
506
+ # the kids that are in the OTHER peer's set but were missed in
507
+ # round 1 due to Bloom false-positives.
508
+ fp_local = (remote_set - local_set) - set(transferred_r)
509
+ fp_remote = (local_set - remote_set) - set(transferred_l)
510
+ transferred_r_set = set(transferred_r) | fp_local
511
+ transferred_l_set = set(transferred_l) | fp_remote
512
+
513
+ # ---- Round 3: cosigned (root, tree_size) agreement ----
514
+ rounds += 1
515
+ merged_local = local_set | transferred_r_set
516
+ merged_remote = remote_set | transferred_l_set
517
+ converged = _set_root(merged_local) == _set_root(merged_remote) and len(merged_local) == len(
518
+ merged_remote
519
+ )
520
+
521
+ return ReconciliationResult(
522
+ rounds=rounds,
523
+ local_only=transferred_l_set,
524
+ remote_only=transferred_r_set,
525
+ used_iblt=used_iblt,
526
+ converged=converged,
527
+ )
528
+
529
+
530
+ # ---------------------------------------------------------------------------
531
+ # Resumable RANGE_REQ / RANGE_RESP wire handler.
532
+ # ---------------------------------------------------------------------------
533
+
534
+ # Bound on the number of sessions a single peer remembers cursors for.
535
+ # OrderedDict eviction keeps memory bounded if a malicious peer churns
536
+ # session_ids — closes the resumability gap from spec/06-conformance.md.
537
+ SESSION_TABLE_MAX = 256
538
+
539
+
540
+ def new_session_id() -> str:
541
+ """Fresh random session id (32-hex / 16 bytes)."""
542
+ return uuid.uuid4().hex
543
+
544
+
545
+ def make_range_req(items_total: int, *, session_id: str | None = None) -> dict:
546
+ """Build a RANGE_REQ message dict.
547
+
548
+ ``session_id`` may be reused across reconnects to resume from the
549
+ last cursor the responder recorded for that id.
550
+ """
551
+ return {
552
+ "kind": "RANGE_REQ",
553
+ "session_id": session_id or new_session_id(),
554
+ "items_total": int(items_total),
555
+ }
556
+
557
+
558
+ @dataclass
559
+ class RangeResponder:
560
+ """Per-peer state for resumable RANGE_REQ/RANGE_RESP exchanges.
561
+
562
+ The responder stores a bounded ``session_id -> cursor`` table. When
563
+ a RANGE_REQ arrives with a known session_id the next chunk starts
564
+ at the saved cursor; unknown ids start at 0. The table is evicted
565
+ in oldest-first order at ``SESSION_TABLE_MAX`` entries.
566
+ """
567
+
568
+ chunk_size: int = AE_CHUNK_SIZE
569
+ _sessions: OrderedDict[str, int] = field(default_factory=OrderedDict)
570
+
571
+ def _get_cursor(self, session_id: str) -> int:
572
+ if session_id in self._sessions:
573
+ self._sessions.move_to_end(session_id)
574
+ return self._sessions[session_id]
575
+ return 0
576
+
577
+ def _set_cursor(self, session_id: str, cursor: int) -> None:
578
+ if session_id in self._sessions:
579
+ self._sessions.move_to_end(session_id)
580
+ self._sessions[session_id] = cursor
581
+ return
582
+ if len(self._sessions) >= SESSION_TABLE_MAX:
583
+ self._sessions.popitem(last=False)
584
+ self._sessions[session_id] = cursor
585
+
586
+ def handle_range_req(self, req: dict, items: list[bytes]) -> dict:
587
+ """Produce a RANGE_RESP for ``req`` over the responder's
588
+ ``items`` list. Cursor is stored against the request's
589
+ ``session_id`` so a follow-up RANGE_REQ with the same id
590
+ resumes at the next slice."""
591
+ if req.get("kind") != "RANGE_REQ":
592
+ raise ValueError("expected RANGE_REQ message")
593
+ sid = req.get("session_id")
594
+ if not isinstance(sid, str) or not sid:
595
+ raise ValueError("RANGE_REQ.session_id must be a non-empty string")
596
+ start = self._get_cursor(sid)
597
+ end = min(start + self.chunk_size, len(items))
598
+ chunk = items[start:end]
599
+ self._set_cursor(sid, end)
600
+ return {
601
+ "kind": "RANGE_RESP",
602
+ "session_id": sid,
603
+ "cursor": end,
604
+ "chunk": list(chunk),
605
+ "done": end >= len(items),
606
+ }
607
+
608
+ def session_count(self) -> int:
609
+ return len(self._sessions)
610
+
611
+
612
+ # ---------------------------------------------------------------------------
613
+ # Self-healing AE background coroutine (P2-02, spec §10.2).
614
+ # ---------------------------------------------------------------------------
615
+
616
+ #: Default interval between AE rounds when all peers are healthy.
617
+ AE_LOOP_INTERVAL_S: float = 30.0
618
+ #: Maximum backoff cap for reconnect attempts.
619
+ AE_MAX_BACKOFF_S: float = 300.0
620
+ #: Multiplier applied on each failed reconnect.
621
+ AE_BACKOFF_FACTOR: float = 2.0
622
+
623
+
624
+ class BackgroundAELoop:
625
+ """Runs anti-entropy reconciliation on a repeating schedule.
626
+
627
+ On each tick it calls the provided ``run_ae`` coroutine which should
628
+ compare local state against all known peers and push/pull any missing
629
+ items. When ``run_ae`` raises an exception the loop applies
630
+ exponential back-off up to ``max_backoff_s``, then retries. On
631
+ success the interval resets to ``interval_s``.
632
+
633
+ Usage::
634
+
635
+ loop = BackgroundAELoop(run_ae=my_ae_coroutine)
636
+ loop.start()
637
+ ...
638
+ await loop.stop()
639
+ """
640
+
641
+ def __init__(
642
+ self,
643
+ *,
644
+ run_ae: Callable[[], Awaitable[None]],
645
+ interval_s: float = AE_LOOP_INTERVAL_S,
646
+ max_backoff_s: float = AE_MAX_BACKOFF_S,
647
+ backoff_factor: float = AE_BACKOFF_FACTOR,
648
+ ) -> None:
649
+ self._run_ae = run_ae
650
+ self._interval_s = interval_s
651
+ self._max_backoff_s = max_backoff_s
652
+ self._backoff_factor = backoff_factor
653
+ self._task: asyncio.Task | None = None
654
+ self._stop_event: asyncio.Event | None = None
655
+ self._consecutive_failures: int = 0
656
+
657
+ def start(self) -> None:
658
+ """Spawn the background task. Must be called from an async context."""
659
+ if self._task is not None and not self._task.done():
660
+ return
661
+ self._stop_event = asyncio.Event()
662
+ self._task = asyncio.create_task(self._loop(), name="ae-background-loop")
663
+
664
+ async def stop(self) -> None:
665
+ """Cancel and await the background task."""
666
+ if self._stop_event is not None:
667
+ self._stop_event.set()
668
+ if self._task is not None:
669
+ self._task.cancel()
670
+ try:
671
+ await self._task
672
+ except (asyncio.CancelledError, Exception):
673
+ pass
674
+ self._task = None
675
+
676
+ @property
677
+ def running(self) -> bool:
678
+ return self._task is not None and not self._task.done()
679
+
680
+ async def _loop(self) -> None:
681
+ assert self._stop_event is not None
682
+ current_delay = self._interval_s
683
+ while not self._stop_event.is_set():
684
+ try:
685
+ await asyncio.wait_for(self._stop_event.wait(), timeout=current_delay)
686
+ # Stop event was set — exit cleanly.
687
+ return
688
+ except TimeoutError:
689
+ pass
690
+ # Run one AE round.
691
+ try:
692
+ await self._run_ae()
693
+ self._consecutive_failures = 0
694
+ current_delay = self._interval_s
695
+ except asyncio.CancelledError:
696
+ return
697
+ except Exception as exc:
698
+ self._consecutive_failures += 1
699
+ current_delay = min(
700
+ self._max_backoff_s,
701
+ self._interval_s * (self._backoff_factor**self._consecutive_failures),
702
+ )
703
+ logger.warning(
704
+ "BackgroundAELoop: AE round failed (failure #%d), backing off %.1fs: %s",
705
+ self._consecutive_failures,
706
+ current_delay,
707
+ exc,
708
+ )