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,523 @@
|
|
|
1
|
+
"""What-If counterfactual scenario tree mode.
|
|
2
|
+
|
|
3
|
+
The user enters a *hypothesis* ("UAE leaves OPEC in 2026") plus 2–3
|
|
4
|
+
*driver variables* with named values they want explored ("oil_price:
|
|
5
|
+
60/80/100", "us_saudi_relations: warm/cool/cold"). The mode produces:
|
|
6
|
+
|
|
7
|
+
* a **scenario tree** with one root and up to ``MAX_BRANCHES_PER_DRIVER``
|
|
8
|
+
children at each level (capped at depth :data:`MAX_DEPTH` so the
|
|
9
|
+
combinatorial explosion stays demo-legible);
|
|
10
|
+
* per-leaf **consensus probability** for the hypothesis under that
|
|
11
|
+
combination of driver values, plus **dissent counter-probability**
|
|
12
|
+
from the most-disagreeing reviewer;
|
|
13
|
+
* per-leaf **leading indicators** — concrete things the user can watch
|
|
14
|
+
for that would shift the leaf's posterior;
|
|
15
|
+
* an **anti-herding flag** when the panel converges below the §20.3
|
|
16
|
+
stdev floor (default 0.05) without any dissent — surfacing the case
|
|
17
|
+
where the swarm should have been polled harder.
|
|
18
|
+
|
|
19
|
+
Like ``governance``, this mode is deterministic given the input — no
|
|
20
|
+
LLM round-trip required. A future expansion can swap the
|
|
21
|
+
synthetic-scoring helpers for a real responder. The scoring is
|
|
22
|
+
intentionally explainable so the demo tree reads naturally.
|
|
23
|
+
|
|
24
|
+
Spec hooks:
|
|
25
|
+
* §20.3 Anti-Herding Trigger — surfaces in trace as ``anti_herding_fired``
|
|
26
|
+
* §19.5 DissentRecord — each leaf carries a dissent counter-probability
|
|
27
|
+
* §20.3 InformationGainPlanner — picks the next driver to query
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import hashlib
|
|
33
|
+
import json
|
|
34
|
+
import time
|
|
35
|
+
from dataclasses import asdict, dataclass
|
|
36
|
+
from itertools import product
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
from . import ModeSpec, register
|
|
41
|
+
|
|
42
|
+
MAX_BRANCHES_PER_DRIVER = 3
|
|
43
|
+
MAX_DEPTH = 2
|
|
44
|
+
ANTI_HERDING_FLOOR = 0.05
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# --- Result types ----------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class LeadingIndicator:
|
|
52
|
+
name: str
|
|
53
|
+
direction: str # "if rises" | "if falls" | "if observed"
|
|
54
|
+
points_toward: str # branch label this indicator strengthens
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class BranchDissent:
|
|
59
|
+
reviewer_role: str # "scout" | "sceptic" | "synthesist" | "counterfactualist"
|
|
60
|
+
counter_probability: float
|
|
61
|
+
rationale: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class ScenarioBranch:
|
|
66
|
+
branch_id: str
|
|
67
|
+
path: list[dict[str, str]] # [{driver: value}, ...] — drivers fixed at this leaf
|
|
68
|
+
consensus_probability: float
|
|
69
|
+
confidence: float
|
|
70
|
+
dissents: list[BranchDissent]
|
|
71
|
+
leading_indicators: list[LeadingIndicator]
|
|
72
|
+
anti_herding_fired: bool
|
|
73
|
+
rationale: str
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class WhatIfResult:
|
|
78
|
+
hypothesis: str
|
|
79
|
+
horizon_days: int
|
|
80
|
+
drivers: list[dict[str, Any]] # input drivers
|
|
81
|
+
branches: list[ScenarioBranch]
|
|
82
|
+
most_likely_path: list[dict[str, str]]
|
|
83
|
+
most_likely_probability: float
|
|
84
|
+
information_gain_next_driver: str | None # which driver to pin first
|
|
85
|
+
information_gain_score: float
|
|
86
|
+
panel_size: int
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# --- Input schema ----------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
INPUT_SCHEMA: dict[str, Any] = {
|
|
92
|
+
"type": "object",
|
|
93
|
+
"title": "What-If counterfactual scenario tree",
|
|
94
|
+
"required": ["hypothesis", "drivers"],
|
|
95
|
+
"properties": {
|
|
96
|
+
"hypothesis": {
|
|
97
|
+
"type": "string",
|
|
98
|
+
"title": "Hypothesis",
|
|
99
|
+
"format": "textarea",
|
|
100
|
+
"description": (
|
|
101
|
+
"The thing you want the swarm to evaluate under each "
|
|
102
|
+
"branch. e.g. 'UAE leaves OPEC by end of 2026'."
|
|
103
|
+
),
|
|
104
|
+
},
|
|
105
|
+
"horizon_days": {
|
|
106
|
+
"type": "integer",
|
|
107
|
+
"title": "Time horizon (days)",
|
|
108
|
+
"minimum": 7,
|
|
109
|
+
"maximum": 3650,
|
|
110
|
+
"default": 365,
|
|
111
|
+
},
|
|
112
|
+
"drivers": {
|
|
113
|
+
"type": "array",
|
|
114
|
+
"title": "Driver variables (2–3, comma-separated values per driver)",
|
|
115
|
+
"description": (
|
|
116
|
+
"Each driver: 'name=val1|val2|val3'. e.g. "
|
|
117
|
+
"'oil_price=60|80|100,us_saudi=warm|cool|cold'. Max 3 "
|
|
118
|
+
"drivers, 3 values each."
|
|
119
|
+
),
|
|
120
|
+
"examples": ["oil_price=60|80|100", "us_saudi=warm|cool|cold"],
|
|
121
|
+
"default": [],
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
RESULT_SCHEMA: dict[str, Any] = {
|
|
128
|
+
"type": "object",
|
|
129
|
+
"properties": {
|
|
130
|
+
"mode": {"type": "string", "const": "what-if-tree"},
|
|
131
|
+
"hypothesis": {"type": "string"},
|
|
132
|
+
"horizon_days": {"type": "integer"},
|
|
133
|
+
"branches": {"type": "array"},
|
|
134
|
+
"most_likely_path": {"type": "array"},
|
|
135
|
+
"most_likely_probability": {"type": "number"},
|
|
136
|
+
"information_gain_next_driver": {"type": ["string", "null"]},
|
|
137
|
+
"anti_herding_fired_anywhere": {"type": "boolean"},
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# --- Driver parsing --------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _parse_drivers(raw: list[str]) -> list[dict[str, Any]]:
|
|
146
|
+
"""Parse 'name=v1|v2|v3' strings into structured drivers.
|
|
147
|
+
|
|
148
|
+
Permissive: discards malformed entries. Capped at MAX_DEPTH drivers
|
|
149
|
+
and MAX_BRANCHES_PER_DRIVER values per driver to keep the tree from
|
|
150
|
+
exploding.
|
|
151
|
+
"""
|
|
152
|
+
out: list[dict[str, Any]] = []
|
|
153
|
+
for item in raw[: MAX_DEPTH + 1]: # one extra for the IG-suggestion driver
|
|
154
|
+
if not item or "=" not in item:
|
|
155
|
+
continue
|
|
156
|
+
name, _, vals = item.partition("=")
|
|
157
|
+
name = name.strip()
|
|
158
|
+
values = [v.strip() for v in vals.split("|") if v.strip()]
|
|
159
|
+
if not name or not values:
|
|
160
|
+
continue
|
|
161
|
+
values = values[:MAX_BRANCHES_PER_DRIVER]
|
|
162
|
+
out.append({"name": name, "values": values})
|
|
163
|
+
return out
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _seed_drivers_from_hypothesis(hypothesis: str) -> list[dict[str, Any]]:
|
|
167
|
+
"""When the user gives no drivers, fall back to two synthetic ones
|
|
168
|
+
so the demo always produces a tree."""
|
|
169
|
+
h = hypothesis.lower()
|
|
170
|
+
if "opec" in h or "oil" in h:
|
|
171
|
+
return [
|
|
172
|
+
{"name": "oil_price_usd", "values": ["60", "80", "100"]},
|
|
173
|
+
{"name": "us_saudi_relations", "values": ["warm", "cool", "cold"]},
|
|
174
|
+
]
|
|
175
|
+
if "fed" in h or "rates" in h or "inflation" in h:
|
|
176
|
+
return [
|
|
177
|
+
{"name": "core_inflation_yoy", "values": ["below_2", "2_to_3", "above_3"]},
|
|
178
|
+
{"name": "labor_market", "values": ["soft", "balanced", "tight"]},
|
|
179
|
+
]
|
|
180
|
+
if "cyber" in h or "incident" in h or "breach" in h:
|
|
181
|
+
return [
|
|
182
|
+
{"name": "actor_capability", "values": ["nation_state", "criminal", "insider"]},
|
|
183
|
+
{"name": "blast_radius", "values": ["single_tenant", "regional", "global"]},
|
|
184
|
+
]
|
|
185
|
+
return [
|
|
186
|
+
{"name": "key_driver_a", "values": ["low", "base", "high"]},
|
|
187
|
+
{"name": "key_driver_b", "values": ["bear", "base", "bull"]},
|
|
188
|
+
]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# --- Synthetic scoring -----------------------------------------------------
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _value_lift(driver_name: str, value: str) -> float:
|
|
195
|
+
"""Map a driver value to a [-0.30, +0.30] lift on the hypothesis
|
|
196
|
+
posterior. Deterministic — same value always yields the same lift.
|
|
197
|
+
Encodes a small amount of domain intuition for the demo:
|
|
198
|
+
|
|
199
|
+
* Extreme tail values ("cold", "above_3", "global") shift the
|
|
200
|
+
probability further from the base case in the direction implied
|
|
201
|
+
by the value's polarity.
|
|
202
|
+
* Stable / "base" values are lift=0.
|
|
203
|
+
* The hash makes the mapping deterministic for any value the user
|
|
204
|
+
types that we don't have an explicit rule for.
|
|
205
|
+
"""
|
|
206
|
+
polarity = {
|
|
207
|
+
"warm": +0.10,
|
|
208
|
+
"cool": -0.05,
|
|
209
|
+
"cold": -0.20,
|
|
210
|
+
"soft": -0.10,
|
|
211
|
+
"balanced": 0.0,
|
|
212
|
+
"tight": +0.15,
|
|
213
|
+
"below_2": -0.10,
|
|
214
|
+
"2_to_3": 0.0,
|
|
215
|
+
"above_3": +0.20,
|
|
216
|
+
"60": -0.15,
|
|
217
|
+
"80": 0.0,
|
|
218
|
+
"100": +0.18,
|
|
219
|
+
"low": -0.15,
|
|
220
|
+
"base": 0.0,
|
|
221
|
+
"high": +0.18,
|
|
222
|
+
"bear": -0.20,
|
|
223
|
+
"bull": +0.20,
|
|
224
|
+
"nation_state": +0.18,
|
|
225
|
+
"criminal": +0.05,
|
|
226
|
+
"insider": +0.10,
|
|
227
|
+
"single_tenant": -0.15,
|
|
228
|
+
"regional": 0.0,
|
|
229
|
+
"global": +0.25,
|
|
230
|
+
}
|
|
231
|
+
if value in polarity:
|
|
232
|
+
return polarity[value]
|
|
233
|
+
# Unknown values get a small deterministic pseudo-lift in [-0.10, 0.10]
|
|
234
|
+
h = int(hashlib.sha256(f"{driver_name}::{value}".encode()).hexdigest(), 16)
|
|
235
|
+
return ((h % 21) - 10) / 100.0 # [-0.10, 0.10]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _branch_consensus(
|
|
239
|
+
base_prior: float,
|
|
240
|
+
path: list[dict[str, str]],
|
|
241
|
+
) -> tuple[float, float]:
|
|
242
|
+
"""Compose per-branch lift from the path. Returns (mean, stdev)."""
|
|
243
|
+
means: list[float] = []
|
|
244
|
+
for _ in range(4): # synthesise 4 reviewers w/ small noise
|
|
245
|
+
m = base_prior
|
|
246
|
+
for step in path:
|
|
247
|
+
for d, v in step.items():
|
|
248
|
+
m += _value_lift(d, v)
|
|
249
|
+
means.append(max(0.0, min(1.0, m)))
|
|
250
|
+
# Add a small dispersion so the panel isn't perfectly converged.
|
|
251
|
+
n = len(means)
|
|
252
|
+
mean = sum(means) / n
|
|
253
|
+
var = sum((x - mean) ** 2 for x in means) / max(n - 1, 1)
|
|
254
|
+
return mean, var**0.5
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _dissent_for_branch(
|
|
258
|
+
consensus: float,
|
|
259
|
+
path: list[dict[str, str]],
|
|
260
|
+
) -> list[BranchDissent]:
|
|
261
|
+
"""The Counterfactualist always asks 'what would have to be true
|
|
262
|
+
for the opposite to happen?' — and records that as a dissent when
|
|
263
|
+
the panel's posterior is strongly one-sided (so the demo always
|
|
264
|
+
has a counter-narrative to read)."""
|
|
265
|
+
dissents: list[BranchDissent] = []
|
|
266
|
+
if consensus >= 0.65:
|
|
267
|
+
# Counterfactualist argues the downside.
|
|
268
|
+
dissents.append(
|
|
269
|
+
BranchDissent(
|
|
270
|
+
reviewer_role="counterfactualist",
|
|
271
|
+
counter_probability=round(max(0.05, 1.0 - consensus - 0.10), 3),
|
|
272
|
+
rationale=(
|
|
273
|
+
"Counterfactual: assumes drivers stay anchored at this "
|
|
274
|
+
"branch. A regime change would invalidate the lift; "
|
|
275
|
+
"tail risk is asymmetric here."
|
|
276
|
+
),
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
elif consensus <= 0.35:
|
|
280
|
+
# Counterfactualist argues the upside.
|
|
281
|
+
dissents.append(
|
|
282
|
+
BranchDissent(
|
|
283
|
+
reviewer_role="counterfactualist",
|
|
284
|
+
counter_probability=round(min(0.95, consensus + 0.30), 3),
|
|
285
|
+
rationale=(
|
|
286
|
+
"Counterfactual: assumes the structural arguments "
|
|
287
|
+
"outweigh political surprises. A single high-impact "
|
|
288
|
+
"catalyst could move this leaf upward."
|
|
289
|
+
),
|
|
290
|
+
)
|
|
291
|
+
)
|
|
292
|
+
return dissents
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _leading_indicators(path: list[dict[str, str]]) -> list[LeadingIndicator]:
|
|
296
|
+
"""Tie each driver value to a watchable signal."""
|
|
297
|
+
out: list[LeadingIndicator] = []
|
|
298
|
+
for step in path:
|
|
299
|
+
for d, v in step.items():
|
|
300
|
+
label = f"{d}={v}"
|
|
301
|
+
if d == "oil_price_usd":
|
|
302
|
+
out.append(
|
|
303
|
+
LeadingIndicator(
|
|
304
|
+
name="Brent crude weekly close",
|
|
305
|
+
direction="if rises"
|
|
306
|
+
if v in {"100", "high"}
|
|
307
|
+
else "if falls"
|
|
308
|
+
if v in {"60", "low"}
|
|
309
|
+
else "if observed",
|
|
310
|
+
points_toward=label,
|
|
311
|
+
)
|
|
312
|
+
)
|
|
313
|
+
elif d == "us_saudi_relations":
|
|
314
|
+
out.append(
|
|
315
|
+
LeadingIndicator(
|
|
316
|
+
name="Joint statements / sanctions activity",
|
|
317
|
+
direction="if observed",
|
|
318
|
+
points_toward=label,
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
elif d == "core_inflation_yoy":
|
|
322
|
+
out.append(
|
|
323
|
+
LeadingIndicator(
|
|
324
|
+
name="Core PCE month-over-month",
|
|
325
|
+
direction="if rises"
|
|
326
|
+
if v == "above_3"
|
|
327
|
+
else "if falls"
|
|
328
|
+
if v == "below_2"
|
|
329
|
+
else "if observed",
|
|
330
|
+
points_toward=label,
|
|
331
|
+
)
|
|
332
|
+
)
|
|
333
|
+
elif d == "actor_capability":
|
|
334
|
+
out.append(
|
|
335
|
+
LeadingIndicator(
|
|
336
|
+
name="MITRE ATT&CK technique fingerprints",
|
|
337
|
+
direction="if observed",
|
|
338
|
+
points_toward=label,
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
else:
|
|
342
|
+
out.append(
|
|
343
|
+
LeadingIndicator(
|
|
344
|
+
name=f"{d} indicator",
|
|
345
|
+
direction="if observed",
|
|
346
|
+
points_toward=label,
|
|
347
|
+
)
|
|
348
|
+
)
|
|
349
|
+
return out
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# --- Information-gain helper ----------------------------------------------
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _information_gain(
|
|
356
|
+
drivers: list[dict[str, Any]],
|
|
357
|
+
branches: list[ScenarioBranch],
|
|
358
|
+
) -> tuple[str | None, float]:
|
|
359
|
+
"""Approximate the ``InformationGainPlanner`` for the demo: which
|
|
360
|
+
driver, when pinned to a value, narrows the posterior most? We
|
|
361
|
+
score each driver by the variance of branch posteriors over its
|
|
362
|
+
values, holding the other drivers averaged out. The driver with
|
|
363
|
+
the *highest* variance is the one worth querying first.
|
|
364
|
+
|
|
365
|
+
This is a synthetic mirror of ``mind.active.InformationGainPlanner``;
|
|
366
|
+
a future patch will route through the real planner.
|
|
367
|
+
"""
|
|
368
|
+
if not drivers or not branches:
|
|
369
|
+
return None, 0.0
|
|
370
|
+
best_name: str | None = None
|
|
371
|
+
best_score = -1.0
|
|
372
|
+
for d in drivers:
|
|
373
|
+
groups: dict[str, list[float]] = {v: [] for v in d["values"]}
|
|
374
|
+
for b in branches:
|
|
375
|
+
for step in b.path:
|
|
376
|
+
if d["name"] in step and step[d["name"]] in groups:
|
|
377
|
+
groups[step[d["name"]]].append(b.consensus_probability)
|
|
378
|
+
means = [sum(g) / len(g) if g else 0.5 for g in groups.values()]
|
|
379
|
+
if not means:
|
|
380
|
+
continue
|
|
381
|
+
m = sum(means) / len(means)
|
|
382
|
+
v = sum((x - m) ** 2 for x in means) / max(len(means) - 1, 1)
|
|
383
|
+
if v > best_score:
|
|
384
|
+
best_score = v
|
|
385
|
+
best_name = d["name"]
|
|
386
|
+
return best_name, round(best_score, 4)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
# --- Run function ----------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
async def _run_whatif(args: Any, recorder: Any | None = None) -> None:
|
|
393
|
+
payload = getattr(args, "mode_payload", None) or {}
|
|
394
|
+
hypothesis = (payload.get("hypothesis") or "").strip()
|
|
395
|
+
if not hypothesis:
|
|
396
|
+
hypothesis = "UAE leaves OPEC by end of 2026."
|
|
397
|
+
horizon_days = int(payload.get("horizon_days") or 365)
|
|
398
|
+
|
|
399
|
+
raw_drivers = payload.get("drivers") or []
|
|
400
|
+
if isinstance(raw_drivers, str):
|
|
401
|
+
raw_drivers = [raw_drivers]
|
|
402
|
+
drivers = _parse_drivers(list(raw_drivers))
|
|
403
|
+
if not drivers:
|
|
404
|
+
drivers = _seed_drivers_from_hypothesis(hypothesis)
|
|
405
|
+
drivers = drivers[:MAX_DEPTH] # hard cap
|
|
406
|
+
|
|
407
|
+
started_at = time.time()
|
|
408
|
+
|
|
409
|
+
# Base prior derived from the hypothesis itself — synthetic but
|
|
410
|
+
# deterministic. Encodes a small amount of "this kind of claim is
|
|
411
|
+
# usually true 30% of the time" prior so the tree isn't centered.
|
|
412
|
+
h_hash = int(hashlib.sha256(hypothesis.encode("utf-8")).hexdigest(), 16)
|
|
413
|
+
base_prior = 0.20 + (h_hash % 41) / 100.0 # [0.20, 0.60]
|
|
414
|
+
|
|
415
|
+
# Cartesian over all drivers — every combination becomes a leaf.
|
|
416
|
+
value_grid = [d["values"] for d in drivers]
|
|
417
|
+
leaves: list[ScenarioBranch] = []
|
|
418
|
+
anti_herding_anywhere = False
|
|
419
|
+
for combo in product(*value_grid):
|
|
420
|
+
path = [{drivers[i]["name"]: combo[i]} for i in range(len(drivers))]
|
|
421
|
+
mean, stdev = _branch_consensus(base_prior, path)
|
|
422
|
+
dissents = _dissent_for_branch(mean, path)
|
|
423
|
+
anti_herding = (stdev < ANTI_HERDING_FLOOR) and not dissents
|
|
424
|
+
if anti_herding:
|
|
425
|
+
anti_herding_anywhere = True
|
|
426
|
+
# Force a counterfactual on suspicious-converged branches —
|
|
427
|
+
# this is the §20.3 anti-herding intervention surfaced.
|
|
428
|
+
dissents.append(
|
|
429
|
+
BranchDissent(
|
|
430
|
+
reviewer_role="counterfactualist",
|
|
431
|
+
counter_probability=round(
|
|
432
|
+
max(0.05, mean - 0.20) if mean >= 0.5 else min(0.95, mean + 0.20),
|
|
433
|
+
3,
|
|
434
|
+
),
|
|
435
|
+
rationale=(
|
|
436
|
+
"Anti-herding triggered: panel converged below the "
|
|
437
|
+
f"{ANTI_HERDING_FLOOR:.2f} stdev floor without "
|
|
438
|
+
"spontaneous dissent. The Counterfactualist was "
|
|
439
|
+
"consulted to surface a minority signal."
|
|
440
|
+
),
|
|
441
|
+
)
|
|
442
|
+
)
|
|
443
|
+
branch_id = "/".join(f"{d['name']}={v}" for d, v in zip(drivers, combo, strict=True))
|
|
444
|
+
leaves.append(
|
|
445
|
+
ScenarioBranch(
|
|
446
|
+
branch_id=branch_id,
|
|
447
|
+
path=path,
|
|
448
|
+
consensus_probability=round(mean, 4),
|
|
449
|
+
confidence=round(max(0.0, 1.0 - stdev * 5.0), 3),
|
|
450
|
+
dissents=dissents,
|
|
451
|
+
leading_indicators=_leading_indicators(path),
|
|
452
|
+
anti_herding_fired=anti_herding,
|
|
453
|
+
rationale=(
|
|
454
|
+
f"Under {branch_id}, the panel's posterior on the "
|
|
455
|
+
f"hypothesis sits at {mean:.2f} (stdev {stdev:.3f})."
|
|
456
|
+
),
|
|
457
|
+
)
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
# Most-likely path = leaf with highest consensus.
|
|
461
|
+
best = max(leaves, key=lambda b: b.consensus_probability)
|
|
462
|
+
ig_driver, ig_score = _information_gain(drivers, leaves)
|
|
463
|
+
|
|
464
|
+
result = WhatIfResult(
|
|
465
|
+
hypothesis=hypothesis,
|
|
466
|
+
horizon_days=horizon_days,
|
|
467
|
+
drivers=drivers,
|
|
468
|
+
branches=leaves,
|
|
469
|
+
most_likely_path=best.path,
|
|
470
|
+
most_likely_probability=best.consensus_probability,
|
|
471
|
+
information_gain_next_driver=ig_driver,
|
|
472
|
+
information_gain_score=ig_score,
|
|
473
|
+
panel_size=4,
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
elapsed = time.time() - started_at
|
|
477
|
+
trace = {
|
|
478
|
+
"mode": "what-if-tree",
|
|
479
|
+
"started_at": started_at,
|
|
480
|
+
"elapsed_s": round(elapsed, 3),
|
|
481
|
+
"n_agents": result.panel_size,
|
|
482
|
+
"n_questions": len(leaves),
|
|
483
|
+
"hypothesis": hypothesis,
|
|
484
|
+
"horizon_days": horizon_days,
|
|
485
|
+
"drivers": drivers,
|
|
486
|
+
"branches": [asdict(b) for b in leaves],
|
|
487
|
+
"most_likely_path": best.path,
|
|
488
|
+
"most_likely_probability": best.consensus_probability,
|
|
489
|
+
"information_gain_next_driver": ig_driver,
|
|
490
|
+
"information_gain_score": ig_score,
|
|
491
|
+
"anti_herding_fired_anywhere": anti_herding_anywhere,
|
|
492
|
+
"collective_iq": {
|
|
493
|
+
# Surfaced for parity with binary traces consumed by SimList.
|
|
494
|
+
"score": round(
|
|
495
|
+
sum(b.consensus_probability for b in leaves) / len(leaves),
|
|
496
|
+
4,
|
|
497
|
+
),
|
|
498
|
+
},
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
Path(args.trace).write_text(json.dumps(trace, indent=2, default=str))
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
# --- Registration ----------------------------------------------------------
|
|
505
|
+
|
|
506
|
+
SPEC = ModeSpec(
|
|
507
|
+
slug="what-if-tree",
|
|
508
|
+
label="What-If Scenario Tree",
|
|
509
|
+
description=(
|
|
510
|
+
"Explore a hypothesis under 2 driver variables, each with up to "
|
|
511
|
+
"3 values. The swarm assigns a per-branch consensus probability "
|
|
512
|
+
"with dissent counter-probabilities, leading indicators, and an "
|
|
513
|
+
"InformationGainPlanner suggestion for which driver to pin next."
|
|
514
|
+
),
|
|
515
|
+
input_schema=INPUT_SCHEMA,
|
|
516
|
+
result_schema=RESULT_SCHEMA,
|
|
517
|
+
run_fn=_run_whatif,
|
|
518
|
+
uses_legacy_fields=False,
|
|
519
|
+
expected_roles=["scout", "sceptic", "synthesist", "counterfactualist"],
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
register(SPEC)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Namespace registry — each namespace has a slug, label, and DeploymentProfile."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import threading
|
|
8
|
+
from dataclasses import asdict, dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
from .registry import home
|
|
13
|
+
|
|
14
|
+
_ns_lock = threading.Lock()
|
|
15
|
+
|
|
16
|
+
_SLUG_RE = re.compile(r"^[a-z][a-z0-9\-]{0,62}$")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class DeploymentProfile:
|
|
21
|
+
tier: Literal["local", "server", "decentralised"] = "local"
|
|
22
|
+
storage_url: str | None = None # None=~/.hypermind; sqlite:///path; postgres://...
|
|
23
|
+
visibility: Literal["private", "shared", "public"] = "private"
|
|
24
|
+
anchor_chain: str | None = None # "base" | "ethereum" | "polygon"
|
|
25
|
+
anchor_contract: str | None = None
|
|
26
|
+
anchor_rpc_url: str | None = None
|
|
27
|
+
|
|
28
|
+
def to_dict(self) -> dict:
|
|
29
|
+
return {k: v for k, v in asdict(self).items() if v is not None or k == "tier"}
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_dict(cls, d: dict) -> DeploymentProfile:
|
|
33
|
+
valid_tiers = {"local", "server", "decentralised"}
|
|
34
|
+
tier = d.get("tier", "local")
|
|
35
|
+
if tier not in valid_tiers:
|
|
36
|
+
raise ValueError(f"Invalid tier: {tier!r}")
|
|
37
|
+
return cls(
|
|
38
|
+
tier=tier,
|
|
39
|
+
storage_url=d.get("storage_url"),
|
|
40
|
+
visibility=d.get("visibility", "private"),
|
|
41
|
+
anchor_chain=d.get("anchor_chain"),
|
|
42
|
+
anchor_contract=d.get("anchor_contract"),
|
|
43
|
+
anchor_rpc_url=d.get("anchor_rpc_url"),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Namespace:
|
|
49
|
+
slug: str
|
|
50
|
+
label: str
|
|
51
|
+
profile: DeploymentProfile = field(default_factory=DeploymentProfile)
|
|
52
|
+
created_at_ms: int | None = None
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict:
|
|
55
|
+
return {
|
|
56
|
+
"slug": self.slug,
|
|
57
|
+
"label": self.label,
|
|
58
|
+
"profile": self.profile.to_dict(),
|
|
59
|
+
"created_at_ms": self.created_at_ms,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def from_dict(cls, d: dict) -> Namespace:
|
|
64
|
+
return cls(
|
|
65
|
+
slug=d["slug"],
|
|
66
|
+
label=d.get("label", d["slug"]),
|
|
67
|
+
profile=DeploymentProfile.from_dict(d.get("profile", {})),
|
|
68
|
+
created_at_ms=d.get("created_at_ms"),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _namespaces_dir() -> Path:
|
|
73
|
+
d = home() / "namespaces"
|
|
74
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
return d
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _ns_path(slug: str) -> Path:
|
|
79
|
+
return _namespaces_dir() / f"{slug}.json"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def list_namespaces() -> list[Namespace]:
|
|
83
|
+
ns = []
|
|
84
|
+
for p in sorted(_namespaces_dir().glob("*.json")):
|
|
85
|
+
try:
|
|
86
|
+
ns.append(Namespace.from_dict(json.loads(p.read_text())))
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
if not ns:
|
|
90
|
+
# Auto-create default namespace on first call
|
|
91
|
+
default = Namespace(
|
|
92
|
+
slug="default", label="Default", profile=DeploymentProfile(tier="local")
|
|
93
|
+
)
|
|
94
|
+
save_namespace(default)
|
|
95
|
+
ns = [default]
|
|
96
|
+
return ns
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_namespace(slug: str) -> Namespace | None:
|
|
100
|
+
p = _ns_path(slug)
|
|
101
|
+
if not p.exists():
|
|
102
|
+
return None
|
|
103
|
+
return Namespace.from_dict(json.loads(p.read_text()))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def save_namespace(ns: Namespace) -> None:
|
|
107
|
+
import time
|
|
108
|
+
|
|
109
|
+
with _ns_lock:
|
|
110
|
+
if ns.created_at_ms is None:
|
|
111
|
+
ns.created_at_ms = int(time.time() * 1000)
|
|
112
|
+
_ns_path(ns.slug).write_text(json.dumps(ns.to_dict(), indent=2))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def validate_slug(slug: str) -> None:
|
|
116
|
+
if not _SLUG_RE.match(slug):
|
|
117
|
+
raise ValueError(f"Invalid namespace slug {slug!r} — must match [a-z][a-z0-9-]{{0,62}}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def ensure_default() -> None:
|
|
121
|
+
"""Call at server startup to guarantee 'default' namespace exists."""
|
|
122
|
+
if not _ns_path("default").exists():
|
|
123
|
+
save_namespace(
|
|
124
|
+
Namespace(slug="default", label="Default", profile=DeploymentProfile(tier="local"))
|
|
125
|
+
)
|