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,338 @@
1
+ """Topic-adaptive configuration inference.
2
+
3
+ Given only a topic string, infers the right simulation parameters:
4
+ n_questions, personas, replicas, deliberation_rounds, persona_mix
5
+
6
+ The adapter classifies the topic into one of several *complexity profiles*
7
+ and adjusts all parameters accordingly. LLM mode asks the model to reason
8
+ about the topic before returning a structured config — the same model that
9
+ will be used for the simulation itself, so the choice is coherent.
10
+
11
+ Profiles (heuristic fast-path, no LLM required):
12
+ QUICK — narrow, operational, single-asset questions
13
+ (stock price, product launch, binary event)
14
+ → 4 questions, 3 personas × 1 replica, 1 round
15
+ STANDARD — domain analysis, policy, market dynamics
16
+ → 6 questions, 4 personas × 2 replicas, 2 rounds
17
+ DEEP — multi-factor, geopolitical, science/technology roadmaps
18
+ → 8 questions, 5 personas × 2 replicas, 3 rounds
19
+ STRATEGIC — long-horizon, cross-domain, portfolio/investment decisions
20
+ → 10 questions, 6 personas × 2 replicas, 3 rounds
21
+
22
+ When OPENROUTER_API_KEY is present, the adapter also asks the LLM to
23
+ suggest the profile and to propose relevant persona archetypes from the
24
+ available roster. The LLM answer is parsed and validated; any malformed
25
+ response falls back to the heuristic.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import hashlib
31
+ import json
32
+ import os
33
+ import re
34
+ from dataclasses import dataclass, field
35
+ from pathlib import Path
36
+ from typing import Any
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Profile definitions
41
+ # ---------------------------------------------------------------------------
42
+
43
+
44
+ @dataclass
45
+ class TopicProfile:
46
+ """Inferred simulation parameters for a topic."""
47
+ profile_name: str
48
+ n_questions: int
49
+ personas: int
50
+ replicas: int
51
+ deliberation_rounds: int
52
+ rationale: str = ""
53
+ suggested_personas: list[str] = field(default_factory=list)
54
+
55
+
56
+ _PROFILES: dict[str, TopicProfile] = {
57
+ "quick": TopicProfile(
58
+ profile_name="quick",
59
+ n_questions=4,
60
+ personas=3,
61
+ replicas=1,
62
+ deliberation_rounds=1,
63
+ rationale="Narrow, time-sensitive question — minimal swarm for speed.",
64
+ ),
65
+ "standard": TopicProfile(
66
+ profile_name="standard",
67
+ n_questions=6,
68
+ personas=4,
69
+ replicas=2,
70
+ deliberation_rounds=2,
71
+ rationale="Domain analysis — moderate panel size with one deliberation pass.",
72
+ ),
73
+ "deep": TopicProfile(
74
+ profile_name="deep",
75
+ n_questions=8,
76
+ personas=5,
77
+ replicas=2,
78
+ deliberation_rounds=3,
79
+ rationale="Multi-factor topic — larger panel with extra deliberation rounds for convergence.",
80
+ ),
81
+ "strategic": TopicProfile(
82
+ profile_name="strategic",
83
+ n_questions=10,
84
+ personas=6,
85
+ replicas=2,
86
+ deliberation_rounds=3,
87
+ rationale="Long-horizon strategic question — full swarm with deep deliberation.",
88
+ ),
89
+ }
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Heuristic classifier
94
+ # ---------------------------------------------------------------------------
95
+
96
+
97
+ _QUICK_SIGNALS = frozenset({
98
+ "today", "tonight", "tomorrow", "this week", "this month",
99
+ "price", "stock", "earnings", "ipo", "launch", "release",
100
+ "win", "lose", "beat", "miss", "crash", "spike",
101
+ "will it", "is it", "should i",
102
+ })
103
+
104
+ _STRATEGIC_SIGNALS = frozenset({
105
+ "geopolit", "sovereign", "decade", "2030", "2035", "2040", "2050",
106
+ "long-term", "long term", "strategic", "nation", "global",
107
+ "civilisati", "civilization", "regime", "alliance",
108
+ "portfolio", "multi-asset", "sector rotation", "structural",
109
+ })
110
+
111
+ _DEEP_SIGNALS = frozenset({
112
+ "regul", "policy", "legislation", "infrastructure", "platform",
113
+ "ecosystem", "supply chain", "adopt", "transition", "compet",
114
+ "market structure", "industri", "technolog", "innovat",
115
+ "research", "science", "climate", "energy", "defence", "security",
116
+ "economic", "fiscal", "monetary", "trade war", "tariff",
117
+ })
118
+
119
+
120
+ def _heuristic_profile(topic: str) -> str:
121
+ t = topic.lower()
122
+ words = set(re.findall(r"[a-z]+", t))
123
+
124
+ # Check strategic first (long-horizon beats everything)
125
+ for sig in _STRATEGIC_SIGNALS:
126
+ if sig in t:
127
+ return "strategic"
128
+
129
+ # Deep: multi-factor domain analysis
130
+ deep_hits = sum(1 for sig in _DEEP_SIGNALS if sig in t)
131
+ if deep_hits >= 2:
132
+ return "deep"
133
+
134
+ # Quick: operational, time-sensitive, personal
135
+ quick_hits = sum(1 for sig in _QUICK_SIGNALS if sig in words or sig in t)
136
+ if quick_hits >= 2:
137
+ return "quick"
138
+
139
+ # Length heuristic: longer, richer topics warrant deeper analysis
140
+ word_count = len(topic.split())
141
+ if word_count <= 6:
142
+ return "quick"
143
+ elif word_count <= 12:
144
+ return "standard"
145
+ else:
146
+ return "deep"
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # LLM classifier (async, non-blocking)
151
+ # ---------------------------------------------------------------------------
152
+
153
+
154
+ _SYSTEM_PROMPT = """\
155
+ You are a research-design expert. Given a topic for a multi-agent forecasting simulation,
156
+ output ONLY a JSON object with these fields:
157
+
158
+ {
159
+ "profile": "quick" | "standard" | "deep" | "strategic",
160
+ "rationale": "one sentence explaining the choice",
161
+ "suggested_personas": ["slug1", "slug2", "slug3"]
162
+ }
163
+
164
+ Profile guide:
165
+ quick — narrow, time-sensitive, single-asset (≤4 questions make sense)
166
+ standard — domain analysis, market dynamics, policy (6 questions)
167
+ deep — multi-factor, technology roadmaps, sector analysis (8 questions)
168
+ strategic — long-horizon, geopolitical, cross-domain portfolio (10 questions)
169
+
170
+ For suggested_personas, pick 3–5 slugs from this roster that best cover the topic:
171
+ analyst, economist, technologist, risk-manager, geopolitical-analyst,
172
+ lawyer, scientist, ethicist, entrepreneur, investor, sceptic,
173
+ policy-expert, security-analyst, healthcare-expert, energy-expert
174
+
175
+ Output ONLY valid JSON. No markdown, no preamble.
176
+ """
177
+
178
+
179
+ async def _llm_profile(topic: str, model: str, api_key: str) -> dict[str, Any] | None:
180
+ try:
181
+ import httpx
182
+
183
+ async with httpx.AsyncClient(timeout=12.0) as client:
184
+ resp = await client.post(
185
+ "https://openrouter.ai/api/v1/chat/completions",
186
+ headers={
187
+ "Authorization": f"Bearer {api_key}",
188
+ "Content-Type": "application/json",
189
+ "HTTP-Referer": "https://hypermind.ai",
190
+ },
191
+ json={
192
+ "model": model,
193
+ "messages": [
194
+ {"role": "system", "content": _SYSTEM_PROMPT},
195
+ {"role": "user", "content": f"Topic: {topic}"},
196
+ ],
197
+ "temperature": 0.2,
198
+ "max_tokens": 200,
199
+ },
200
+ )
201
+ resp.raise_for_status()
202
+ content = resp.json()["choices"][0]["message"]["content"].strip()
203
+ # Strip markdown fences if present
204
+ content = re.sub(r"^```json?\s*", "", content)
205
+ content = re.sub(r"\s*```$", "", content)
206
+ parsed = json.loads(content)
207
+ if parsed.get("profile") in _PROFILES:
208
+ return parsed
209
+ except Exception:
210
+ pass
211
+ return None
212
+
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # Public entry point
216
+ # ---------------------------------------------------------------------------
217
+
218
+
219
+ def _profile_cache_path(topic: str, model: str) -> Path:
220
+ key = hashlib.sha256(f"{topic}|{model}".encode()).hexdigest()[:16]
221
+ cache_dir = Path.home() / ".hypermind" / "profile_cache"
222
+ cache_dir.mkdir(parents=True, exist_ok=True)
223
+ return cache_dir / f"{key}.json"
224
+
225
+
226
+ def _load_cached_profile(topic: str, model: str) -> TopicProfile | None:
227
+ path = _profile_cache_path(topic, model)
228
+ if path.exists():
229
+ try:
230
+ d = json.loads(path.read_text())
231
+ return TopicProfile(**d)
232
+ except Exception:
233
+ pass
234
+ return None
235
+
236
+
237
+ def _save_cached_profile(topic: str, model: str, profile: TopicProfile) -> None:
238
+ try:
239
+ d = {
240
+ "profile_name": profile.profile_name,
241
+ "n_questions": profile.n_questions,
242
+ "personas": profile.personas,
243
+ "replicas": profile.replicas,
244
+ "deliberation_rounds": profile.deliberation_rounds,
245
+ "rationale": profile.rationale,
246
+ "suggested_personas": profile.suggested_personas,
247
+ }
248
+ _profile_cache_path(topic, model).write_text(json.dumps(d, indent=2))
249
+ except Exception:
250
+ pass
251
+
252
+
253
+ async def infer_topic_profile(
254
+ topic: str,
255
+ *,
256
+ model: str | None = None,
257
+ no_llm: bool = False,
258
+ ) -> TopicProfile:
259
+ """Return a TopicProfile inferred from *topic*.
260
+
261
+ Fast-path: heuristic classifier (no LLM, no network).
262
+ If OPENROUTER_API_KEY and model are available, asks the LLM to refine
263
+ the profile and suggest personas. Falls back to heuristic on any error.
264
+ """
265
+ topic = (topic or "").strip()
266
+ if not topic:
267
+ return _PROFILES["standard"]
268
+
269
+ heuristic_key = _heuristic_profile(topic)
270
+ heuristic = _PROFILES[heuristic_key]
271
+
272
+ api_key = os.environ.get("OPENROUTER_API_KEY", "").strip()
273
+ if no_llm or not api_key or not model:
274
+ return heuristic
275
+
276
+ # Cache hit — same topic+model already classified
277
+ cached = _load_cached_profile(topic, model)
278
+ if cached is not None:
279
+ return cached
280
+
281
+ llm_result = await _llm_profile(topic, model, api_key)
282
+ if llm_result is None:
283
+ return heuristic
284
+
285
+ profile_key = llm_result.get("profile", heuristic_key)
286
+ base = _PROFILES.get(profile_key, heuristic)
287
+
288
+ result = TopicProfile(
289
+ profile_name=base.profile_name,
290
+ n_questions=base.n_questions,
291
+ personas=base.personas,
292
+ replicas=base.replicas,
293
+ deliberation_rounds=base.deliberation_rounds,
294
+ rationale=llm_result.get("rationale", base.rationale),
295
+ suggested_personas=llm_result.get("suggested_personas", []),
296
+ )
297
+ _save_cached_profile(topic, model, result)
298
+ return result
299
+
300
+
301
+ def apply_profile_to_config(config_dict: dict[str, Any], profile: TopicProfile) -> dict[str, Any]:
302
+ """Apply an inferred profile to a raw config dict.
303
+
304
+ Only overwrites fields that were left at their sentinel defaults
305
+ (i.e. the caller didn't explicitly supply them).
306
+ """
307
+ out = dict(config_dict)
308
+ sentinel = _SENTINELS
309
+
310
+ if out.get("n_questions") in (None, sentinel["n_questions"]):
311
+ out["n_questions"] = profile.n_questions
312
+ if out.get("personas") in (None, sentinel["personas"]):
313
+ out["personas"] = profile.personas
314
+ if out.get("replicas") in (None, sentinel["replicas"]):
315
+ out["replicas"] = profile.replicas
316
+ if out.get("deliberation_rounds") in (None, sentinel["deliberation_rounds"]):
317
+ out["deliberation_rounds"] = profile.deliberation_rounds
318
+
319
+ # Inject suggested persona override if provided and caller didn't already set one
320
+ if profile.suggested_personas and not out.get("personas_override") and not out.get("swarm"):
321
+ from .personas import get_persona
322
+ valid = [s for s in profile.suggested_personas if get_persona(s) is not None]
323
+ if len(valid) >= 2:
324
+ out["personas_override"] = valid
325
+
326
+ out["_profile"] = profile.profile_name
327
+ out["_profile_rationale"] = profile.rationale
328
+ return out
329
+
330
+
331
+ # Sentinel values — these are the SimConfig dataclass defaults.
332
+ # Any field still at its default is considered "not explicitly set by caller".
333
+ _SENTINELS: dict[str, Any] = {
334
+ "n_questions": 8,
335
+ "personas": 6,
336
+ "replicas": 5,
337
+ "deliberation_rounds": 2,
338
+ }
@@ -0,0 +1,259 @@
1
+ """Topic-driven question generation.
2
+
3
+ Given a free-text topic ("Microsoft Azure outlook 2026") and a count,
4
+ emit a list of binary-forecast questions in the same shape as the
5
+ bundled question_sets/*.json files:
6
+
7
+ {"id": "...", "query": "Will ...?", "ground_truth": 0.5, "category": "..."}
8
+
9
+ Modes
10
+ -----
11
+ - LLM mode (preferred): asks the configured model to draft N specific,
12
+ binary, falsifiable questions on the topic. Ground truth is set to
13
+ 0.5 for each (we don't know the answer; the panel forecasts and the
14
+ oracle resolves later from peer consensus).
15
+ - Synthetic mode: deterministic template generator. Used when
16
+ ``no_llm=True`` or when the LLM call fails. Slugs the topic and emits
17
+ N questions across a fixed set of forecast frames (growth, risk,
18
+ adoption, competition, regulation, sentiment).
19
+
20
+ The output is the **same dict shape** the existing scenario already
21
+ consumes via ``_load_questions``, so the persona panel doesn't need any
22
+ changes to handle generated questions.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import hashlib
28
+ import json
29
+ import os
30
+ import re
31
+ from dataclasses import dataclass
32
+ from pathlib import Path
33
+
34
+ import httpx
35
+
36
+ SYNTHETIC_FRAMES = [
37
+ ("growth", "Will {topic} see meaningful growth over the next 12 months?"),
38
+ ("risk", "Is there a major risk that {topic} fails or stalls in the next 12 months?"),
39
+ ("adoption", "Will adoption of {topic} significantly increase in the next year?"),
40
+ ("competition", "Will {topic} retain its competitive position over the next year?"),
41
+ ("regulation", "Will regulators take meaningful action on {topic} in the next 18 months?"),
42
+ ("sentiment", "Will public/market sentiment toward {topic} improve in the next 6 months?"),
43
+ ("cost", "Will the cost (financial or operational) of {topic} change materially this year?"),
44
+ ("technology", "Will the underlying technology behind {topic} see a major shift in 12 months?"),
45
+ ("opportunity", "Does {topic} represent a compelling opportunity vs alternatives?"),
46
+ ("longterm", "Will {topic} still be relevant 3 years from now?"),
47
+ ]
48
+
49
+
50
+ @dataclass
51
+ class GeneratedQuestion:
52
+ id: str
53
+ query: str
54
+ ground_truth: float
55
+ category: str
56
+
57
+
58
+ def _slug(s: str) -> str:
59
+ out = re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")
60
+ return out[:48] or "topic"
61
+
62
+
63
+ def _normalize_topic_for_template(topic: str) -> str:
64
+ """Strip leading 'Will/Does/Is/Can' and trailing '?' so the topic
65
+ plugs into our forecast frames as a noun phrase, not a sentence.
66
+ """
67
+ t = topic.strip().rstrip("?").strip()
68
+ for prefix in ("Will ", "Does ", "Is ", "Can ", "Should ", "Are ", "Has "):
69
+ if t.lower().startswith(prefix.lower()):
70
+ t = t[len(prefix) :].strip()
71
+ break
72
+ # Lowercase the first letter unless it's a proper noun (heuristic: keep if
73
+ # next chars are also upper, e.g. "AI", "EU", or it's a single word).
74
+ if t and t[0].isupper() and len(t) > 1 and not t[1].isupper():
75
+ t = t[0].lower() + t[1:]
76
+ return t
77
+
78
+
79
+ def _synthetic_questions(topic: str, n: int) -> list[dict]:
80
+ """Deterministic fallback.
81
+
82
+ If the topic itself ends with '?', use it as Q1 verbatim and generate
83
+ n−1 derived sub-questions framed around the cleaned subject. Otherwise
84
+ cycle through frames with the topic as a noun phrase.
85
+ """
86
+ slug = _slug(topic)
87
+ is_direct_question = topic.strip().endswith("?")
88
+ subject = _normalize_topic_for_template(topic)
89
+
90
+ out: list[dict] = []
91
+ if is_direct_question:
92
+ out.append(
93
+ {
94
+ "id": f"{slug}-001",
95
+ "query": topic.strip(),
96
+ "ground_truth": 0.5,
97
+ "category": "primary",
98
+ }
99
+ )
100
+ offset = 1
101
+ else:
102
+ offset = 0
103
+
104
+ for i in range(offset, n):
105
+ frame_id, template = SYNTHETIC_FRAMES[(i - offset) % len(SYNTHETIC_FRAMES)]
106
+ out.append(
107
+ {
108
+ "id": f"{slug}-{i + 1:03d}",
109
+ "query": template.format(topic=subject),
110
+ "ground_truth": 0.5,
111
+ "category": frame_id,
112
+ }
113
+ )
114
+ return out
115
+
116
+
117
+ _SYSTEM_PROMPT = (
118
+ "You are a forecasting analyst. Given a TOPIC, draft exactly N binary "
119
+ "(yes/no) forecast questions about it. Each question must be:\n"
120
+ " - specific (named entity or measurable claim)\n"
121
+ " - falsifiable within 6–24 months\n"
122
+ " - answerable as a probability (0..1)\n"
123
+ " - non-redundant with the others\n"
124
+ "Cover diverse angles: growth, risk, adoption, competition, regulation, "
125
+ "sentiment, technology, cost, geopolitics. Reply ONLY with a JSON array "
126
+ "of N objects, each: "
127
+ '{"query": "Will ...?", "category": "<one-word>"}. No prose, no markdown.'
128
+ )
129
+
130
+
131
+ async def _llm_questions(
132
+ topic: str, n: int, *, model: str, api_key: str, timeout_s: float = 30.0
133
+ ) -> list[dict]:
134
+ """Ask OpenRouter to draft N forecast questions.
135
+
136
+ Async — uses httpx.AsyncClient so the event loop stays responsive
137
+ while we wait on OpenRouter. Raises on any failure; the caller
138
+ should fall back to synthetic.
139
+ """
140
+ user = f"TOPIC: {topic}\nN: {n}"
141
+ body = {
142
+ "model": model,
143
+ "messages": [
144
+ {"role": "system", "content": _SYSTEM_PROMPT},
145
+ {"role": "user", "content": user},
146
+ ],
147
+ "temperature": 0.4,
148
+ "response_format": {"type": "json_object"},
149
+ }
150
+ headers = {
151
+ "Authorization": f"Bearer {api_key}",
152
+ "HTTP-Referer": "https://github.com/ZySec-AI/hypermind",
153
+ "X-Title": "HyperMind SimLab",
154
+ "Content-Type": "application/json",
155
+ }
156
+ async with httpx.AsyncClient(timeout=timeout_s) as client:
157
+ r = await client.post(
158
+ "https://openrouter.ai/api/v1/chat/completions",
159
+ json=body,
160
+ headers=headers,
161
+ )
162
+ r.raise_for_status()
163
+ payload = r.json()
164
+ text = payload["choices"][0]["message"]["content"]
165
+ parsed = json.loads(text)
166
+ # Some models wrap a top-level key; try common shapes.
167
+ if isinstance(parsed, dict):
168
+ for key in ("questions", "items", "data"):
169
+ if key in parsed and isinstance(parsed[key], list):
170
+ parsed = parsed[key]
171
+ break
172
+ if not isinstance(parsed, list):
173
+ raise ValueError(f"LLM returned non-list shape: {type(parsed).__name__}")
174
+ slug = _slug(topic)
175
+ out: list[dict] = []
176
+ for i, item in enumerate(parsed[:n]):
177
+ if not isinstance(item, dict) or "query" not in item:
178
+ continue
179
+ out.append(
180
+ {
181
+ "id": f"{slug}-{i + 1:03d}",
182
+ "query": str(item["query"]).strip(),
183
+ "ground_truth": 0.5,
184
+ "category": str(item.get("category", "general")).lower(),
185
+ }
186
+ )
187
+ if not out:
188
+ raise ValueError("LLM returned no usable questions")
189
+ # Top up with synthetic if the model returned fewer than asked
190
+ if len(out) < n:
191
+ out.extend(_synthetic_questions(topic, n - len(out)))
192
+ return out
193
+
194
+
195
+ def _question_cache_path(topic: str, n: int, model: str) -> Path:
196
+ key = hashlib.sha256(f"{topic}|{n}|{model}".encode()).hexdigest()[:16]
197
+ cache_dir = Path.home() / ".hypermind" / "question_cache"
198
+ cache_dir.mkdir(parents=True, exist_ok=True)
199
+ return cache_dir / f"{key}.json"
200
+
201
+
202
+ def _load_cached_questions(topic: str, n: int, model: str) -> list[dict] | None:
203
+ path = _question_cache_path(topic, n, model)
204
+ if path.exists():
205
+ try:
206
+ return json.loads(path.read_text())
207
+ except Exception:
208
+ pass
209
+ return None
210
+
211
+
212
+ def _save_cached_questions(topic: str, n: int, model: str, questions: list[dict]) -> None:
213
+ try:
214
+ _question_cache_path(topic, n, model).write_text(json.dumps(questions, indent=2))
215
+ except Exception:
216
+ pass
217
+
218
+
219
+ async def generate_questions_for_topic(
220
+ topic: str,
221
+ n: int,
222
+ *,
223
+ model: str | None = None,
224
+ no_llm: bool = False,
225
+ ) -> list[dict]:
226
+ """Public entry point.
227
+
228
+ Async because the LLM path uses httpx.AsyncClient so the event
229
+ loop stays responsive (otherwise the worker blocks all HTTP traffic
230
+ in the SimLab API for the duration of the OpenRouter call).
231
+
232
+ Returns a list of question dicts with the same shape as the bundled
233
+ question_sets JSON files. Falls back to synthetic on any LLM error.
234
+
235
+ Results are cached on disk keyed by (topic, n, model) so repeated
236
+ runs on the same topic skip the LLM question-gen round-trip entirely.
237
+ """
238
+ n = max(1, min(50, int(n)))
239
+ topic = (topic or "").strip()
240
+ if not topic:
241
+ raise ValueError("topic is required")
242
+ api_key = os.environ.get("OPENROUTER_API_KEY", "").strip()
243
+ if no_llm or not api_key or not model:
244
+ return _synthetic_questions(topic, n)
245
+
246
+ # Cache hit — same topic+n+model already generated
247
+ cached = _load_cached_questions(topic, n, model)
248
+ if cached is not None:
249
+ return cached
250
+
251
+ try:
252
+ questions = await _llm_questions(topic, n, model=model, api_key=api_key)
253
+ _save_cached_questions(topic, n, model, questions)
254
+ return questions
255
+ except Exception:
256
+ return _synthetic_questions(topic, n)
257
+
258
+
259
+ __all__ = ["GeneratedQuestion", "generate_questions_for_topic"]
@@ -0,0 +1,69 @@
1
+ """Persistence layer for HyperMind worlds.
2
+
3
+ A ``StorageBackend`` is an opt-in companion to ``_NamespaceWorld`` that
4
+ durably records every sealed claim, dispute, reputation snapshot, and
5
+ Merkle leaf. The default sandbox/testbed factories run with no backend
6
+ (in-memory only). Production agents wire one of:
7
+
8
+ * :class:`SqliteBackend` — single-file SQLite, zero extra deps.
9
+
10
+ The backend is a Protocol — operators can substitute Postgres, S3+RDS,
11
+ or a cloud KV without touching the SDK core.
12
+
13
+ Lifecycle:
14
+
15
+ 1. ``backend.open()`` — connect / create schema if missing.
16
+ 2. ``backend.append_claim(stmt)`` — called from ``publish_claim``.
17
+ 3. ``backend.append_dispute(disp)`` — called from ``dispute``.
18
+ 4. ``backend.snapshot_reputation(rep)`` — periodic checkpoint.
19
+ 5. ``backend.close()`` — flush + disconnect.
20
+
21
+ Crash recovery:
22
+
23
+ - On ``open()``, the backend re-loads sealed claims, disputes,
24
+ reputation posteriors, and Merkle leaves into the world.
25
+ - The Merkle tree is rebuilt by replaying leaves in append order
26
+ (RFC 6962 deterministic re-derivation).
27
+
28
+ This first iteration covers single-process durability. Multi-writer /
29
+ HA setups belong on a Postgres backend in v0.6+.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import os
35
+
36
+ from hypermind.storage.backend import StorageBackend
37
+ from hypermind.storage.relata import RelataBackend
38
+ from hypermind.storage.sqlite import SqliteBackend
39
+
40
+
41
+ def storage_from_env(*, namespace_id: str = "default") -> StorageBackend | None:
42
+ """Resolve the production storage backend from the environment.
43
+
44
+ RelataDB is the default *the moment it is configured*; SQLite and in-memory
45
+ stay one env var away. ``sandbox()`` / ``testbed()`` ignore this and remain
46
+ in-memory (zero-dep, no server) — only ``open(mode="production")`` calls it.
47
+
48
+ Precedence:
49
+
50
+ - ``HYPERMIND_STORAGE=relata`` (or any ``RELATA_URL`` set) → :class:`RelataBackend`
51
+ - ``HYPERMIND_STORAGE=sqlite:/path/to.db`` → :class:`SqliteBackend`
52
+ - unset → ``None`` (in-memory)
53
+ """
54
+ kind = os.getenv("HYPERMIND_STORAGE") or ("relata" if os.getenv("RELATA_URL") else "")
55
+ if kind == "relata":
56
+ url = os.getenv("RELATA_URL")
57
+ if not url:
58
+ raise RuntimeError("HYPERMIND_STORAGE=relata requires RELATA_URL to be set")
59
+ return RelataBackend(
60
+ url,
61
+ namespace_id=os.getenv("HYPERMIND_NAMESPACE", namespace_id),
62
+ bearer_token=os.getenv("RELATA_TOKEN"),
63
+ )
64
+ if kind.startswith("sqlite:"):
65
+ return SqliteBackend(kind[len("sqlite:") :])
66
+ return None
67
+
68
+
69
+ __all__ = ["RelataBackend", "SqliteBackend", "StorageBackend", "storage_from_env"]