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,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"]
|