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,71 @@
1
+ """StorageBackend Protocol — the contract every persistence layer satisfies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Protocol
6
+
7
+ if TYPE_CHECKING:
8
+ from hypermind.dispute import Dispute
9
+ from hypermind.wire import Kid, SignedStatement
10
+
11
+
12
+ class StorageBackend(Protocol):
13
+ """Durable storage for a HyperMind world.
14
+
15
+ Every method is synchronous — backends should use threadlocal
16
+ connections and short transactions. Async wrapping happens at the
17
+ caller (``_NamespaceWorld``) when needed.
18
+
19
+ Implementations MUST be safe to call from multiple coroutines on a
20
+ single event loop. They are NOT required to be safe under multiple
21
+ OS threads, multiple processes, or concurrent writers.
22
+ """
23
+
24
+ def open(self) -> None:
25
+ """Connect, create schema if missing, prepare for I/O."""
26
+
27
+ def close(self) -> None:
28
+ """Flush and disconnect. Idempotent."""
29
+
30
+ # ---- Claims ---------------------------------------------------------
31
+
32
+ def append_claim(self, stmt: SignedStatement) -> None:
33
+ """Persist one sealed claim. Idempotent on ``stmt.kid``."""
34
+
35
+ def load_claims(self) -> list[SignedStatement]:
36
+ """Return every persisted claim in append order."""
37
+
38
+ # ---- Disputes -------------------------------------------------------
39
+
40
+ def append_dispute(self, kid: Kid, dispute: Dispute) -> None:
41
+ """Persist (or upsert) a dispute keyed by claim ``kid``."""
42
+
43
+ def load_disputes(self) -> dict[Kid, Dispute]:
44
+ """Return every persisted dispute as ``{claim_kid: Dispute}``."""
45
+
46
+ # ---- Reputation -----------------------------------------------------
47
+
48
+ def snapshot_reputation(self, rows: list[tuple[bytes, str, float, float, int]]) -> None:
49
+ """Persist reputation rows ``(agent_kid, topic, alpha, beta, last_update_ms)``."""
50
+
51
+ def load_reputation(self) -> list[tuple[bytes, str, float, float, int]]:
52
+ """Return every persisted reputation row."""
53
+
54
+ # ---- Merkle leaves --------------------------------------------------
55
+
56
+ def append_merkle_leaf(self, leaf: bytes) -> int:
57
+ """Append a Merkle leaf; return its 1-based index in the log."""
58
+
59
+ def load_merkle_leaves(self) -> list[bytes]:
60
+ """Return every persisted Merkle leaf in append order."""
61
+
62
+ # ---- FROST transcripts (DURABILITY for nonce-reuse defence) --------
63
+
64
+ def remember_transcript(self, transcript_id: bytes) -> bool:
65
+ """Record a FROST transcript_id. Return True if newly added,
66
+ False if the transcript was already present (nonce reuse)."""
67
+
68
+ # ---- Health ---------------------------------------------------------
69
+
70
+ def stats(self) -> dict[str, int]:
71
+ """Return basic metrics: ``{claims, disputes, reputation_rows, leaves, transcripts}``."""
@@ -0,0 +1,245 @@
1
+ """RelataDB-backed StorageBackend — durable ledger over the Relata Python SDK.
2
+
3
+ Same :class:`~hypermind.storage.backend.StorageBackend` Protocol as
4
+ :class:`~hypermind.storage.sqlite.SqliteBackend`; swaps single-file SQLite for a
5
+ governed, bi-temporal, multi-tenant RelataDB server reached over HTTP / pgwire.
6
+
7
+ Identity mapping (the spine): **HyperMind namespace → Relata tenant**. Every
8
+ sub-client carries ``X-Organization-Id = namespace_id`` so claims, disputes,
9
+ reputation, and the Merkle log are tenant-isolated. Within the tenant:
10
+
11
+ * claim ``kid`` → ``object_id`` of ``HmClaim`` (wire bytes, base64)
12
+ * dispute (claim ``kid``)→ ``object_id`` of ``HmDispute`` (pickled, base64)
13
+ * (agent, topic) → ``object_id`` of ``HmReputation``
14
+ * Merkle leaf → Relata's durable WAL-backed ordered log (``LogClient``)
15
+ * FROST transcript id → ``object_id`` of ``HmTranscript`` (durable test-and-set)
16
+
17
+ **Why transcripts use an object row, not ``TokenClient``:** Relata's dedup-token
18
+ store is in-memory today (resets on restart), which fails the replay-defence
19
+ durability contract. The ``HmTranscript`` object row is WAL-durable and safe
20
+ under the single-writer-per-namespace model.
21
+
22
+ The ``relata`` SDK is imported lazily inside :meth:`open` so this module imports
23
+ cleanly without the optional ``hypermind[relata]`` extra installed. Tests inject
24
+ a ``client_factory`` to run the full round-trip against a fake SDK — no live
25
+ server required.
26
+
27
+ Run the server on ``RELATA_ORG_MODE=strict`` so type-ownership isolation is
28
+ enforced (row-level ``with_tenant`` filtering applies regardless of mode).
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import base64
34
+ import pickle
35
+ import time
36
+ from collections.abc import Callable
37
+ from typing import TYPE_CHECKING, Any
38
+
39
+ if TYPE_CHECKING:
40
+ from hypermind.dispute import Dispute
41
+ from hypermind.wire import Kid, SignedStatement
42
+
43
+ _PURPOSE = "hm_ledger"
44
+
45
+ # A factory returns the three clients the backend needs, already tenant-scoped:
46
+ # (query_client, log_client, object_client)
47
+ # ``query_client`` needs ``.query(sql) -> QueryResult`` (with ``.rows``) + ``.close()``.
48
+ ClientFactory = Callable[[], "tuple[Any, Any, Any]"]
49
+
50
+
51
+ class RelataBackend:
52
+ """RelataDB StorageBackend. One instance per (server, namespace)."""
53
+
54
+ def __init__(
55
+ self,
56
+ base_url: str,
57
+ *,
58
+ namespace_id: str,
59
+ bearer_token: str | None = None,
60
+ purpose: str = _PURPOSE,
61
+ client_factory: ClientFactory | None = None,
62
+ ) -> None:
63
+ self._url = base_url
64
+ self._ns = namespace_id
65
+ self._tok = bearer_token
66
+ self._purpose = purpose
67
+ self._factory = client_factory
68
+ self._c: Any = None
69
+ self._log: Any = None
70
+ self._obj: Any = None
71
+ self._seq = 1
72
+ # ponytail: in-memory set gives read-your-writes consistency for the test-and-set;
73
+ # the durable upsert covers the restart-replay defence.
74
+ self._seen_transcripts: set[str] = set()
75
+
76
+ # ---- Lifecycle ------------------------------------------------------
77
+
78
+ def open(self) -> None:
79
+ if self._c is not None:
80
+ return
81
+ if self._factory is not None:
82
+ self._c, self._log, self._obj = self._factory()
83
+ else: # pragma: no cover - requires the optional `relata` SDK + live server
84
+ from relata import RelataClient
85
+ from relata.log import LogClient
86
+ from relata.objects import ObjectClient
87
+
88
+ self._c = RelataClient(
89
+ self._url,
90
+ bearer_token=self._tok,
91
+ purpose=self._purpose,
92
+ tenant=self._ns, # → X-Organization-Id on every request
93
+ )
94
+ # from_client forwards _extra_headers (incl. the tenant header) so
95
+ # the log + object doors stay tenant-scoped. Do NOT construct these
96
+ # bare — that would un-tenant the Merkle log.
97
+ self._log = LogClient.from_client(self._c)
98
+ self._obj = ObjectClient.from_client(self._c)
99
+ # Initialise next_seq from the high-water mark so append order survives
100
+ # restart. Relata's SQL subset rejects COALESCE(MAX(...)); use ORDER BY
101
+ # DESC LIMIT 1 instead (verified against the live server).
102
+ rows = self._rows("SELECT seq FROM HmClaim ORDER BY seq DESC LIMIT 1")
103
+ self._seq = (int(rows[0]["seq"]) + 1) if rows else 1
104
+
105
+ def close(self) -> None:
106
+ for client in (self._log, self._obj, self._c):
107
+ if client is not None:
108
+ client.close()
109
+ self._c = self._log = self._obj = None
110
+
111
+ def _require(self) -> Any:
112
+ if self._c is None:
113
+ raise RuntimeError("RelataBackend used before open()")
114
+ return self._c
115
+
116
+ def _rows(self, sql: str) -> list[dict[str, Any]]:
117
+ return list(self._require().query(sql).rows)
118
+
119
+ # ---- Claims (immutable, append-ordered) -----------------------------
120
+
121
+ def append_claim(self, stmt: SignedStatement) -> None:
122
+ self._obj.upsert(
123
+ "HmClaim",
124
+ bytes(stmt.kid).hex(),
125
+ {
126
+ "seq": self._seq,
127
+ "wire_b64": base64.b64encode(stmt.to_bytes()).decode("ascii"),
128
+ "created_ms": int(time.time() * 1000),
129
+ },
130
+ )
131
+ self._seq += 1
132
+
133
+ def load_claims(self) -> list[SignedStatement]:
134
+ from hypermind.wire import SignedStatement
135
+
136
+ rows = self._rows("SELECT wire_b64, seq FROM HmClaim ORDER BY seq ASC")
137
+ return [SignedStatement.from_bytes(base64.b64decode(r["wire_b64"])) for r in rows]
138
+
139
+ # ---- Disputes (upsert by claim kid) ---------------------------------
140
+
141
+ def append_dispute(self, kid: Kid, dispute: Dispute) -> None:
142
+ payload = pickle.dumps(dispute, protocol=pickle.HIGHEST_PROTOCOL)
143
+ self._obj.upsert(
144
+ "HmDispute",
145
+ bytes(kid).hex(),
146
+ {
147
+ "claim_kid": bytes(kid).hex(),
148
+ "payload_b64": base64.b64encode(payload).decode("ascii"),
149
+ "updated_ms": int(time.time() * 1000),
150
+ },
151
+ )
152
+
153
+ def load_disputes(self) -> dict[Kid, Dispute]:
154
+ out: dict[bytes, Dispute] = {}
155
+ for r in self._rows("SELECT claim_kid, payload_b64 FROM HmDispute"):
156
+ try:
157
+ out[bytes.fromhex(r["claim_kid"])] = pickle.loads(
158
+ base64.b64decode(r["payload_b64"])
159
+ )
160
+ except Exception:
161
+ continue
162
+ return out
163
+
164
+ # ---- Reputation (bulk upsert, keyed on agent+topic) -----------------
165
+
166
+ def snapshot_reputation(self, rows: list[tuple[bytes, str, float, float, int]]) -> None:
167
+ if not rows:
168
+ return
169
+ self._obj.batch_upsert(
170
+ "HmReputation",
171
+ [
172
+ {
173
+ "id": f"{agent.hex()}:{topic}",
174
+ "agent_hex": agent.hex(),
175
+ "topic": topic,
176
+ "alpha": alpha,
177
+ "beta": beta,
178
+ "last_update_ms": last_ms,
179
+ }
180
+ for (agent, topic, alpha, beta, last_ms) in rows
181
+ ],
182
+ )
183
+
184
+ def load_reputation(self) -> list[tuple[bytes, str, float, float, int]]:
185
+ rows = self._rows("SELECT agent_hex, topic, alpha, beta, last_update_ms FROM HmReputation")
186
+ return [
187
+ (bytes.fromhex(r["agent_hex"]), r["topic"], r["alpha"], r["beta"], r["last_update_ms"])
188
+ for r in rows
189
+ ]
190
+
191
+ # ---- Merkle leaves → Relata's durable ordered log -------------------
192
+
193
+ def append_merkle_leaf(self, leaf: bytes) -> int:
194
+ index, _commit_ns = self._log.append(leaf)
195
+ return int(index)
196
+
197
+ def load_merkle_leaves(self) -> list[bytes]:
198
+ # `since` semantics vary (SDK docstring says inclusive; the live server
199
+ # behaves exclusive), so page defensively: dedup by index, advance to the
200
+ # max index seen, and stop when a page yields no new leaves. Correct
201
+ # under either semantics. Ordered by index at the end.
202
+ seen: dict[int, bytes] = {}
203
+ since = 0
204
+ while True:
205
+ batch = self._log.load_leaves(since=since, limit=1000)
206
+ new = [lf for lf in batch if lf.index not in seen]
207
+ if not new:
208
+ break
209
+ for lf in new:
210
+ seen[lf.index] = lf.data
211
+ since = max(lf.index for lf in batch)
212
+ return [seen[i] for i in sorted(seen)]
213
+
214
+ # ---- FROST transcripts (durable test-and-set) -----------------------
215
+
216
+ def remember_transcript(self, transcript_id: bytes) -> bool:
217
+ """Return True if newly recorded, False on duplicate.
218
+
219
+ Single-writer-per-namespace check-then-insert. ``tid`` is hex (0-9a-f)
220
+ so the WHERE literal is injection-safe.
221
+ """
222
+ tid = transcript_id.hex()
223
+ if tid in self._seen_transcripts:
224
+ return False
225
+ # Also check durable store to catch replays across restarts.
226
+ if self._rows(f"SELECT id FROM HmTranscript WHERE id = '{tid}' LIMIT 1"):
227
+ self._seen_transcripts.add(tid)
228
+ return False
229
+ self._obj.upsert("HmTranscript", tid, {"created_ms": int(time.time() * 1000)})
230
+ self._seen_transcripts.add(tid)
231
+ return True
232
+
233
+ # ---- Health ---------------------------------------------------------
234
+
235
+ def stats(self) -> dict[str, int]:
236
+ def count(table: str) -> int:
237
+ return int(self._rows(f"SELECT COUNT(*) AS n FROM {table}")[0]["n"])
238
+
239
+ return {
240
+ "claims": count("HmClaim"),
241
+ "disputes": count("HmDispute"),
242
+ "reputation_rows": count("HmReputation"),
243
+ "leaves": int(self._log.head()),
244
+ "transcripts": count("HmTranscript"),
245
+ }
@@ -0,0 +1,258 @@
1
+ """SQLite-backed StorageBackend — single-file durability, zero extra deps.
2
+
3
+ Schema (auto-created on ``open()``):
4
+
5
+ claims
6
+ kid BLOB PRIMARY KEY
7
+ seq INTEGER NOT NULL -- monotonic append order
8
+ bytes BLOB NOT NULL -- canonical CBOR-encoded SignedStatement
9
+ created_ms INTEGER NOT NULL
10
+
11
+ disputes
12
+ claim_kid BLOB PRIMARY KEY -- the disputed claim kid
13
+ payload BLOB NOT NULL -- pickled Dispute (bond+state+transitions)
14
+ updated_ms INTEGER NOT NULL
15
+
16
+ reputation
17
+ agent_kid BLOB NOT NULL
18
+ topic TEXT NOT NULL
19
+ alpha REAL NOT NULL
20
+ beta REAL NOT NULL
21
+ last_update_ms INTEGER NOT NULL
22
+ PRIMARY KEY (agent_kid, topic)
23
+
24
+ merkle_leaves
25
+ seq INTEGER PRIMARY KEY AUTOINCREMENT
26
+ leaf BLOB NOT NULL
27
+
28
+ frost_transcripts
29
+ transcript_id BLOB PRIMARY KEY
30
+ created_ms INTEGER NOT NULL
31
+
32
+ The ``Dispute`` payload is pickled rather than columnised because the
33
+ state-transition history is variable-length and the field set will grow
34
+ across versions. Pickle is acceptable here — the SQLite file is local-
35
+ process-private; no untrusted dispute payloads cross this boundary.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import pickle
41
+ import sqlite3
42
+ import threading
43
+ import time
44
+ from pathlib import Path
45
+ from typing import TYPE_CHECKING
46
+
47
+ if TYPE_CHECKING:
48
+ from hypermind.dispute import Dispute
49
+ from hypermind.wire import Kid, SignedStatement
50
+
51
+
52
+ _SCHEMA = """
53
+ CREATE TABLE IF NOT EXISTS claims (
54
+ kid BLOB PRIMARY KEY,
55
+ seq INTEGER NOT NULL,
56
+ bytes BLOB NOT NULL,
57
+ created_ms INTEGER NOT NULL
58
+ );
59
+ CREATE INDEX IF NOT EXISTS idx_claims_seq ON claims(seq);
60
+
61
+ CREATE TABLE IF NOT EXISTS disputes (
62
+ claim_kid BLOB PRIMARY KEY,
63
+ payload BLOB NOT NULL,
64
+ updated_ms INTEGER NOT NULL
65
+ );
66
+
67
+ CREATE TABLE IF NOT EXISTS reputation (
68
+ agent_kid BLOB NOT NULL,
69
+ topic TEXT NOT NULL,
70
+ alpha REAL NOT NULL,
71
+ beta REAL NOT NULL,
72
+ last_update_ms INTEGER NOT NULL,
73
+ PRIMARY KEY (agent_kid, topic)
74
+ );
75
+
76
+ CREATE TABLE IF NOT EXISTS merkle_leaves (
77
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
78
+ leaf BLOB NOT NULL
79
+ );
80
+
81
+ CREATE TABLE IF NOT EXISTS frost_transcripts (
82
+ transcript_id BLOB PRIMARY KEY,
83
+ created_ms INTEGER NOT NULL
84
+ );
85
+ """
86
+
87
+
88
+ class SqliteBackend:
89
+ """File-backed StorageBackend.
90
+
91
+ Construct with a path; pass ``":memory:"`` for an ephemeral test backend
92
+ (useful when you want the persistence code paths exercised without
93
+ touching disk).
94
+ """
95
+
96
+ def __init__(self, path: str | Path) -> None:
97
+ self.path = str(path)
98
+ self._conn: sqlite3.Connection | None = None
99
+ self._lock = threading.Lock()
100
+ self._next_seq = 1
101
+
102
+ # ---- Lifecycle ------------------------------------------------------
103
+
104
+ def open(self) -> None:
105
+ if self._conn is not None:
106
+ return
107
+ # ``check_same_thread=False`` because asyncio may schedule writes
108
+ # from different worker threads under uvicorn — we serialise via
109
+ # ``self._lock`` instead.
110
+ self._conn = sqlite3.connect(
111
+ self.path,
112
+ check_same_thread=False,
113
+ isolation_level=None, # autocommit; we manage transactions explicitly
114
+ )
115
+ self._conn.execute("PRAGMA journal_mode=WAL")
116
+ self._conn.execute("PRAGMA synchronous=NORMAL")
117
+ self._conn.executescript(_SCHEMA)
118
+ # Initialise next_seq from the high-water mark so claim ordering
119
+ # survives restart.
120
+ row = self._conn.execute("SELECT COALESCE(MAX(seq), 0) FROM claims").fetchone()
121
+ self._next_seq = (row[0] or 0) + 1
122
+
123
+ def close(self) -> None:
124
+ with self._lock:
125
+ if self._conn is not None:
126
+ self._conn.close()
127
+ self._conn = None
128
+
129
+ def _require(self) -> sqlite3.Connection:
130
+ if self._conn is None:
131
+ raise RuntimeError("SqliteBackend used before open()")
132
+ return self._conn
133
+
134
+ # ---- Claims ---------------------------------------------------------
135
+
136
+ def append_claim(self, stmt: SignedStatement) -> None:
137
+ kid = bytes(stmt.kid)
138
+ wire = stmt.to_bytes()
139
+ now = int(time.time() * 1000)
140
+ with self._lock:
141
+ conn = self._require()
142
+ conn.execute(
143
+ "INSERT OR IGNORE INTO claims (kid, seq, bytes, created_ms) VALUES (?, ?, ?, ?)",
144
+ (kid, self._next_seq, wire, now),
145
+ )
146
+ self._next_seq += 1
147
+
148
+ def load_claims(self) -> list[SignedStatement]:
149
+ from hypermind.wire import SignedStatement
150
+
151
+ with self._lock:
152
+ conn = self._require()
153
+ rows = conn.execute("SELECT bytes FROM claims ORDER BY seq ASC").fetchall()
154
+ return [SignedStatement.from_bytes(bytes(r[0])) for r in rows]
155
+
156
+ # ---- Disputes -------------------------------------------------------
157
+
158
+ def append_dispute(self, kid: Kid, dispute: Dispute) -> None:
159
+ payload = pickle.dumps(dispute, protocol=pickle.HIGHEST_PROTOCOL)
160
+ now = int(time.time() * 1000)
161
+ with self._lock:
162
+ conn = self._require()
163
+ conn.execute(
164
+ "INSERT INTO disputes (claim_kid, payload, updated_ms) "
165
+ "VALUES (?, ?, ?) "
166
+ "ON CONFLICT(claim_kid) DO UPDATE SET "
167
+ "payload=excluded.payload, updated_ms=excluded.updated_ms",
168
+ (bytes(kid), payload, now),
169
+ )
170
+
171
+ def load_disputes(self) -> dict[Kid, Dispute]:
172
+ with self._lock:
173
+ conn = self._require()
174
+ rows = conn.execute("SELECT claim_kid, payload FROM disputes").fetchall()
175
+ out: dict[bytes, Dispute] = {}
176
+ for kid, payload in rows:
177
+ try:
178
+ out[bytes(kid)] = pickle.loads(bytes(payload))
179
+ except Exception:
180
+ continue
181
+ return out
182
+
183
+ # ---- Reputation -----------------------------------------------------
184
+
185
+ def snapshot_reputation(self, rows: list[tuple[bytes, str, float, float, int]]) -> None:
186
+ if not rows:
187
+ return
188
+ with self._lock:
189
+ conn = self._require()
190
+ conn.execute("BEGIN")
191
+ try:
192
+ conn.executemany(
193
+ "INSERT INTO reputation "
194
+ "(agent_kid, topic, alpha, beta, last_update_ms) "
195
+ "VALUES (?, ?, ?, ?, ?) "
196
+ "ON CONFLICT(agent_kid, topic) DO UPDATE SET "
197
+ "alpha=excluded.alpha, beta=excluded.beta, "
198
+ "last_update_ms=excluded.last_update_ms",
199
+ rows,
200
+ )
201
+ conn.execute("COMMIT")
202
+ except Exception:
203
+ conn.execute("ROLLBACK")
204
+ raise
205
+
206
+ def load_reputation(self) -> list[tuple[bytes, str, float, float, int]]:
207
+ with self._lock:
208
+ conn = self._require()
209
+ rows = conn.execute(
210
+ "SELECT agent_kid, topic, alpha, beta, last_update_ms FROM reputation"
211
+ ).fetchall()
212
+ return [(bytes(r[0]), r[1], r[2], r[3], r[4]) for r in rows]
213
+
214
+ # ---- Merkle leaves --------------------------------------------------
215
+
216
+ def append_merkle_leaf(self, leaf: bytes) -> int:
217
+ with self._lock:
218
+ conn = self._require()
219
+ cur = conn.execute("INSERT INTO merkle_leaves (leaf) VALUES (?)", (leaf,))
220
+ return int(cur.lastrowid or 0)
221
+
222
+ def load_merkle_leaves(self) -> list[bytes]:
223
+ with self._lock:
224
+ conn = self._require()
225
+ rows = conn.execute("SELECT leaf FROM merkle_leaves ORDER BY seq ASC").fetchall()
226
+ return [bytes(r[0]) for r in rows]
227
+
228
+ # ---- FROST transcripts ---------------------------------------------
229
+
230
+ def remember_transcript(self, transcript_id: bytes) -> bool:
231
+ """Atomic insert. Returns True if newly recorded, False on duplicate.
232
+
233
+ Uses ``INSERT OR IGNORE`` so two callers racing on the same
234
+ transcript_id always have one survivor — the loser sees False
235
+ and MUST refuse to release its z_i (FROST nonce-reuse defence).
236
+ """
237
+ now = int(time.time() * 1000)
238
+ with self._lock:
239
+ conn = self._require()
240
+ cur = conn.execute(
241
+ "INSERT OR IGNORE INTO frost_transcripts (transcript_id, created_ms) VALUES (?, ?)",
242
+ (transcript_id, now),
243
+ )
244
+ return cur.rowcount > 0
245
+
246
+ # ---- Health ---------------------------------------------------------
247
+
248
+ def stats(self) -> dict[str, int]:
249
+ with self._lock:
250
+ conn = self._require()
251
+ rows = {
252
+ "claims": conn.execute("SELECT COUNT(*) FROM claims").fetchone()[0],
253
+ "disputes": conn.execute("SELECT COUNT(*) FROM disputes").fetchone()[0],
254
+ "reputation_rows": conn.execute("SELECT COUNT(*) FROM reputation").fetchone()[0],
255
+ "leaves": conn.execute("SELECT COUNT(*) FROM merkle_leaves").fetchone()[0],
256
+ "transcripts": conn.execute("SELECT COUNT(*) FROM frost_transcripts").fetchone()[0],
257
+ }
258
+ return rows