hypermind 0.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. hmctl/__init__.py +7 -0
  2. hmctl/cli.py +947 -0
  3. hypermind/__init__.py +208 -0
  4. hypermind/_deprecations.py +108 -0
  5. hypermind/agent.py +2896 -0
  6. hypermind/agent_card.py +127 -0
  7. hypermind/api/__init__.py +32 -0
  8. hypermind/api/app.py +686 -0
  9. hypermind/capability.py +923 -0
  10. hypermind/consult.py +493 -0
  11. hypermind/consult_bias.py +112 -0
  12. hypermind/contract_net.py +219 -0
  13. hypermind/crypto/__init__.py +21 -0
  14. hypermind/crypto/cose_encrypt.py +126 -0
  15. hypermind/crypto/dkg.py +246 -0
  16. hypermind/crypto/domain.py +59 -0
  17. hypermind/crypto/frost.py +771 -0
  18. hypermind/crypto/hashing.py +38 -0
  19. hypermind/crypto/hlc.py +163 -0
  20. hypermind/crypto/hpke.py +277 -0
  21. hypermind/crypto/kms.py +196 -0
  22. hypermind/crypto/kms_backends/__init__.py +56 -0
  23. hypermind/crypto/kms_backends/aws.py +128 -0
  24. hypermind/crypto/kms_backends/azure.py +114 -0
  25. hypermind/crypto/kms_backends/gcp.py +97 -0
  26. hypermind/crypto/kms_backends/vault.py +120 -0
  27. hypermind/crypto/namespace_root.py +204 -0
  28. hypermind/crypto/pq.py +152 -0
  29. hypermind/crypto/room_epoch.py +97 -0
  30. hypermind/crypto/signing.py +245 -0
  31. hypermind/deliberation.py +355 -0
  32. hypermind/did_web.py +256 -0
  33. hypermind/dispute.py +858 -0
  34. hypermind/errors.py +218 -0
  35. hypermind/eval/__init__.py +49 -0
  36. hypermind/eval/calibration.py +307 -0
  37. hypermind/eval/comparison.py +251 -0
  38. hypermind/eval/replay.py +202 -0
  39. hypermind/eval/swarm_iq.py +718 -0
  40. hypermind/knowledge/__init__.py +17 -0
  41. hypermind/knowledge/citation_graph.py +141 -0
  42. hypermind/knowledge/correlated_agreement.py +167 -0
  43. hypermind/knowledge/embedding_registry.py +115 -0
  44. hypermind/knowledge/kernel_store.py +197 -0
  45. hypermind/knowledge/rekor.py +173 -0
  46. hypermind/knowledge/reputation.py +364 -0
  47. hypermind/knowledge/swarm_memory.py +523 -0
  48. hypermind/knowledge/transparency.py +409 -0
  49. hypermind/mcp/__init__.py +60 -0
  50. hypermind/mcp/bridge.py +151 -0
  51. hypermind/mcp/client.py +142 -0
  52. hypermind/mcp/provenance.py +214 -0
  53. hypermind/mcp/relata_tools.py +152 -0
  54. hypermind/mcp/server.py +248 -0
  55. hypermind/mcp/transport.py +206 -0
  56. hypermind/mind/__init__.py +150 -0
  57. hypermind/mind/active.py +234 -0
  58. hypermind/mind/belief.py +369 -0
  59. hypermind/mind/deliberator.py +625 -0
  60. hypermind/mind/diversity.py +144 -0
  61. hypermind/mind/evolve.py +184 -0
  62. hypermind/mind/federation.py +154 -0
  63. hypermind/mind/goals.py +117 -0
  64. hypermind/mind/integration.py +190 -0
  65. hypermind/mind/mediator.py +74 -0
  66. hypermind/mind/memory_store.py +129 -0
  67. hypermind/mind/policy.py +307 -0
  68. hypermind/mind/records.py +260 -0
  69. hypermind/mind/roles.py +190 -0
  70. hypermind/mind/sybil_guard.py +41 -0
  71. hypermind/mind/world_model.py +166 -0
  72. hypermind/namespace.py +515 -0
  73. hypermind/namespace_policy.py +254 -0
  74. hypermind/observability/__init__.py +25 -0
  75. hypermind/observability/asgi.py +214 -0
  76. hypermind/observability/cost.py +277 -0
  77. hypermind/observability/otel_export.py +200 -0
  78. hypermind/observability/recorder.py +344 -0
  79. hypermind/observability/swarm_trace.py +438 -0
  80. hypermind/pin_policy.py +116 -0
  81. hypermind/py.typed +0 -0
  82. hypermind/responders/__init__.py +87 -0
  83. hypermind/responders/anthropic.py +159 -0
  84. hypermind/responders/base.py +162 -0
  85. hypermind/responders/openai.py +199 -0
  86. hypermind/responders/router.py +254 -0
  87. hypermind/responders/structured.py +287 -0
  88. hypermind/responders/tools.py +444 -0
  89. hypermind/revocation.py +270 -0
  90. hypermind/rule_ids.py +135 -0
  91. hypermind/schemas/encrypted_statement.cddl +28 -0
  92. hypermind/schemas/namespace_accept.cddl +20 -0
  93. hypermind/schemas/namespace_create.cddl +25 -0
  94. hypermind/schemas/namespace_founding_attest.cddl +30 -0
  95. hypermind/schemas/namespace_invite.cddl +22 -0
  96. hypermind/schemas/wire-v0.1.cddl +73 -0
  97. hypermind/simlab/__init__.py +15 -0
  98. hypermind/simlab/_persona_registry.py +93 -0
  99. hypermind/simlab/_scenario_impl.py +2778 -0
  100. hypermind/simlab/app.py +2538 -0
  101. hypermind/simlab/backends/__init__.py +27 -0
  102. hypermind/simlab/backends/anchor.py +88 -0
  103. hypermind/simlab/backends/local.py +32 -0
  104. hypermind/simlab/backends/server.py +79 -0
  105. hypermind/simlab/cli_command.py +108 -0
  106. hypermind/simlab/config.py +106 -0
  107. hypermind/simlab/deployment.py +26 -0
  108. hypermind/simlab/knowledge.py +716 -0
  109. hypermind/simlab/learning.py +295 -0
  110. hypermind/simlab/modes/__init__.py +115 -0
  111. hypermind/simlab/modes/_eval_real_llm.py +373 -0
  112. hypermind/simlab/modes/aar.py +611 -0
  113. hypermind/simlab/modes/backtest.py +610 -0
  114. hypermind/simlab/modes/binary_forecast.py +69 -0
  115. hypermind/simlab/modes/conformance.py +1869 -0
  116. hypermind/simlab/modes/evaluation.py +2271 -0
  117. hypermind/simlab/modes/governance.py +648 -0
  118. hypermind/simlab/modes/redteam.py +534 -0
  119. hypermind/simlab/modes/tabletop.py +797 -0
  120. hypermind/simlab/modes/tournament.py +448 -0
  121. hypermind/simlab/modes/whatif.py +523 -0
  122. hypermind/simlab/namespace.py +125 -0
  123. hypermind/simlab/personas.py +477 -0
  124. hypermind/simlab/prompt_generator.py +172 -0
  125. hypermind/simlab/question_sets/geopolitics_q20.json +122 -0
  126. hypermind/simlab/question_sets/governance_q5.json +67 -0
  127. hypermind/simlab/question_sets/msft_outlook_q10.json +62 -0
  128. hypermind/simlab/question_sets/whatif_q3.json +38 -0
  129. hypermind/simlab/registry.py +325 -0
  130. hypermind/simlab/runner.py +239 -0
  131. hypermind/simlab/scenario.py +147 -0
  132. hypermind/simlab/swarms.py +180 -0
  133. hypermind/simlab/tool_handlers/__init__.py +4 -0
  134. hypermind/simlab/tool_handlers/alpha_vantage.py +39 -0
  135. hypermind/simlab/tool_handlers/brave.py +46 -0
  136. hypermind/simlab/tool_handlers/newsapi.py +50 -0
  137. hypermind/simlab/tool_handlers/openweather.py +46 -0
  138. hypermind/simlab/tool_handlers/pinecone.py +52 -0
  139. hypermind/simlab/tool_handlers/qdrant.py +57 -0
  140. hypermind/simlab/tool_handlers/query_knowledge_base.py +21 -0
  141. hypermind/simlab/tool_handlers/relata_recall.py +61 -0
  142. hypermind/simlab/tool_handlers/retrieve_document.py +21 -0
  143. hypermind/simlab/tool_handlers/serper.py +46 -0
  144. hypermind/simlab/tool_handlers/tavily.py +53 -0
  145. hypermind/simlab/tool_handlers/weaviate.py +65 -0
  146. hypermind/simlab/tool_handlers/wikipedia.py +64 -0
  147. hypermind/simlab/tool_handlers/wolfram.py +48 -0
  148. hypermind/simlab/tool_handlers/yahoo_finance.py +49 -0
  149. hypermind/simlab/tool_registry.py +568 -0
  150. hypermind/simlab/topic_adapter.py +338 -0
  151. hypermind/simlab/topic_questions.py +259 -0
  152. hypermind/storage/__init__.py +69 -0
  153. hypermind/storage/backend.py +71 -0
  154. hypermind/storage/relata.py +245 -0
  155. hypermind/storage/sqlite.py +258 -0
  156. hypermind/sync.py +191 -0
  157. hypermind/tal.py +175 -0
  158. hypermind/tasks.py +334 -0
  159. hypermind/testing.py +16 -0
  160. hypermind/tools/__init__.py +32 -0
  161. hypermind/tools/registry.py +61 -0
  162. hypermind/transport/__init__.py +22 -0
  163. hypermind/transport/anti_entropy.py +708 -0
  164. hypermind/transport/bus.py +206 -0
  165. hypermind/transport/knows_delta.py +204 -0
  166. hypermind/transport/libp2p.py +298 -0
  167. hypermind/transport/placement.py +58 -0
  168. hypermind/transport/tcp.py +797 -0
  169. hypermind/transport/tls_profile.py +417 -0
  170. hypermind/types.py +59 -0
  171. hypermind/uncertainty.py +222 -0
  172. hypermind/wire.py +897 -0
  173. hypermind/workflow/__init__.py +72 -0
  174. hypermind/workflow/engine.py +655 -0
  175. hypermind/workflow/plan.py +182 -0
  176. hypermind/workflow/repair.py +203 -0
  177. hypermind-0.11.0.dist-info/METADATA +524 -0
  178. hypermind-0.11.0.dist-info/RECORD +181 -0
  179. hypermind-0.11.0.dist-info/WHEEL +4 -0
  180. hypermind-0.11.0.dist-info/entry_points.txt +2 -0
  181. hypermind-0.11.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,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)