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.
- hmctl/__init__.py +7 -0
- hmctl/cli.py +947 -0
- hypermind/__init__.py +208 -0
- hypermind/_deprecations.py +108 -0
- hypermind/agent.py +2896 -0
- hypermind/agent_card.py +127 -0
- hypermind/api/__init__.py +32 -0
- hypermind/api/app.py +686 -0
- hypermind/capability.py +923 -0
- hypermind/consult.py +493 -0
- hypermind/consult_bias.py +112 -0
- hypermind/contract_net.py +219 -0
- hypermind/crypto/__init__.py +21 -0
- hypermind/crypto/cose_encrypt.py +126 -0
- hypermind/crypto/dkg.py +246 -0
- hypermind/crypto/domain.py +59 -0
- hypermind/crypto/frost.py +771 -0
- hypermind/crypto/hashing.py +38 -0
- hypermind/crypto/hlc.py +163 -0
- hypermind/crypto/hpke.py +277 -0
- hypermind/crypto/kms.py +196 -0
- hypermind/crypto/kms_backends/__init__.py +56 -0
- hypermind/crypto/kms_backends/aws.py +128 -0
- hypermind/crypto/kms_backends/azure.py +114 -0
- hypermind/crypto/kms_backends/gcp.py +97 -0
- hypermind/crypto/kms_backends/vault.py +120 -0
- hypermind/crypto/namespace_root.py +204 -0
- hypermind/crypto/pq.py +152 -0
- hypermind/crypto/room_epoch.py +97 -0
- hypermind/crypto/signing.py +245 -0
- hypermind/deliberation.py +355 -0
- hypermind/did_web.py +256 -0
- hypermind/dispute.py +858 -0
- hypermind/errors.py +218 -0
- hypermind/eval/__init__.py +49 -0
- hypermind/eval/calibration.py +307 -0
- hypermind/eval/comparison.py +251 -0
- hypermind/eval/replay.py +202 -0
- hypermind/eval/swarm_iq.py +718 -0
- hypermind/knowledge/__init__.py +17 -0
- hypermind/knowledge/citation_graph.py +141 -0
- hypermind/knowledge/correlated_agreement.py +167 -0
- hypermind/knowledge/embedding_registry.py +115 -0
- hypermind/knowledge/kernel_store.py +197 -0
- hypermind/knowledge/rekor.py +173 -0
- hypermind/knowledge/reputation.py +364 -0
- hypermind/knowledge/swarm_memory.py +523 -0
- hypermind/knowledge/transparency.py +409 -0
- hypermind/mcp/__init__.py +60 -0
- hypermind/mcp/bridge.py +151 -0
- hypermind/mcp/client.py +142 -0
- hypermind/mcp/provenance.py +214 -0
- hypermind/mcp/relata_tools.py +152 -0
- hypermind/mcp/server.py +248 -0
- hypermind/mcp/transport.py +206 -0
- hypermind/mind/__init__.py +150 -0
- hypermind/mind/active.py +234 -0
- hypermind/mind/belief.py +369 -0
- hypermind/mind/deliberator.py +625 -0
- hypermind/mind/diversity.py +144 -0
- hypermind/mind/evolve.py +184 -0
- hypermind/mind/federation.py +154 -0
- hypermind/mind/goals.py +117 -0
- hypermind/mind/integration.py +190 -0
- hypermind/mind/mediator.py +74 -0
- hypermind/mind/memory_store.py +129 -0
- hypermind/mind/policy.py +307 -0
- hypermind/mind/records.py +260 -0
- hypermind/mind/roles.py +190 -0
- hypermind/mind/sybil_guard.py +41 -0
- hypermind/mind/world_model.py +166 -0
- hypermind/namespace.py +515 -0
- hypermind/namespace_policy.py +254 -0
- hypermind/observability/__init__.py +25 -0
- hypermind/observability/asgi.py +214 -0
- hypermind/observability/cost.py +277 -0
- hypermind/observability/otel_export.py +200 -0
- hypermind/observability/recorder.py +344 -0
- hypermind/observability/swarm_trace.py +438 -0
- hypermind/pin_policy.py +116 -0
- hypermind/py.typed +0 -0
- hypermind/responders/__init__.py +87 -0
- hypermind/responders/anthropic.py +159 -0
- hypermind/responders/base.py +162 -0
- hypermind/responders/openai.py +199 -0
- hypermind/responders/router.py +254 -0
- hypermind/responders/structured.py +287 -0
- hypermind/responders/tools.py +444 -0
- hypermind/revocation.py +270 -0
- hypermind/rule_ids.py +135 -0
- hypermind/schemas/encrypted_statement.cddl +28 -0
- hypermind/schemas/namespace_accept.cddl +20 -0
- hypermind/schemas/namespace_create.cddl +25 -0
- hypermind/schemas/namespace_founding_attest.cddl +30 -0
- hypermind/schemas/namespace_invite.cddl +22 -0
- hypermind/schemas/wire-v0.1.cddl +73 -0
- hypermind/simlab/__init__.py +15 -0
- hypermind/simlab/_persona_registry.py +93 -0
- hypermind/simlab/_scenario_impl.py +2778 -0
- hypermind/simlab/app.py +2538 -0
- hypermind/simlab/backends/__init__.py +27 -0
- hypermind/simlab/backends/anchor.py +88 -0
- hypermind/simlab/backends/local.py +32 -0
- hypermind/simlab/backends/server.py +79 -0
- hypermind/simlab/cli_command.py +108 -0
- hypermind/simlab/config.py +106 -0
- hypermind/simlab/deployment.py +26 -0
- hypermind/simlab/knowledge.py +716 -0
- hypermind/simlab/learning.py +295 -0
- hypermind/simlab/modes/__init__.py +115 -0
- hypermind/simlab/modes/_eval_real_llm.py +373 -0
- hypermind/simlab/modes/aar.py +611 -0
- hypermind/simlab/modes/backtest.py +610 -0
- hypermind/simlab/modes/binary_forecast.py +69 -0
- hypermind/simlab/modes/conformance.py +1869 -0
- hypermind/simlab/modes/evaluation.py +2271 -0
- hypermind/simlab/modes/governance.py +648 -0
- hypermind/simlab/modes/redteam.py +534 -0
- hypermind/simlab/modes/tabletop.py +797 -0
- hypermind/simlab/modes/tournament.py +448 -0
- hypermind/simlab/modes/whatif.py +523 -0
- hypermind/simlab/namespace.py +125 -0
- hypermind/simlab/personas.py +477 -0
- hypermind/simlab/prompt_generator.py +172 -0
- hypermind/simlab/question_sets/geopolitics_q20.json +122 -0
- hypermind/simlab/question_sets/governance_q5.json +67 -0
- hypermind/simlab/question_sets/msft_outlook_q10.json +62 -0
- hypermind/simlab/question_sets/whatif_q3.json +38 -0
- hypermind/simlab/registry.py +325 -0
- hypermind/simlab/runner.py +239 -0
- hypermind/simlab/scenario.py +147 -0
- hypermind/simlab/swarms.py +180 -0
- hypermind/simlab/tool_handlers/__init__.py +4 -0
- hypermind/simlab/tool_handlers/alpha_vantage.py +39 -0
- hypermind/simlab/tool_handlers/brave.py +46 -0
- hypermind/simlab/tool_handlers/newsapi.py +50 -0
- hypermind/simlab/tool_handlers/openweather.py +46 -0
- hypermind/simlab/tool_handlers/pinecone.py +52 -0
- hypermind/simlab/tool_handlers/qdrant.py +57 -0
- hypermind/simlab/tool_handlers/query_knowledge_base.py +21 -0
- hypermind/simlab/tool_handlers/relata_recall.py +61 -0
- hypermind/simlab/tool_handlers/retrieve_document.py +21 -0
- hypermind/simlab/tool_handlers/serper.py +46 -0
- hypermind/simlab/tool_handlers/tavily.py +53 -0
- hypermind/simlab/tool_handlers/weaviate.py +65 -0
- hypermind/simlab/tool_handlers/wikipedia.py +64 -0
- hypermind/simlab/tool_handlers/wolfram.py +48 -0
- hypermind/simlab/tool_handlers/yahoo_finance.py +49 -0
- hypermind/simlab/tool_registry.py +568 -0
- hypermind/simlab/topic_adapter.py +338 -0
- hypermind/simlab/topic_questions.py +259 -0
- hypermind/storage/__init__.py +69 -0
- hypermind/storage/backend.py +71 -0
- hypermind/storage/relata.py +245 -0
- hypermind/storage/sqlite.py +258 -0
- hypermind/sync.py +191 -0
- hypermind/tal.py +175 -0
- hypermind/tasks.py +334 -0
- hypermind/testing.py +16 -0
- hypermind/tools/__init__.py +32 -0
- hypermind/tools/registry.py +61 -0
- hypermind/transport/__init__.py +22 -0
- hypermind/transport/anti_entropy.py +708 -0
- hypermind/transport/bus.py +206 -0
- hypermind/transport/knows_delta.py +204 -0
- hypermind/transport/libp2p.py +298 -0
- hypermind/transport/placement.py +58 -0
- hypermind/transport/tcp.py +797 -0
- hypermind/transport/tls_profile.py +417 -0
- hypermind/types.py +59 -0
- hypermind/uncertainty.py +222 -0
- hypermind/wire.py +897 -0
- hypermind/workflow/__init__.py +72 -0
- hypermind/workflow/engine.py +655 -0
- hypermind/workflow/plan.py +182 -0
- hypermind/workflow/repair.py +203 -0
- hypermind-0.11.0.dist-info/METADATA +524 -0
- hypermind-0.11.0.dist-info/RECORD +181 -0
- hypermind-0.11.0.dist-info/WHEEL +4 -0
- hypermind-0.11.0.dist-info/entry_points.txt +2 -0
- 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."""
|