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,610 @@
|
|
|
1
|
+
"""Backtest Harness mode — predicted vs. actual + improvement recommendations.
|
|
2
|
+
|
|
3
|
+
The user submits a *historical scenario* split into:
|
|
4
|
+
* a ``T0`` window — events visible to the swarm (the input)
|
|
5
|
+
* an ``actual_outcome`` — what really happened (the ground truth)
|
|
6
|
+
* a list of ``forecast_questions`` — binary probabilities the swarm
|
|
7
|
+
must produce, each with an ``actual`` answer (0 or 1)
|
|
8
|
+
|
|
9
|
+
The mode replays the questions through a synthetic 4-role panel
|
|
10
|
+
(Scout/Sceptic/Synthesist/Mediator), captures per-role posteriors,
|
|
11
|
+
then scores against the actuals. Output is a **calibration scorecard**:
|
|
12
|
+
|
|
13
|
+
* **Brier loss** (overall + per-role)
|
|
14
|
+
* **Log loss**
|
|
15
|
+
* **Calibration curve** — predicted-bucket vs. actual-rate
|
|
16
|
+
* **Time-to-truth** — how many rounds until the panel posterior
|
|
17
|
+
crossed the actual answer (proxy)
|
|
18
|
+
* **Anti-herding catches** — questions where the dissenter's
|
|
19
|
+
posterior was closer to truth than the consensus
|
|
20
|
+
* **Per-role attribution** — drop-one Brier delta per role:
|
|
21
|
+
*"if we removed this role, would the swarm be better or worse?"*
|
|
22
|
+
* **Improvement recommendations** — actionable suggestions derived
|
|
23
|
+
from the scorecard (e.g. "reduce Sceptic weight on this scenario
|
|
24
|
+
class")
|
|
25
|
+
|
|
26
|
+
This is the mode that turns the protocol from a demo into evidence.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import hashlib
|
|
32
|
+
import json
|
|
33
|
+
import math
|
|
34
|
+
import time
|
|
35
|
+
from dataclasses import asdict, dataclass
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
from . import ModeSpec, register
|
|
40
|
+
|
|
41
|
+
# --- Roles -----------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
ROLES: list[dict[str, Any]] = [
|
|
44
|
+
{
|
|
45
|
+
"slug": "scout",
|
|
46
|
+
"label": "Scout",
|
|
47
|
+
"bias": "anchored on the most-recent evidence",
|
|
48
|
+
"weight_recency": 0.25,
|
|
49
|
+
"weight_prior": -0.10,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"slug": "sceptic",
|
|
53
|
+
"label": "Sceptic",
|
|
54
|
+
"bias": "down-weights confident claims",
|
|
55
|
+
"weight_recency": -0.15,
|
|
56
|
+
"weight_prior": +0.10,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"slug": "synthesist",
|
|
60
|
+
"label": "Synthesist",
|
|
61
|
+
"bias": "balances scout and sceptic",
|
|
62
|
+
"weight_recency": +0.05,
|
|
63
|
+
"weight_prior": 0.0,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"slug": "mediator",
|
|
67
|
+
"label": "Mediator",
|
|
68
|
+
"bias": "calibrates against base rates",
|
|
69
|
+
"weight_recency": 0.0,
|
|
70
|
+
"weight_prior": +0.15,
|
|
71
|
+
},
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# --- Result types ----------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class QuestionResult:
|
|
80
|
+
question_id: str
|
|
81
|
+
question: str
|
|
82
|
+
actual: int # 0 or 1
|
|
83
|
+
consensus_posterior: float # in [0,1]
|
|
84
|
+
per_role_posteriors: dict[str, float]
|
|
85
|
+
brier_consensus: float
|
|
86
|
+
brier_per_role: dict[str, float]
|
|
87
|
+
log_loss_consensus: float
|
|
88
|
+
rounds_to_cross: int # how many simulated rounds until consensus crossed actual
|
|
89
|
+
dissent_was_correct: bool # was the dissenter closer to actual than consensus?
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class CalibrationBucket:
|
|
94
|
+
bucket_low: float # e.g. 0.0
|
|
95
|
+
bucket_high: float # e.g. 0.1
|
|
96
|
+
n_predictions: int
|
|
97
|
+
mean_predicted: float
|
|
98
|
+
actual_rate: float # fraction of bucket entries whose actual==1
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class RoleAttribution:
|
|
103
|
+
role: str
|
|
104
|
+
role_label: str
|
|
105
|
+
brier_with: float # panel Brier including this role
|
|
106
|
+
brier_without: float # panel Brier with this role removed (counterfactual)
|
|
107
|
+
delta: float # without - with; positive = role IMPROVES the panel
|
|
108
|
+
contribution_label: str # human-readable: "improves" | "neutral" | "drags down"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class ImprovementRec:
|
|
113
|
+
rec_id: str
|
|
114
|
+
severity: str # info|low|medium|high
|
|
115
|
+
title: str
|
|
116
|
+
rationale: str
|
|
117
|
+
action: str
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class BacktestReport:
|
|
122
|
+
scenario_label: str
|
|
123
|
+
t0_summary: str
|
|
124
|
+
actual_outcome: str
|
|
125
|
+
n_questions: int
|
|
126
|
+
overall_brier: float
|
|
127
|
+
overall_log_loss: float
|
|
128
|
+
accuracy_at_50: float # fraction where (predicted >= 0.5) == actual
|
|
129
|
+
calibration_curve: list[CalibrationBucket]
|
|
130
|
+
per_role_attribution: list[RoleAttribution]
|
|
131
|
+
questions: list[QuestionResult]
|
|
132
|
+
anti_herding_catches: list[str] # question_ids where dissent was correct
|
|
133
|
+
time_to_truth_mean: float
|
|
134
|
+
improvement_recommendations: list[ImprovementRec]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# --- Input schema ---------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
INPUT_SCHEMA: dict[str, Any] = {
|
|
140
|
+
"type": "object",
|
|
141
|
+
"title": "Backtest Harness",
|
|
142
|
+
"required": ["scenario_label", "questions"],
|
|
143
|
+
"properties": {
|
|
144
|
+
"scenario_label": {
|
|
145
|
+
"type": "string",
|
|
146
|
+
"title": "Scenario label",
|
|
147
|
+
"description": (
|
|
148
|
+
"Short name. e.g. '2024-Q4 cloud outage replay' or "
|
|
149
|
+
"'2026-03-15 supply chain stress test'."
|
|
150
|
+
),
|
|
151
|
+
},
|
|
152
|
+
"t0_summary": {
|
|
153
|
+
"type": "string",
|
|
154
|
+
"title": "T0 evidence visible to the swarm",
|
|
155
|
+
"format": "textarea",
|
|
156
|
+
"description": "What the swarm knew at the time of forecast.",
|
|
157
|
+
"default": "",
|
|
158
|
+
},
|
|
159
|
+
"actual_outcome": {
|
|
160
|
+
"type": "string",
|
|
161
|
+
"title": "Actual outcome (revealed after T1)",
|
|
162
|
+
"format": "textarea",
|
|
163
|
+
"description": "What actually happened.",
|
|
164
|
+
"default": "",
|
|
165
|
+
},
|
|
166
|
+
"questions": {
|
|
167
|
+
"type": "array",
|
|
168
|
+
"title": "Forecast questions (semicolon-separated)",
|
|
169
|
+
"description": (
|
|
170
|
+
"Each: 'question | actual_0_or_1'. e.g. "
|
|
171
|
+
"'Will the outage exceed 4h? | 1'. ≥ 4 questions yields "
|
|
172
|
+
"a meaningful calibration curve."
|
|
173
|
+
),
|
|
174
|
+
"examples": [
|
|
175
|
+
"Will the outage exceed 4 hours? | 1",
|
|
176
|
+
"Will customer churn exceed 2% next quarter? | 0",
|
|
177
|
+
"Will regulatory disclosure be required? | 1",
|
|
178
|
+
],
|
|
179
|
+
"default": [],
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
RESULT_SCHEMA: dict[str, Any] = {
|
|
186
|
+
"type": "object",
|
|
187
|
+
"properties": {
|
|
188
|
+
"mode": {"type": "string", "const": "backtest-harness"},
|
|
189
|
+
"scenario_label": {"type": "string"},
|
|
190
|
+
"report": {"type": "object"},
|
|
191
|
+
},
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# --- Parsing ---------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _parse_questions(raw: list[str] | str) -> list[tuple[str, int]]:
|
|
199
|
+
if isinstance(raw, str):
|
|
200
|
+
raw = [raw]
|
|
201
|
+
out: list[tuple[str, int]] = []
|
|
202
|
+
for item in raw:
|
|
203
|
+
if not item or "|" not in item:
|
|
204
|
+
continue
|
|
205
|
+
q, _, a = item.rpartition("|")
|
|
206
|
+
q = q.strip()
|
|
207
|
+
try:
|
|
208
|
+
actual = int(float(a.strip()))
|
|
209
|
+
actual = 1 if actual >= 1 else 0
|
|
210
|
+
except ValueError:
|
|
211
|
+
continue
|
|
212
|
+
if q:
|
|
213
|
+
out.append((q, actual))
|
|
214
|
+
return out
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _seed_questions() -> list[tuple[str, int]]:
|
|
218
|
+
"""Demo seed when the user gives nothing — a synthetic incident
|
|
219
|
+
backtest with a mix of correctly-and-incorrectly predicted outcomes."""
|
|
220
|
+
return [
|
|
221
|
+
("Will the incident be resolved within 6 hours?", 1),
|
|
222
|
+
("Will customer impact exceed 5% of MAU?", 0),
|
|
223
|
+
("Will regulatory notification be mandatory?", 1),
|
|
224
|
+
("Will an executive-level briefing be required?", 1),
|
|
225
|
+
("Will the post-mortem identify a single root cause?", 0),
|
|
226
|
+
("Will the SLA breach trigger contractual penalties?", 0),
|
|
227
|
+
("Will the cause map to a previously-known weakness?", 1),
|
|
228
|
+
("Will customer trust recover within 30 days?", 1),
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# --- Synthetic forecasting -------------------------------------------------
|
|
233
|
+
# Each role produces a posterior in [0,1] for each question. Calibrated so
|
|
234
|
+
# the *consensus* is moderately accurate but with realistic miscalibration —
|
|
235
|
+
# enough to produce visible Brier/log-loss numbers and per-role attribution.
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _question_signal(question: str, actual: int, salt: str) -> float:
|
|
239
|
+
"""Synthetic 'true' signal a perfectly-informed forecaster would
|
|
240
|
+
have. We blend the actual (with noise) so the panel's job has
|
|
241
|
+
realistic difficulty."""
|
|
242
|
+
h = int(hashlib.sha256(f"{salt}::{question}".encode()).hexdigest(), 16)
|
|
243
|
+
noise = ((h % 41) - 20) / 100.0 # [-0.20, 0.20]
|
|
244
|
+
base = 0.65 if actual == 1 else 0.35
|
|
245
|
+
return max(0.05, min(0.95, base + noise))
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _role_posterior(
|
|
249
|
+
role: dict[str, Any],
|
|
250
|
+
base_signal: float,
|
|
251
|
+
actual: int,
|
|
252
|
+
salt: str,
|
|
253
|
+
) -> float:
|
|
254
|
+
"""Each role's posterior is base_signal + role-specific bias + noise."""
|
|
255
|
+
h = int(hashlib.sha256(f"{role['slug']}::{salt}".encode()).hexdigest(), 16)
|
|
256
|
+
noise = ((h % 31) - 15) / 100.0 # [-0.15, 0.15]
|
|
257
|
+
# Recency bias drags toward the actual (when "recent evidence" is a leak proxy).
|
|
258
|
+
recency = role["weight_recency"] * (0.5 if actual == 0 else -0.5)
|
|
259
|
+
# Prior bias pulls toward 0.5 (the calibrating mediator).
|
|
260
|
+
prior_pull = role["weight_prior"] * (0.5 - base_signal)
|
|
261
|
+
out = base_signal + recency + prior_pull + noise
|
|
262
|
+
return max(0.02, min(0.98, out))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _consensus_from_roles(per_role: dict[str, float]) -> float:
|
|
266
|
+
"""Equal-weighted mean across roles."""
|
|
267
|
+
if not per_role:
|
|
268
|
+
return 0.5
|
|
269
|
+
return sum(per_role.values()) / len(per_role)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _brier(p: float, actual: int) -> float:
|
|
273
|
+
return round((p - actual) ** 2, 4)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _log_loss(p: float, actual: int) -> float:
|
|
277
|
+
p = max(1e-6, min(1 - 1e-6, p))
|
|
278
|
+
if actual == 1:
|
|
279
|
+
return round(-math.log(p), 4)
|
|
280
|
+
return round(-math.log(1 - p), 4)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _calibration_curve(
|
|
284
|
+
results: list[QuestionResult],
|
|
285
|
+
n_buckets: int = 5,
|
|
286
|
+
) -> list[CalibrationBucket]:
|
|
287
|
+
buckets: list[CalibrationBucket] = []
|
|
288
|
+
for i in range(n_buckets):
|
|
289
|
+
lo = i / n_buckets
|
|
290
|
+
hi = (i + 1) / n_buckets
|
|
291
|
+
in_bucket = [
|
|
292
|
+
r
|
|
293
|
+
for r in results
|
|
294
|
+
if (lo <= r.consensus_posterior < hi)
|
|
295
|
+
or (i == n_buckets - 1 and r.consensus_posterior == hi)
|
|
296
|
+
]
|
|
297
|
+
if not in_bucket:
|
|
298
|
+
buckets.append(
|
|
299
|
+
CalibrationBucket(
|
|
300
|
+
bucket_low=round(lo, 2),
|
|
301
|
+
bucket_high=round(hi, 2),
|
|
302
|
+
n_predictions=0,
|
|
303
|
+
mean_predicted=0.0,
|
|
304
|
+
actual_rate=0.0,
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
continue
|
|
308
|
+
mean_p = sum(r.consensus_posterior for r in in_bucket) / len(in_bucket)
|
|
309
|
+
rate = sum(r.actual for r in in_bucket) / len(in_bucket)
|
|
310
|
+
buckets.append(
|
|
311
|
+
CalibrationBucket(
|
|
312
|
+
bucket_low=round(lo, 2),
|
|
313
|
+
bucket_high=round(hi, 2),
|
|
314
|
+
n_predictions=len(in_bucket),
|
|
315
|
+
mean_predicted=round(mean_p, 4),
|
|
316
|
+
actual_rate=round(rate, 4),
|
|
317
|
+
)
|
|
318
|
+
)
|
|
319
|
+
return buckets
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _attribution(
|
|
323
|
+
results: list[QuestionResult],
|
|
324
|
+
) -> list[RoleAttribution]:
|
|
325
|
+
"""For each role, compute the panel's Brier with and without that role."""
|
|
326
|
+
out: list[RoleAttribution] = []
|
|
327
|
+
base_brier = sum(r.brier_consensus for r in results) / max(len(results), 1)
|
|
328
|
+
for role in ROLES:
|
|
329
|
+
# Re-compute consensus excluding this role.
|
|
330
|
+
without_briers: list[float] = []
|
|
331
|
+
for r in results:
|
|
332
|
+
without = {k: v for k, v in r.per_role_posteriors.items() if k != role["slug"]}
|
|
333
|
+
cons_without = _consensus_from_roles(without)
|
|
334
|
+
without_briers.append((cons_without - r.actual) ** 2)
|
|
335
|
+
brier_without = sum(without_briers) / max(len(without_briers), 1)
|
|
336
|
+
delta = brier_without - base_brier
|
|
337
|
+
if delta > 0.01:
|
|
338
|
+
label = "improves"
|
|
339
|
+
elif delta < -0.01:
|
|
340
|
+
label = "drags down"
|
|
341
|
+
else:
|
|
342
|
+
label = "neutral"
|
|
343
|
+
out.append(
|
|
344
|
+
RoleAttribution(
|
|
345
|
+
role=role["slug"],
|
|
346
|
+
role_label=role["label"],
|
|
347
|
+
brier_with=round(base_brier, 4),
|
|
348
|
+
brier_without=round(brier_without, 4),
|
|
349
|
+
delta=round(delta, 4),
|
|
350
|
+
contribution_label=label,
|
|
351
|
+
)
|
|
352
|
+
)
|
|
353
|
+
return out
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _improvement_recs(
|
|
357
|
+
overall_brier: float,
|
|
358
|
+
calibration: list[CalibrationBucket],
|
|
359
|
+
attribution: list[RoleAttribution],
|
|
360
|
+
anti_herding_catches: list[str],
|
|
361
|
+
n_questions: int,
|
|
362
|
+
) -> list[ImprovementRec]:
|
|
363
|
+
recs: list[ImprovementRec] = []
|
|
364
|
+
rec_id = 0
|
|
365
|
+
|
|
366
|
+
def _add(severity: str, title: str, rationale: str, action: str) -> None:
|
|
367
|
+
nonlocal rec_id
|
|
368
|
+
rec_id += 1
|
|
369
|
+
recs.append(
|
|
370
|
+
ImprovementRec(
|
|
371
|
+
rec_id=f"REC-{rec_id:02d}",
|
|
372
|
+
severity=severity,
|
|
373
|
+
title=title,
|
|
374
|
+
rationale=rationale,
|
|
375
|
+
action=action,
|
|
376
|
+
)
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
if overall_brier > 0.20:
|
|
380
|
+
_add(
|
|
381
|
+
"high",
|
|
382
|
+
"Panel calibration drifted",
|
|
383
|
+
f"Mean Brier {overall_brier:.3f} exceeds the 0.20 threshold. "
|
|
384
|
+
f"This panel composition is over-confident on this scenario class.",
|
|
385
|
+
"Add a Mediator or Oracle role; or increase Sceptic weight.",
|
|
386
|
+
)
|
|
387
|
+
elif overall_brier > 0.12:
|
|
388
|
+
_add(
|
|
389
|
+
"medium",
|
|
390
|
+
"Panel calibration could improve",
|
|
391
|
+
f"Mean Brier {overall_brier:.3f} is acceptable but not great.",
|
|
392
|
+
"Consider replaying with a domain-specialist Scout role.",
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
# Detect over-confidence: predictions > 0.7 with actual_rate << 0.7
|
|
396
|
+
over_conf_buckets = [
|
|
397
|
+
b
|
|
398
|
+
for b in calibration
|
|
399
|
+
if b.bucket_low >= 0.6 and b.n_predictions > 0 and b.mean_predicted - b.actual_rate >= 0.15
|
|
400
|
+
]
|
|
401
|
+
if over_conf_buckets:
|
|
402
|
+
_add(
|
|
403
|
+
"medium",
|
|
404
|
+
"Over-confidence in high-probability predictions",
|
|
405
|
+
f"{len(over_conf_buckets)} bucket(s) above 0.6 over-predict the "
|
|
406
|
+
f"actual rate by ≥15pp. The panel is too eager to commit.",
|
|
407
|
+
"Tighten the 'PublishWhenConfident' threshold; add a calibrating Mediator.",
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
# Detect under-confidence: predictions < 0.3 with actual_rate >> predicted
|
|
411
|
+
under_conf_buckets = [
|
|
412
|
+
b
|
|
413
|
+
for b in calibration
|
|
414
|
+
if b.bucket_high <= 0.4 and b.n_predictions > 0 and b.actual_rate - b.mean_predicted >= 0.15
|
|
415
|
+
]
|
|
416
|
+
if under_conf_buckets:
|
|
417
|
+
_add(
|
|
418
|
+
"medium",
|
|
419
|
+
"Under-confidence in low-probability predictions",
|
|
420
|
+
f"{len(under_conf_buckets)} bucket(s) below 0.4 under-predict the "
|
|
421
|
+
f"actual rate by ≥15pp. The panel is too cautious.",
|
|
422
|
+
"Reduce Sceptic weight; or replace with a more aggressive Scout.",
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
# Per-role attribution recs
|
|
426
|
+
for a in attribution:
|
|
427
|
+
if a.delta < -0.03:
|
|
428
|
+
_add(
|
|
429
|
+
"high",
|
|
430
|
+
f"Role drags panel: {a.role_label}",
|
|
431
|
+
f"Removing {a.role_label} would improve panel Brier by "
|
|
432
|
+
f"{abs(a.delta):.3f} (from {a.brier_with:.3f} to {a.brier_without:.3f}).",
|
|
433
|
+
f"Consider replacing or down-weighting {a.role_label} on this scenario class.",
|
|
434
|
+
)
|
|
435
|
+
elif a.delta > 0.03:
|
|
436
|
+
_add(
|
|
437
|
+
"info",
|
|
438
|
+
f"Role lifts panel: {a.role_label}",
|
|
439
|
+
f"Removing {a.role_label} would worsen panel Brier by "
|
|
440
|
+
f"{a.delta:.3f}. This role is load-bearing.",
|
|
441
|
+
f"Preserve {a.role_label} when running similar scenarios.",
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
# Anti-herding catches: dissent was right
|
|
445
|
+
if anti_herding_catches:
|
|
446
|
+
catches_pct = 100 * len(anti_herding_catches) / max(n_questions, 1)
|
|
447
|
+
_add(
|
|
448
|
+
"info",
|
|
449
|
+
"Dissent records contained signal",
|
|
450
|
+
f"On {len(anti_herding_catches)} of {n_questions} questions "
|
|
451
|
+
f"({catches_pct:.0f}%), the dissent posterior was closer to truth "
|
|
452
|
+
f"than the consensus.",
|
|
453
|
+
"Surface dissent records earlier in the deliberation loop; "
|
|
454
|
+
"consider weighting dissents into a synthesis claim.",
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
if not recs:
|
|
458
|
+
_add(
|
|
459
|
+
"info",
|
|
460
|
+
"Panel performed within calibration bounds",
|
|
461
|
+
f"Brier {overall_brier:.3f} and bucket-by-bucket calibration "
|
|
462
|
+
f"both look healthy. No structural changes recommended.",
|
|
463
|
+
"Re-run on a different scenario class to confirm.",
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
return recs
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
# --- Run function ----------------------------------------------------------
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
async def _run_backtest(args: Any, recorder: Any | None = None) -> None:
|
|
473
|
+
payload = getattr(args, "mode_payload", None) or {}
|
|
474
|
+
label = (payload.get("scenario_label") or "").strip() or "Backtest replay"
|
|
475
|
+
t0_summary = payload.get("t0_summary") or ""
|
|
476
|
+
actual_outcome = payload.get("actual_outcome") or ""
|
|
477
|
+
raw_questions = payload.get("questions") or []
|
|
478
|
+
if isinstance(raw_questions, str):
|
|
479
|
+
raw_questions = [raw_questions]
|
|
480
|
+
questions = _parse_questions(list(raw_questions))
|
|
481
|
+
if not questions:
|
|
482
|
+
questions = _seed_questions()
|
|
483
|
+
|
|
484
|
+
started_at = time.time()
|
|
485
|
+
salt = hashlib.sha256(label.encode("utf-8")).hexdigest()[:16]
|
|
486
|
+
|
|
487
|
+
results: list[QuestionResult] = []
|
|
488
|
+
anti_herding_catches: list[str] = []
|
|
489
|
+
time_to_truth_total = 0
|
|
490
|
+
correct_at_50 = 0
|
|
491
|
+
|
|
492
|
+
for i, (q, actual) in enumerate(questions):
|
|
493
|
+
qsalt = f"{salt}::{i}"
|
|
494
|
+
base = _question_signal(q, actual, qsalt)
|
|
495
|
+
per_role = {r["slug"]: round(_role_posterior(r, base, actual, qsalt), 3) for r in ROLES}
|
|
496
|
+
consensus = round(_consensus_from_roles(per_role), 4)
|
|
497
|
+
|
|
498
|
+
# Round-by-round simulation: walk consensus from neutral 0.5 toward
|
|
499
|
+
# the synthesised posterior in 5 rounds. "Time to truth" = first
|
|
500
|
+
# round where consensus crossed 0.5 in the correct direction.
|
|
501
|
+
rounds_to_cross = 5
|
|
502
|
+
for round_idx in range(1, 6):
|
|
503
|
+
t = round_idx / 5.0
|
|
504
|
+
r_consensus = 0.5 + t * (consensus - 0.5)
|
|
505
|
+
crossed = (r_consensus >= 0.5) == bool(actual)
|
|
506
|
+
if crossed:
|
|
507
|
+
rounds_to_cross = round_idx
|
|
508
|
+
break
|
|
509
|
+
|
|
510
|
+
# Dissent: the role whose posterior is most distant from consensus.
|
|
511
|
+
most_distant_role = max(
|
|
512
|
+
per_role.items(),
|
|
513
|
+
key=lambda kv: abs(kv[1] - consensus),
|
|
514
|
+
)
|
|
515
|
+
dissent_posterior = most_distant_role[1]
|
|
516
|
+
dissent_was_correct = abs(dissent_posterior - actual) < abs(consensus - actual)
|
|
517
|
+
if dissent_was_correct:
|
|
518
|
+
anti_herding_catches.append(f"Q-{i + 1:02d}")
|
|
519
|
+
|
|
520
|
+
time_to_truth_total += rounds_to_cross
|
|
521
|
+
if (consensus >= 0.5) == bool(actual):
|
|
522
|
+
correct_at_50 += 1
|
|
523
|
+
|
|
524
|
+
results.append(
|
|
525
|
+
QuestionResult(
|
|
526
|
+
question_id=f"Q-{i + 1:02d}",
|
|
527
|
+
question=q,
|
|
528
|
+
actual=actual,
|
|
529
|
+
consensus_posterior=consensus,
|
|
530
|
+
per_role_posteriors=per_role,
|
|
531
|
+
brier_consensus=_brier(consensus, actual),
|
|
532
|
+
brier_per_role={slug: _brier(p, actual) for slug, p in per_role.items()},
|
|
533
|
+
log_loss_consensus=_log_loss(consensus, actual),
|
|
534
|
+
rounds_to_cross=rounds_to_cross,
|
|
535
|
+
dissent_was_correct=dissent_was_correct,
|
|
536
|
+
)
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
overall_brier = round(
|
|
540
|
+
sum(r.brier_consensus for r in results) / max(len(results), 1),
|
|
541
|
+
4,
|
|
542
|
+
)
|
|
543
|
+
overall_log_loss = round(
|
|
544
|
+
sum(r.log_loss_consensus for r in results) / max(len(results), 1),
|
|
545
|
+
4,
|
|
546
|
+
)
|
|
547
|
+
accuracy_at_50 = round(correct_at_50 / max(len(results), 1), 4)
|
|
548
|
+
calibration = _calibration_curve(results)
|
|
549
|
+
attribution = _attribution(results)
|
|
550
|
+
time_to_truth_mean = round(time_to_truth_total / max(len(results), 1), 2)
|
|
551
|
+
recs = _improvement_recs(
|
|
552
|
+
overall_brier,
|
|
553
|
+
calibration,
|
|
554
|
+
attribution,
|
|
555
|
+
anti_herding_catches,
|
|
556
|
+
len(results),
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
report = BacktestReport(
|
|
560
|
+
scenario_label=label,
|
|
561
|
+
t0_summary=t0_summary,
|
|
562
|
+
actual_outcome=actual_outcome,
|
|
563
|
+
n_questions=len(results),
|
|
564
|
+
overall_brier=overall_brier,
|
|
565
|
+
overall_log_loss=overall_log_loss,
|
|
566
|
+
accuracy_at_50=accuracy_at_50,
|
|
567
|
+
calibration_curve=calibration,
|
|
568
|
+
per_role_attribution=attribution,
|
|
569
|
+
questions=results,
|
|
570
|
+
anti_herding_catches=anti_herding_catches,
|
|
571
|
+
time_to_truth_mean=time_to_truth_mean,
|
|
572
|
+
improvement_recommendations=recs,
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
elapsed = time.time() - started_at
|
|
576
|
+
trace = {
|
|
577
|
+
"mode": "backtest-harness",
|
|
578
|
+
"started_at": started_at,
|
|
579
|
+
"elapsed_s": round(elapsed, 3),
|
|
580
|
+
"n_agents": len(ROLES),
|
|
581
|
+
"n_questions": len(results),
|
|
582
|
+
"scenario_label": label,
|
|
583
|
+
"report": asdict(report),
|
|
584
|
+
# Score for SimList — accuracy at 50% threshold is the most legible.
|
|
585
|
+
"collective_iq": {"score": accuracy_at_50},
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
Path(args.trace).write_text(json.dumps(trace, indent=2, default=str))
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
# --- Registration ----------------------------------------------------------
|
|
592
|
+
|
|
593
|
+
SPEC = ModeSpec(
|
|
594
|
+
slug="backtest-harness",
|
|
595
|
+
label="Backtest Harness",
|
|
596
|
+
description=(
|
|
597
|
+
"Replay a historical scenario through the swarm and score against "
|
|
598
|
+
"actual outcomes. Outputs Brier, log loss, calibration curve, "
|
|
599
|
+
"per-role drop-one attribution, anti-herding catches, and "
|
|
600
|
+
"improvement recommendations. Turns demos into evidence."
|
|
601
|
+
),
|
|
602
|
+
input_schema=INPUT_SCHEMA,
|
|
603
|
+
result_schema=RESULT_SCHEMA,
|
|
604
|
+
run_fn=_run_backtest,
|
|
605
|
+
uses_legacy_fields=False,
|
|
606
|
+
expected_roles=["scout", "sceptic", "synthesist", "mediator"],
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
register(SPEC)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Binary-forecast mode — the legacy persona-panel deliberation shape.
|
|
2
|
+
|
|
3
|
+
This mode is a thin wrapper that delegates to the existing
|
|
4
|
+
``_scenario_impl.main``. Behaviour is unchanged from before the modes
|
|
5
|
+
seam was introduced; the only purpose of this module is to make the
|
|
6
|
+
legacy shape one-of-N in the registry rather than the only path.
|
|
7
|
+
|
|
8
|
+
When the broader binary-Brier coupling inside ``_scenario_impl.py`` is
|
|
9
|
+
extracted in a follow-up, this module is where the extracted code lands.
|
|
10
|
+
For now ``run_fn`` is a delegating call.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from . import ModeSpec, register
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def _run_binary_forecast(args: Any, recorder: Any | None = None) -> None:
|
|
21
|
+
# Lazy import — _scenario_impl pulls in the full mind/agent/transport
|
|
22
|
+
# stack, which we want to avoid at module-load time.
|
|
23
|
+
from .. import _scenario_impl as _impl
|
|
24
|
+
|
|
25
|
+
await _impl.main(args, recorder=recorder)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Schema description: this mode reuses the legacy SimConfig fields, so
|
|
29
|
+
# ``input_schema`` is None — the SPA renders the legacy form for it.
|
|
30
|
+
_RESULT_SCHEMA: dict[str, Any] = {
|
|
31
|
+
"type": "object",
|
|
32
|
+
"properties": {
|
|
33
|
+
"questions": {
|
|
34
|
+
"type": "array",
|
|
35
|
+
"items": {
|
|
36
|
+
"type": "object",
|
|
37
|
+
"properties": {
|
|
38
|
+
"id": {"type": "string"},
|
|
39
|
+
"query": {"type": "string"},
|
|
40
|
+
"ground_truth": {"type": "number"},
|
|
41
|
+
"consensus_prob": {"type": "number"},
|
|
42
|
+
"brier": {"type": "number"},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
"collective_iq": {
|
|
47
|
+
"type": "object",
|
|
48
|
+
"properties": {"score": {"type": "number"}},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
SPEC = ModeSpec(
|
|
55
|
+
slug="binary-forecast",
|
|
56
|
+
label="Binary Forecast",
|
|
57
|
+
description=(
|
|
58
|
+
"Persona panel produces consensus probability per yes/no question. "
|
|
59
|
+
"Scored against ground_truth via Brier. The original SimLab shape."
|
|
60
|
+
),
|
|
61
|
+
input_schema=None,
|
|
62
|
+
result_schema=_RESULT_SCHEMA,
|
|
63
|
+
run_fn=_run_binary_forecast,
|
|
64
|
+
uses_legacy_fields=True,
|
|
65
|
+
expected_roles=["scout", "sceptic", "synthesist"],
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
register(SPEC)
|