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,355 @@
1
+ """Multi-cycle deliberation for consult panels.
2
+
3
+ Single-shot LLM panels cannot revise on disagreement. The
4
+ :func:`run_deliberation` loop runs the panel multiple times, surfacing
5
+ each round's anonymised reasoning summaries back to every member as
6
+ context for the next round, so the panel can refine, qualify, or
7
+ rebut its earlier answers.
8
+
9
+ Convergence:
10
+ Stop early when ``|disagreement_t - disagreement_{t-1}| < convergence_threshold``
11
+ OR when ``max_rounds`` is reached, whichever comes first.
12
+
13
+ Backward-compat:
14
+ With ``max_rounds=1`` (the v0.5 default), this module is a no-op
15
+ pass-through to the existing single-shot behaviour. Existing
16
+ callers see no behavioural change.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import time
23
+ from collections.abc import Awaitable, Callable
24
+ from dataclasses import dataclass, field
25
+ from typing import TYPE_CHECKING, Any
26
+
27
+ from hypermind.consult import (
28
+ ArgumentEdge,
29
+ PanelMember,
30
+ aggregate_panel,
31
+ )
32
+ from hypermind.responders.base import unpack_responder_result
33
+ from hypermind.uncertainty import Uncertainty
34
+
35
+ if TYPE_CHECKING:
36
+ pass
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Round + result records
41
+ # ---------------------------------------------------------------------------
42
+
43
+
44
+ @dataclass
45
+ class DeliberationRound:
46
+ """One round of the deliberation loop."""
47
+
48
+ round_index: int # 0-based
49
+ panel: list[PanelMember]
50
+ aggregated: Uncertainty
51
+ disagreement: float
52
+ elapsed_ms: float
53
+ revision_edges: list[ArgumentEdge] = field(default_factory=list)
54
+
55
+
56
+ @dataclass
57
+ class DeliberationResult:
58
+ """Outcome of a multi-round deliberation.
59
+
60
+ ``rounds[-1]`` is always the final round whose ``aggregated`` and
61
+ ``panel`` populate the parent :class:`ConsultResult`.
62
+ """
63
+
64
+ rounds: list[DeliberationRound]
65
+ final_posterior: Uncertainty
66
+ final_disagreement: float
67
+ converged: bool
68
+ converged_reason: str
69
+
70
+ @property
71
+ def n_rounds(self) -> int:
72
+ return len(self.rounds)
73
+
74
+ @property
75
+ def final_panel(self) -> list[PanelMember]:
76
+ return self.rounds[-1].panel if self.rounds else []
77
+
78
+ def all_revision_edges(self) -> list[ArgumentEdge]:
79
+ out: list[ArgumentEdge] = []
80
+ for r in self.rounds:
81
+ out.extend(r.revision_edges)
82
+ return out
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Anonymised reasoning summary — what later rounds see
87
+ # ---------------------------------------------------------------------------
88
+
89
+ #: Soft cap on how many bytes of context each later round receives.
90
+ #: Large summaries blow the token budget; we cap at ~3000 chars.
91
+ MAX_DELIBERATION_CONTEXT_CHARS = 3000
92
+
93
+ #: How much of each panel member's reasoning to include in the summary.
94
+ MAX_REASONING_PER_MEMBER_CHARS = 240
95
+
96
+
97
+ def _build_deliberation_context(
98
+ round_index: int,
99
+ panel: list[PanelMember],
100
+ disagreement: float,
101
+ ) -> str:
102
+ """Compose the round-N context sent to all members for round N+1.
103
+
104
+ Only ``StructuredResponse.reasoning`` is surfaced; agent kids are
105
+ anonymised to ``A1, A2, …`` so re-elicitation can't leak identity.
106
+ """
107
+ if not panel:
108
+ return ""
109
+
110
+ posteriors = [m.posterior.mean() for m in panel]
111
+ avg_post = sum(posteriors) / len(posteriors)
112
+
113
+ lines = [
114
+ f"--- Round {round_index} panel summary ---",
115
+ f"Aggregate posterior so far: {avg_post:.0%}.",
116
+ f"Panel disagreement (Jensen-Shannon): {disagreement:.3f} "
117
+ f"({'consensus' if disagreement < 0.10 else 'divided' if disagreement > 0.30 else 'mixed'}).",
118
+ "",
119
+ "Per-member reasoning (anonymised; revisit your answer in light of these):",
120
+ ]
121
+
122
+ used = sum(len(s) for s in lines)
123
+ for i, m in enumerate(panel, start=1):
124
+ if used >= MAX_DELIBERATION_CONTEXT_CHARS:
125
+ break
126
+ reasoning = ""
127
+ if m.structured is not None:
128
+ reasoning = (m.structured.reasoning or "").strip()
129
+ if not reasoning:
130
+ reasoning = f"(confidence={m.posterior.mean():.2f}; no reasoning recorded)"
131
+ if len(reasoning) > MAX_REASONING_PER_MEMBER_CHARS:
132
+ reasoning = reasoning[:MAX_REASONING_PER_MEMBER_CHARS].rstrip() + "…"
133
+ line = f" · A{i} (confidence={m.posterior.mean():.2f}): {reasoning}"
134
+ lines.append(line)
135
+ used += len(line)
136
+
137
+ lines.append("")
138
+ lines.append(
139
+ "Now answer the original question again. You may keep, qualify, or "
140
+ "rebut your earlier answer. Be specific about what changed (or didn't) "
141
+ "given the panel summary."
142
+ )
143
+ return "\n".join(lines)
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # Revision edges — Toulmin/IBIS labels for round-over-round changes
148
+ # ---------------------------------------------------------------------------
149
+
150
+ #: How much the mean must shift to count as "qualified" vs "rebuts".
151
+ QUALIFY_THRESHOLD = 0.10
152
+ REBUT_THRESHOLD = 0.30
153
+
154
+
155
+ def _classify_revision(prev_mean: float, new_mean: float) -> str:
156
+ """Return one of: ``"supports"``, ``"qualifies"``, ``"rebuts"``."""
157
+ delta = abs(new_mean - prev_mean)
158
+ if delta < QUALIFY_THRESHOLD:
159
+ return "supports"
160
+ # Direction flip across 0.5 → rebuts; same-side shift → qualifies
161
+ if (prev_mean - 0.5) * (new_mean - 0.5) < 0 or delta >= REBUT_THRESHOLD:
162
+ return "rebuts"
163
+ return "qualifies"
164
+
165
+
166
+ def _build_revision_edges(
167
+ prev_panel: list[PanelMember],
168
+ new_panel: list[PanelMember],
169
+ ) -> list[ArgumentEdge]:
170
+ """Build edges from each agent's prev posterior to its new posterior."""
171
+ if not prev_panel or not new_panel:
172
+ return []
173
+ prev_by_kid: dict[bytes, PanelMember] = {m.agent_id: m for m in prev_panel}
174
+ edges: list[ArgumentEdge] = []
175
+ for new_m in new_panel:
176
+ prev_m = prev_by_kid.get(new_m.agent_id)
177
+ if prev_m is None:
178
+ continue
179
+ kind = _classify_revision(prev_m.posterior.mean(), new_m.posterior.mean())
180
+ edges.append(
181
+ ArgumentEdge(
182
+ src_kid=new_m.agent_id,
183
+ dst_kid=new_m.agent_id,
184
+ edge_type=kind,
185
+ )
186
+ )
187
+ return edges
188
+
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # The loop
192
+ # ---------------------------------------------------------------------------
193
+
194
+
195
+ async def _elicit_round(
196
+ chosen: list[bytes],
197
+ contextualised_query: str,
198
+ responder: Callable[[bytes, str], Awaitable[Any]] | None,
199
+ default_posterior_for: Callable[[bytes], Uncertainty],
200
+ rep_for: Callable[[bytes], float],
201
+ timeout_s: float,
202
+ route_reason: str,
203
+ on_responder_error: Callable[[bytes, BaseException], None] | None = None,
204
+ ) -> list[PanelMember]:
205
+ """Dispatch one elicitation round and return the surviving panel members."""
206
+
207
+ async def _ask(kid: bytes) -> Any:
208
+ if responder is not None:
209
+ return await responder(kid, contextualised_query)
210
+ return default_posterior_for(rep_for(kid))
211
+
212
+ results = await asyncio.wait_for(
213
+ asyncio.gather(*(_ask(c) for c in chosen), return_exceptions=True),
214
+ timeout=timeout_s,
215
+ )
216
+
217
+ panel: list[PanelMember] = []
218
+ for c, r in zip(chosen, results, strict=True):
219
+ if isinstance(r, BaseException):
220
+ if on_responder_error is not None:
221
+ try:
222
+ on_responder_error(c, r)
223
+ except Exception:
224
+ pass
225
+ continue
226
+ try:
227
+ posterior, structured = unpack_responder_result(r)
228
+ except TypeError:
229
+ continue
230
+ panel.append(
231
+ PanelMember(
232
+ agent_id=c,
233
+ weight=rep_for(c),
234
+ posterior=posterior,
235
+ route_reason=route_reason,
236
+ structured=structured,
237
+ )
238
+ )
239
+ return panel
240
+
241
+
242
+ async def run_deliberation(
243
+ *,
244
+ chosen: list[bytes],
245
+ query: str,
246
+ responder: Callable[[bytes, str], Awaitable[Any]] | None,
247
+ default_posterior_for: Callable[[bytes], Uncertainty],
248
+ rep_for: Callable[[bytes], float],
249
+ max_rounds: int,
250
+ convergence_threshold: float,
251
+ timeout_per_round_s: float,
252
+ route_reason: str,
253
+ on_responder_error: Callable[[bytes, BaseException], None] | None = None,
254
+ ) -> DeliberationResult:
255
+ """Run up to ``max_rounds`` of elicitation with anonymised feedback.
256
+
257
+ Round 0 elicits cold (just the original ``query``). Round k>0 prefixes
258
+ the query with anonymised reasoning summaries from round k-1. Stops
259
+ early when ``|Δ disagreement| < convergence_threshold``.
260
+ """
261
+ if max_rounds < 1:
262
+ raise ValueError("max_rounds must be >= 1")
263
+
264
+ rounds: list[DeliberationRound] = []
265
+ prev_disagreement: float | None = None
266
+ converged = False
267
+ converged_reason = "max_rounds_reached"
268
+
269
+ contextualised_query = query
270
+
271
+ for round_idx in range(max_rounds):
272
+ t0 = time.perf_counter()
273
+
274
+ panel = await _elicit_round(
275
+ chosen=chosen,
276
+ contextualised_query=contextualised_query,
277
+ responder=responder,
278
+ default_posterior_for=default_posterior_for,
279
+ rep_for=rep_for,
280
+ timeout_s=timeout_per_round_s,
281
+ route_reason=route_reason,
282
+ on_responder_error=on_responder_error,
283
+ )
284
+
285
+ if not panel:
286
+ converged = True
287
+ converged_reason = "no_panel_responses"
288
+ break
289
+
290
+ aggregated, disagreement = aggregate_panel(panel)
291
+ elapsed_ms = (time.perf_counter() - t0) * 1000.0
292
+
293
+ # Build revision edges by comparing to previous round (if any).
294
+ revision_edges: list[ArgumentEdge] = []
295
+ if rounds:
296
+ revision_edges = _build_revision_edges(rounds[-1].panel, panel)
297
+
298
+ rounds.append(
299
+ DeliberationRound(
300
+ round_index=round_idx,
301
+ panel=panel,
302
+ aggregated=aggregated,
303
+ disagreement=disagreement,
304
+ elapsed_ms=elapsed_ms,
305
+ revision_edges=revision_edges,
306
+ )
307
+ )
308
+
309
+ # Check convergence
310
+ if prev_disagreement is not None:
311
+ delta = abs(disagreement - prev_disagreement)
312
+ if delta < convergence_threshold:
313
+ converged = True
314
+ converged_reason = "disagreement_stable"
315
+ break
316
+ prev_disagreement = disagreement
317
+
318
+ # Build context for next round (only if we'll do another)
319
+ if round_idx + 1 < max_rounds:
320
+ ctx = _build_deliberation_context(
321
+ round_index=round_idx,
322
+ panel=panel,
323
+ disagreement=disagreement,
324
+ )
325
+ contextualised_query = f"{ctx}\n\n--- Original question ---\n{query}"
326
+
327
+ if not rounds:
328
+ # Shouldn't happen unless every responder errored on round 0.
329
+ # Caller is responsible for handling empty panel; we surface it.
330
+ return DeliberationResult(
331
+ rounds=[],
332
+ final_posterior=Uncertainty.beta(1.0, 1.0),
333
+ final_disagreement=0.0,
334
+ converged=True,
335
+ converged_reason="no_panel_responses",
336
+ )
337
+
338
+ return DeliberationResult(
339
+ rounds=rounds,
340
+ final_posterior=rounds[-1].aggregated,
341
+ final_disagreement=rounds[-1].disagreement,
342
+ converged=converged,
343
+ converged_reason=converged_reason,
344
+ )
345
+
346
+
347
+ __all__ = [
348
+ "MAX_DELIBERATION_CONTEXT_CHARS",
349
+ "MAX_REASONING_PER_MEMBER_CHARS",
350
+ "QUALIFY_THRESHOLD",
351
+ "REBUT_THRESHOLD",
352
+ "DeliberationResult",
353
+ "DeliberationRound",
354
+ "run_deliberation",
355
+ ]
hypermind/did_web.py ADDED
@@ -0,0 +1,256 @@
1
+ """DID:web rendezvous for HyperMind namespaces (spec §24.6).
2
+
3
+ Every namespace MUST publish a DID Document at::
4
+
5
+ did:web:<host>:hm:<namespace_id_hex>
6
+
7
+ resolved per W3C DID Core. The document carries:
8
+
9
+ * ``id`` — the DID URI itself
10
+ * ``verificationMethod`` — at least one entry binding the namespace
11
+ ``root_pubkey`` (the FROST group public key from §24.3)
12
+ * ``service[]`` — gossip endpoint advertisements for the namespace
13
+ * ``proof`` — VC-Data-Integrity proof signed by the namespace root key
14
+
15
+ This module supplies the format, builder, and resolver. Real HTTP
16
+ fetching is reserved for v1.0; the :class:`DidWebResolver` accepts an
17
+ ``OfflineCache`` shim so unit tests do not perform live network calls.
18
+
19
+ Document encoding: spec §24.6 references W3C VC-Data-Integrity, which
20
+ is defined over JSON-LD. This implementation uses **canonical JSON
21
+ sorted-keys** (RFC 8785-style) — the §24 OPEN GAP on resolution policy
22
+ covers this; we flag the choice in :data:`DOC_ENCODING_NOTE`.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import re
29
+ from dataclasses import asdict, dataclass, field
30
+
31
+ from hypermind.crypto.namespace_root import NamespaceRoot
32
+
33
+ DOC_ENCODING_NOTE = (
34
+ "did:web Document is serialised as canonical JSON sorted-keys per RFC 8785 "
35
+ "(spec §24.6 leaves the precise canonicalisation form open for v1.0 errata). "
36
+ "FROST proof is computed over the document with `proof.signatureValue` zeroed."
37
+ )
38
+
39
+ _DID_RE = re.compile(r"^did:web:([A-Za-z0-9._-]+):hm:([0-9a-f]{32,64})$")
40
+
41
+
42
+ def format_did(host: str, ns_id: bytes) -> str:
43
+ """Format a HyperMind DID:web URI.
44
+
45
+ ``host`` is the operator-chosen DNS name; ``ns_id`` is the 16-byte
46
+ namespace UUIDv7 (or longer if a future profile uses 32 bytes).
47
+ """
48
+ if not host or not re.match(r"^[A-Za-z0-9._-]+$", host):
49
+ raise ValueError(f"invalid host {host!r}")
50
+ if not isinstance(ns_id, (bytes, bytearray)) or len(ns_id) not in (16, 32):
51
+ raise ValueError("ns_id must be 16 or 32 bytes")
52
+ return f"did:web:{host}:hm:{ns_id.hex()}"
53
+
54
+
55
+ def parse_did(did: str) -> tuple[str, bytes]:
56
+ """Parse a HyperMind DID:web URI back into (host, ns_id)."""
57
+ m = _DID_RE.match(did)
58
+ if not m:
59
+ raise ValueError(f"not a HyperMind DID:web URI: {did!r}")
60
+ host = m.group(1)
61
+ hex_ns = m.group(2)
62
+ if len(hex_ns) not in (32, 64): # 16 or 32 bytes hex
63
+ raise ValueError(f"namespace_id hex must be 32 or 64 chars, got {len(hex_ns)}")
64
+ return host, bytes.fromhex(hex_ns)
65
+
66
+
67
+ @dataclass
68
+ class VerificationMethod:
69
+ id: str
70
+ type: str
71
+ controller: str
72
+ publicKeyMultibase: str
73
+
74
+
75
+ @dataclass
76
+ class ServiceEntry:
77
+ id: str
78
+ type: str
79
+ serviceEndpoint: str
80
+
81
+
82
+ @dataclass
83
+ class Proof:
84
+ type: str = "FrostEd25519Signature2025"
85
+ proofPurpose: str = "assertionMethod"
86
+ verificationMethod: str = ""
87
+ signatureValue: str = ""
88
+
89
+
90
+ @dataclass
91
+ class DidDocument:
92
+ id: str
93
+ verificationMethod: list[VerificationMethod] = field(default_factory=list)
94
+ service: list[ServiceEntry] = field(default_factory=list)
95
+ proof: Proof = field(default_factory=Proof)
96
+
97
+ @classmethod
98
+ def build(
99
+ cls,
100
+ ns: NamespaceRoot,
101
+ host: str,
102
+ gossip_endpoints: list[str],
103
+ ns_id: bytes,
104
+ ) -> DidDocument:
105
+ """Build + sign a DID Document for the given namespace.
106
+
107
+ ``gossip_endpoints`` is a list of HyperMind gossip-bus URLs (TLS
108
+ endpoints from §22). Each becomes a `service` entry with
109
+ ``type: "HyperMindGossip"``.
110
+ """
111
+ did = format_did(host, ns_id)
112
+ # Multibase prefix `z` = base58-btc; we use base16 (`f`) + lowercase
113
+ # hex since libsodium ed25519 publickey is fixed-length.
114
+ pk_multibase = "f" + ns.group_pubkey.hex()
115
+ vm = VerificationMethod(
116
+ id=f"{did}#root",
117
+ type="Ed25519VerificationKey2020",
118
+ controller=did,
119
+ publicKeyMultibase=pk_multibase,
120
+ )
121
+ services = [
122
+ ServiceEntry(
123
+ id=f"{did}#gossip-{i}",
124
+ type="HyperMindGossip",
125
+ serviceEndpoint=ep,
126
+ )
127
+ for i, ep in enumerate(gossip_endpoints)
128
+ ]
129
+ doc = cls(
130
+ id=did,
131
+ verificationMethod=[vm],
132
+ service=services,
133
+ proof=Proof(verificationMethod=f"{did}#root"),
134
+ )
135
+ # Sign with FROST quorum over the canonical preimage (proof.sigValue zeroed).
136
+ preimage = doc._signing_preimage()
137
+ signer_subset = list(range(1, ns.k + 1))
138
+ sig = ns.sign(preimage, signer_subset)
139
+ doc.proof.signatureValue = sig.hex()
140
+ return doc
141
+
142
+ def _signing_preimage(self) -> bytes:
143
+ """Canonical JSON of the document with `proof.signatureValue` blank.
144
+
145
+ Per W3C VC-Data-Integrity, the proof value field is excluded from
146
+ the signed preimage. We zero it (empty string) and emit the
147
+ sorted-keys JSON form.
148
+ """
149
+ clone = DidDocument(
150
+ id=self.id,
151
+ verificationMethod=[VerificationMethod(**asdict(v)) for v in self.verificationMethod],
152
+ service=[ServiceEntry(**asdict(s)) for s in self.service],
153
+ proof=Proof(
154
+ type=self.proof.type,
155
+ proofPurpose=self.proof.proofPurpose,
156
+ verificationMethod=self.proof.verificationMethod,
157
+ signatureValue="",
158
+ ),
159
+ )
160
+ return json.dumps(clone._to_dict(), sort_keys=True, separators=(",", ":")).encode("utf-8")
161
+
162
+ def _to_dict(self) -> dict:
163
+ return {
164
+ "id": self.id,
165
+ "verificationMethod": [asdict(v) for v in self.verificationMethod],
166
+ "service": [asdict(s) for s in self.service],
167
+ "proof": asdict(self.proof),
168
+ }
169
+
170
+ def serialize(self) -> bytes:
171
+ """Canonical JSON sorted-keys, UTF-8 — what the resolver fetches."""
172
+ return json.dumps(self._to_dict(), sort_keys=True, separators=(",", ":")).encode("utf-8")
173
+
174
+ @classmethod
175
+ def deserialize(cls, raw: bytes) -> DidDocument:
176
+ data = json.loads(raw.decode("utf-8"))
177
+ return cls(
178
+ id=data["id"],
179
+ verificationMethod=[
180
+ VerificationMethod(**v) for v in data.get("verificationMethod", [])
181
+ ],
182
+ service=[ServiceEntry(**s) for s in data.get("service", [])],
183
+ proof=Proof(**data.get("proof", {})),
184
+ )
185
+
186
+
187
+ def verify(doc_bytes: bytes, ns_root_pub: bytes) -> bool:
188
+ """Verify a serialised DID Document's FROST proof against ``ns_root_pub``.
189
+
190
+ Returns ``True`` iff the proof signature validates under the FROST
191
+ group public key declared in the matching ``NAMESPACE_FOUNDING_ATTEST``.
192
+ """
193
+ from hypermind.crypto import frost
194
+
195
+ try:
196
+ doc = DidDocument.deserialize(doc_bytes)
197
+ except (json.JSONDecodeError, KeyError, TypeError):
198
+ return False
199
+ sig_hex = doc.proof.signatureValue
200
+ if not sig_hex:
201
+ return False
202
+ try:
203
+ sig = bytes.fromhex(sig_hex)
204
+ except ValueError:
205
+ return False
206
+ if len(sig) != 64:
207
+ return False
208
+ preimage = doc._signing_preimage()
209
+ R = sig[:32]
210
+ z = sig[32:]
211
+ try:
212
+ return frost.verify(ns_root_pub, preimage, R, z, commitments=None)
213
+ except Exception:
214
+ return False
215
+
216
+
217
+ class DidWebResolver:
218
+ """Resolver for ``did:web:...`` URIs.
219
+
220
+ Production resolvers fetch the document over HTTPS. Unit tests pass
221
+ a preloaded ``cache`` so resolution is deterministic and offline.
222
+
223
+ Parameters
224
+ ----------
225
+ cache:
226
+ Optional ``dict[str, bytes]`` mapping DID URI → serialized
227
+ document bytes. When set, resolution is offline-only.
228
+ """
229
+
230
+ def __init__(self, cache: dict[str, bytes] | None = None) -> None:
231
+ self._cache: dict[str, bytes] = dict(cache) if cache else {}
232
+
233
+ def preload(self, did: str, doc_bytes: bytes) -> None:
234
+ """Add a document to the offline cache."""
235
+ self._cache[did] = doc_bytes
236
+
237
+ def resolve(self, did: str) -> DidDocument:
238
+ """Resolve a DID URI to a :class:`DidDocument`.
239
+
240
+ Raises ``KeyError`` if the document is not cached. Real HTTP
241
+ fetching is reserved for v1.0 (spec §24.9 OPEN GAP on resolution
242
+ policy).
243
+ """
244
+ # Validate the URI shape before lookup so malformed inputs fail fast.
245
+ parse_did(did)
246
+ if did not in self._cache:
247
+ raise KeyError(
248
+ f"DID:web document not in offline cache: {did}. "
249
+ "Live HTTP resolution is reserved for v1.0; preload via "
250
+ "DidWebResolver(cache={did: doc_bytes})."
251
+ )
252
+ return DidDocument.deserialize(self._cache[did])
253
+
254
+
255
+ class OfflineCache(dict):
256
+ """Marker subclass of dict — explicit name for resolver cache."""