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,2778 @@
1
+ """Persona-panel simulator (package-internal implementation).
2
+
3
+ This module is the canonical persona-panel simulator. It is wrapped by
4
+ ``hypermind.simlab.scenario.run_persona_sim`` and exposed via
5
+ ``hmctl simlab``.
6
+
7
+ THE STORY
8
+ =========
9
+ N AI analysts (personas × replicas) each with a distinct LLM-backed
10
+ personality, starting memory of pre-known facts, tool affinity, and
11
+ biases convene to answer a question set. Every action by every agent is
12
+ recorded so a non-technical operator can later see:
13
+
14
+ - what each agent looked up (tool calls)
15
+ - what they remembered before/after each answer
16
+ - what they actually said and why
17
+ - where any two agents disagreed
18
+
19
+ Modern entry points:
20
+
21
+ hmctl simlab # opens the Vue 3 SPA at localhost:8765
22
+ POST /v1/sims # programmatic (registry + worker queue)
23
+
24
+ The dev-only argparse shim at ``examples/scenario_persona_panel.py``
25
+ preserves a CLI for local hacking; new code should call
26
+ ``hypermind.simlab.run_persona_sim(SimConfig, output_dir)`` directly.
27
+
28
+ Outputs (per sim, written under ~/.hypermind/sims/{id}/):
29
+ trace.json full Trace dataclass-as-dict
30
+ status.json queued|running|done|failed + progress
31
+ stderr.log captured stderr from the run
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import argparse
37
+ import asyncio
38
+ import copy
39
+ import hashlib
40
+ import json
41
+ import os
42
+ import time
43
+ from dataclasses import dataclass, field
44
+ from pathlib import Path
45
+ from typing import Any
46
+
47
+ from rich.console import Console
48
+ from rich.panel import Panel
49
+ from rich.table import Table
50
+
51
+ from hypermind import HyperMindAgent, Uncertainty
52
+ from hypermind.responders.base import LLMResponderError
53
+ from hypermind.responders.openai import OpenAIResponder
54
+ from hypermind.responders.tools import (
55
+ Tool,
56
+ ToolCallingResponder,
57
+ query_knowledge_base,
58
+ retrieve_document,
59
+ web_search,
60
+ )
61
+
62
+ ROOM = "persona-panel"
63
+ NAMESPACE = "persona-sim"
64
+ TOPIC = "persona-panel"
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Personas — each has a system prompt, starting memory, tool affinity
69
+ # ---------------------------------------------------------------------------
70
+
71
+ PERSONAS: dict[str, dict[str, Any]] = {
72
+ "optimist-economist": {
73
+ "label": "Optimist Economist",
74
+ "emoji": "📈",
75
+ "system": (
76
+ "You are a senior economist who is fundamentally bullish on long-run "
77
+ "growth. You cite historical analogues (post-1982 expansion, post-COVID "
78
+ "rebound) and emphasise the resilience of consumer spending and "
79
+ "productivity. You acknowledge risks but rarely treat them as base case. "
80
+ "Be specific; quote numbers; lean toward 'this works out'."
81
+ ),
82
+ "starting_memory": [
83
+ "Post-COVID labor market sustained tight through 2024–25",
84
+ "Equity drawdowns >20% recovered within 18 months in 80% of cases since 1950",
85
+ "Productivity gains from AI adoption typically lag investment by 2–3 years",
86
+ ],
87
+ "tool_affinity": "web_search",
88
+ "n_eff": 10,
89
+ },
90
+ "sceptical-engineer": {
91
+ "label": "Sceptical Engineer",
92
+ "emoji": "🔧",
93
+ "system": (
94
+ "You are a senior systems engineer who treats every claim as a "
95
+ "hypothesis until evidenced. You ask 'where's the data?' and 'what's "
96
+ "the failure mode?'. Prefer first-principles reasoning over historical "
97
+ "analogy. Quote specific mechanisms (e.g., capex digestion, latency "
98
+ "budgets, queue overflow). Acknowledge uncertainty explicitly."
99
+ ),
100
+ "starting_memory": [
101
+ "Most large-scale tech projects ship 6–18 months late vs initial plan",
102
+ "Capacity bottlenecks usually appear at the third-most-used resource",
103
+ "Monitoring lags incidents by 3–7 days in regulated industries",
104
+ ],
105
+ "tool_affinity": "retrieve_document",
106
+ "n_eff": 12,
107
+ },
108
+ "risk-averse-lawyer": {
109
+ "label": "Risk-Averse Lawyer",
110
+ "emoji": "⚖️",
111
+ "system": (
112
+ "You are a senior corporate counsel. You think first about "
113
+ "regulatory exposure, antitrust, fiduciary duty, and reputational "
114
+ "risk. You cite precedent (US v Microsoft, Vodafone-Mannesmann) and "
115
+ "are sceptical of any answer that doesn't address the legal angle. "
116
+ "Probabilities should account for known regulatory pipelines."
117
+ ),
118
+ "starting_memory": [
119
+ "EU DMA enforcement on hyperscalers escalated in 2024–25",
120
+ "Antitrust prosecution success rate in tech: ~22% of cases brought",
121
+ "Major settlements in tech average $1.6B over the last decade",
122
+ ],
123
+ "tool_affinity": "query_knowledge_base",
124
+ "n_eff": 11,
125
+ },
126
+ "curious-journalist": {
127
+ "label": "Curious Journalist",
128
+ "emoji": "📰",
129
+ "system": (
130
+ "You are a senior business correspondent. You ask 'what's missing "
131
+ "from the picture?' and weigh narrative vs evidence carefully. You "
132
+ "synthesise multiple sources rather than committing strongly. Hedge "
133
+ "appropriately; identify the one or two facts that would change "
134
+ "your mind."
135
+ ),
136
+ "starting_memory": [
137
+ "Most narrative-driven market calls reverse within 90 days",
138
+ "Insider transactions are a leading indicator with 6-week lag",
139
+ "C-suite turnover rates above 20% precede strategic shifts",
140
+ ],
141
+ "tool_affinity": "web_search",
142
+ "n_eff": 8,
143
+ },
144
+ "pattern-matcher-historian": {
145
+ "label": "Pattern-Matcher Historian",
146
+ "emoji": "📜",
147
+ "system": (
148
+ "You are an economic historian. Every question, you match to past "
149
+ "events: the 1907 Knickerbocker panic, 1973 oil shock, 1989 Japan "
150
+ "bubble, 2008, 2020. You're explicit when no good analogue exists. "
151
+ "Express probability through 'how often this pattern resolved this way'."
152
+ ),
153
+ "starting_memory": [
154
+ "Yield curve inversion preceded recession in 8/10 cases since 1955",
155
+ "Average bear market duration since WWII: 14 months",
156
+ "Tech bubble 2000 saw PE ratios revert from 70x to 30x over 30 months",
157
+ ],
158
+ "tool_affinity": "retrieve_document",
159
+ "n_eff": 11,
160
+ },
161
+ "contrarian-quant": {
162
+ "label": "Contrarian Quant",
163
+ "emoji": "🔬",
164
+ "system": (
165
+ "You are a quant focused on tail risk and consensus-fade. When most "
166
+ "people say YES, you look for reasons to say NO (and vice-versa). "
167
+ "Cite factor exposures (momentum, quality, low-vol), implied volatility, "
168
+ "and crowd-positioning. Lean against the obvious answer when defensible."
169
+ ),
170
+ "starting_memory": [
171
+ "Crowd-positioning extremes (>2σ) reverse within 90 days 70% of time",
172
+ "VIX < 13 historically precedes -10% drawdowns within 6 months",
173
+ "When sell-side consensus exceeds 4.5/5, returns underperform by 4%/yr",
174
+ ],
175
+ "tool_affinity": "mixed",
176
+ "n_eff": 10,
177
+ },
178
+ "data-scientist": {
179
+ "label": "Data Scientist",
180
+ "emoji": "📊",
181
+ "system": (
182
+ "You are a rigorous data scientist who grounds every assessment in empirical evidence. "
183
+ "You demand statistical significance, check for confounds, and flag when sample sizes are too small to draw conclusions. "
184
+ "You think in distributions, not point estimates. You always ask: what does the data actually show, vs. what are we inferring? "
185
+ "You are comfortable with uncertainty and prefer to say '60% confidence, ±15pp' rather than make overconfident claims. "
186
+ "When you don't have data, you say so clearly and reason from base rates."
187
+ ),
188
+ "starting_memory": [
189
+ "Correlation is not causation — always check the causal mechanism.",
190
+ "Effect sizes matter as much as p-values; small effects can be statistically significant but practically irrelevant.",
191
+ "Survivorship bias is everywhere — always ask what data is missing.",
192
+ ],
193
+ "tool_affinity": "mixed",
194
+ "n_eff": 14,
195
+ },
196
+ "security-researcher": {
197
+ "label": "Security Researcher",
198
+ "emoji": "🔐",
199
+ "system": (
200
+ "You are a seasoned security researcher who thinks adversarially. "
201
+ "You model how systems fail, how attackers exploit weaknesses, and what the threat landscape looks like. "
202
+ "You are deeply skeptical of 'security through obscurity' and always ask: what is the attack surface? "
203
+ "You read CVEs, track APT groups, and understand both technical and social engineering vectors. "
204
+ "When assessing risk, you think in terms of CVSS scores, exploit likelihood, and blast radius. "
205
+ "You give lower confidence to claims that ignore adversarial conditions."
206
+ ),
207
+ "starting_memory": [
208
+ "Every system has an attack surface — security is about minimizing and monitoring it.",
209
+ "The weakest link is usually the human layer, not the technical one.",
210
+ "Defense in depth: no single control is sufficient; assume breach and plan accordingly.",
211
+ ],
212
+ "tool_affinity": "retrieve_document",
213
+ "n_eff": 11,
214
+ },
215
+ "product-manager": {
216
+ "label": "Product Manager",
217
+ "emoji": "🎯",
218
+ "system": (
219
+ "You are a pragmatic product manager who thinks in terms of user value, market fit, and execution reality. "
220
+ "You translate technical possibilities into business outcomes. You track what customers actually want vs. what they say they want. "
221
+ "You think about adoption curves, competitive dynamics, and GTM strategy. "
222
+ "You are skeptical of technology-first reasoning that ignores distribution and market timing. "
223
+ "You weight evidence from user research, NPS data, and retention metrics heavily. "
224
+ "You ask: who is the customer, what job are they hiring this for, and what does success look like in 12 months?"
225
+ ),
226
+ "starting_memory": [
227
+ "Build for the job-to-be-done, not the feature request — understand the underlying need.",
228
+ "Distribution is as important as product quality — a great product no one can find fails.",
229
+ "The best products solve one problem exceptionally well before expanding scope.",
230
+ ],
231
+ "tool_affinity": "web_search",
232
+ "n_eff": 9,
233
+ },
234
+ "ethicist": {
235
+ "label": "AI Ethicist",
236
+ "emoji": "🧭",
237
+ "system": (
238
+ "You are an applied AI ethicist who examines the moral, societal, and governance dimensions of technology claims. "
239
+ "You think about who bears the costs and who captures the benefits. You track regulatory developments, bias research, and fairness frameworks. "
240
+ "You are skeptical of techno-optimist framing that ignores distributional impacts and power asymmetries. "
241
+ "You apply frameworks from ethics (consequentialist, deontological, virtue ethics) systematically. "
242
+ "You flag when a claim implicitly assumes a particular value system without acknowledging alternatives. "
243
+ "You believe responsible deployment requires consent, transparency, and accountability mechanisms."
244
+ ),
245
+ "starting_memory": [
246
+ "Technology is not neutral — it encodes the values and assumptions of its designers.",
247
+ "Ask who bears the downside risk vs. who captures the upside — power asymmetries matter.",
248
+ "Governance and technical capability must co-evolve; deployment without governance is reckless.",
249
+ ],
250
+ "tool_affinity": "query_knowledge_base",
251
+ "n_eff": 10,
252
+ },
253
+ "macro-strategist": {
254
+ "label": "Macro Strategist",
255
+ "emoji": "🌍",
256
+ "system": (
257
+ "You are a macro strategist who thinks in geopolitical, economic, and systemic terms. "
258
+ "You track central bank policy, trade flows, demographic trends, and multi-decade cycles. "
259
+ "You connect micro-level events to macro-level forces: interest rate regimes, currency dynamics, commodity supercycles. "
260
+ "You are deeply aware of second and third-order effects and the danger of linear extrapolation in complex adaptive systems. "
261
+ "You use historical analogies but are careful not to over-index on them. "
262
+ "You give high weight to structural constraints (demographics, debt levels, energy infrastructure) that operate on 10–30 year timescales."
263
+ ),
264
+ "starting_memory": [
265
+ "Demographics are destiny — population aging and debt levels constrain policy space for decades.",
266
+ "Never fight the Fed in the short run, but structural forces win in the long run.",
267
+ "Geopolitical shifts happen slowly then suddenly — watch for leading indicators, not just events.",
268
+ ],
269
+ "tool_affinity": "web_search",
270
+ "n_eff": 11,
271
+ },
272
+ "systems-biologist": {
273
+ "label": "Systems Biologist",
274
+ "emoji": "🧬",
275
+ "system": (
276
+ "You are a systems biologist who thinks in terms of complex adaptive systems, feedback loops, and emergent properties. "
277
+ "You draw analogies between biological systems and the domain being analyzed: robustness, fragility, resilience, and phase transitions. "
278
+ "You are deeply skeptical of reductionist explanations that ignore systemic interactions. "
279
+ "You think about homeostasis, tipping points, and path dependence. "
280
+ "You are comfortable with non-linear dynamics and understand that complex systems resist simple interventions. "
281
+ "You apply evolutionary thinking: what selective pressures shape the behavior you're observing?"
282
+ ),
283
+ "starting_memory": [
284
+ "Complex systems have emergent properties that cannot be predicted from components alone.",
285
+ "Robustness in one dimension often means fragility in another — there are no free lunches in system design.",
286
+ "Evolution selects for 'good enough' solutions, not optimal ones — satisficing is the norm.",
287
+ ],
288
+ "tool_affinity": "retrieve_document",
289
+ "n_eff": 11,
290
+ },
291
+ "behavioral-economist": {
292
+ "label": "Behavioral Economist",
293
+ "emoji": "🧠",
294
+ "system": (
295
+ "You are a behavioral economist who understands how humans actually make decisions, not how rational-agent models assume they do. "
296
+ "You think about cognitive biases, heuristics, and the architecture of choice. "
297
+ "You apply prospect theory, loss aversion, hyperbolic discounting, and social proof to analyze real-world behavior. "
298
+ "You are skeptical of predictions based on homo economicus assumptions — people are predictably irrational. "
299
+ "You look for the nudge architecture and ask: what incentives and choice structures are actually operating here? "
300
+ "You weigh field experiments and RCTs above survey data and revealed preferences above stated ones."
301
+ ),
302
+ "starting_memory": [
303
+ "People respond to losses roughly twice as strongly as equivalent gains — loss aversion dominates.",
304
+ "Defaults are destiny: most people never change the default option, so defaults are policy choices.",
305
+ "Context and framing fundamentally change choices — identical options presented differently yield different decisions.",
306
+ ],
307
+ "tool_affinity": "mixed",
308
+ "n_eff": 10,
309
+ },
310
+ "investigative-analyst": {
311
+ "label": "Investigative Analyst",
312
+ "emoji": "🔍",
313
+ "system": (
314
+ "You are a meticulous investigative analyst who follows evidence chains to their source. "
315
+ "You are trained to detect motivated reasoning, conflicts of interest, and manufactured consensus. "
316
+ "You triangulate across multiple independent sources before accepting a claim. "
317
+ "You have a nose for what's NOT being said — omissions and silences are as telling as assertions. "
318
+ "You track document trails, financial flows, and institutional incentives. "
319
+ "You are deeply skeptical of official narratives and corporate communications, while not dismissing them outright. "
320
+ "You assign high confidence only when multiple independent lines of evidence converge."
321
+ ),
322
+ "starting_memory": [
323
+ "Follow the money — financial incentives explain most of what looks like ideology or incompetence.",
324
+ "Absence of evidence is not evidence of absence, but it IS evidence — ask why the evidence is missing.",
325
+ "Primary sources trump secondary sources; always ask: who said this first, and why?",
326
+ ],
327
+ "tool_affinity": "web_search",
328
+ "n_eff": 12,
329
+ },
330
+ }
331
+
332
+
333
+ # ---------------------------------------------------------------------------
334
+ # LLM cost table — USD per 1M tokens (input, output)
335
+ # ---------------------------------------------------------------------------
336
+
337
+ _COST_PER_1M: dict[str, tuple[float, float]] = {
338
+ "gpt-4o": (5.0, 15.0),
339
+ "gpt-4o-mini": (0.15, 0.60),
340
+ "gpt-4o-mini-2024-07-18": (0.15, 0.60),
341
+ "claude-3-5-sonnet-20241022": (3.0, 15.0),
342
+ "claude-3-5-haiku-20241022": (0.80, 4.0),
343
+ "claude-sonnet-4-6": (3.0, 15.0),
344
+ "claude-haiku-4-5-20251001": (0.80, 4.0),
345
+ }
346
+
347
+
348
+ def _estimate_cost(model: str, input_tokens: int, output_tokens: int) -> float:
349
+ rates = _COST_PER_1M.get(model, (0.0, 0.0))
350
+ return (input_tokens * rates[0] + output_tokens * rates[1]) / 1_000_000
351
+
352
+
353
+ # ---------------------------------------------------------------------------
354
+ # Profile + trace
355
+ # ---------------------------------------------------------------------------
356
+
357
+
358
+ @dataclass
359
+ class AgentProfile:
360
+ agent: HyperMindAgent
361
+ name: str
362
+ persona: str
363
+ persona_info: dict[str, Any]
364
+ panel_votes: list[dict[str, Any]] = field(default_factory=list)
365
+ tool_calls: list[dict[str, Any]] = field(default_factory=list)
366
+ memory_states: list[dict[str, Any]] = field(default_factory=list)
367
+ structured: list[dict[str, Any]] = field(default_factory=list)
368
+ agent_timeline: list[dict[str, Any]] = field(default_factory=list)
369
+ published_claims: list[dict[str, Any]] = field(default_factory=list)
370
+ n_llm_calls: int = 0
371
+ total_input_tokens: int = 0
372
+ total_output_tokens: int = 0
373
+
374
+ def record_timeline(self, kind: str, summary: str, **extra: Any) -> None:
375
+ self.agent_timeline.append(
376
+ {
377
+ "ts_wall_ms": int(time.time() * 1000),
378
+ "kind": kind,
379
+ "summary": summary,
380
+ **extra,
381
+ }
382
+ )
383
+
384
+
385
+ @dataclass
386
+ class Trace:
387
+ started_at: float
388
+ finished_at: float = 0.0
389
+ profiles: list[AgentProfile] = field(default_factory=list)
390
+ panel_records: list[dict[str, Any]] = field(default_factory=list)
391
+ questions: list[dict[str, Any]] = field(default_factory=list)
392
+ topic: str = "" # the user's free-text topic (when generated, not from a saved set)
393
+ total_tool_calls: int = 0
394
+ use_real_llm: bool = False
395
+ llm_model: str = ""
396
+ # ── Collective intelligence wiring (P1–P10) ──
397
+ deliberation_records: list[dict[str, Any]] = field(default_factory=list)
398
+ oracle_resolutions: list[dict[str, Any]] = field(default_factory=list)
399
+ syntheses: list[dict[str, Any]] = field(default_factory=list)
400
+ vouches: list[dict[str, Any]] = field(default_factory=list)
401
+ captoken_events: list[dict[str, Any]] = field(default_factory=list)
402
+ audit_replay: dict[str, Any] = field(default_factory=dict)
403
+ reputation_snapshots: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
404
+ # ── Phase timing breakdown (KPI #1) ──
405
+ setup_s: float = 0.0
406
+ deliberation_s: float = 0.0
407
+ oracle_s: float = 0.0
408
+ synthesis_s: float = 0.0
409
+ n_deliberation_rounds: int = 0
410
+ questions_timing: list[dict[str, Any]] = field(default_factory=list)
411
+
412
+
413
+ # ---------------------------------------------------------------------------
414
+ # TracingPersonaResponder — wraps the real responder, captures everything
415
+ # ---------------------------------------------------------------------------
416
+
417
+
418
+ class TracingPersonaResponder:
419
+ """Per-persona dispatcher that records tool_calls + memory diffs.
420
+
421
+ For each (agent_kid, query):
422
+ 1. Snapshot the agent's working_memory (before)
423
+ 2. Dispatch to the persona-specific underlying responder
424
+ 3. Capture last_tool_calls + last_structured
425
+ 4. Compute memory diff (after vs before) — the responder may have
426
+ written facts via the agent's checkpoint mechanism
427
+ 5. Append all of it to the AgentProfile
428
+ """
429
+
430
+ def __init__(
431
+ self,
432
+ profiles_by_kid: dict[bytes, AgentProfile],
433
+ responders_by_persona: dict[str, Any],
434
+ ) -> None:
435
+ self.profiles_by_kid = profiles_by_kid
436
+ self.responders_by_persona = responders_by_persona
437
+
438
+ async def __call__(self, agent_kid: bytes, query: str) -> Any:
439
+ prof = self.profiles_by_kid.get(agent_kid)
440
+ if prof is None:
441
+ return Uncertainty.beta(5, 5)
442
+
443
+ responder = self.responders_by_persona.get(
444
+ prof.persona,
445
+ next(iter(self.responders_by_persona.values())),
446
+ )
447
+
448
+ # 1. Snapshot memory before
449
+ try:
450
+ mem_before = _snapshot_working_memory(prof.agent.working_memory())
451
+ except Exception:
452
+ mem_before = {}
453
+
454
+ t0 = time.perf_counter()
455
+ prof.n_llm_calls += 1
456
+ try:
457
+ result = await responder(agent_kid, query)
458
+ except (LLMResponderError, TimeoutError) as exc:
459
+ elapsed = (time.perf_counter() - t0) * 1000
460
+ label = "timeout" if isinstance(exc, TimeoutError) else "vote_error"
461
+ prof.panel_votes.append(
462
+ {
463
+ "query": query,
464
+ "error": str(exc)[:200],
465
+ "via": "fallback",
466
+ "elapsed_ms": elapsed,
467
+ }
468
+ )
469
+ prof.record_timeline(label, f"{exc!s:.80}", query=query)
470
+ return Uncertainty.beta(5, 5)
471
+ except asyncio.CancelledError:
472
+ # Propagate cancellation — the outer wait_for in _one() owns the timeout.
473
+ raise
474
+ elapsed_ms = (time.perf_counter() - t0) * 1000
475
+
476
+ # 2. Unpack — may be (Uncertainty, StructuredResponse) or bare Uncertainty
477
+ if isinstance(result, tuple) and len(result) == 2:
478
+ unc, structured = result
479
+ else:
480
+ unc = result
481
+ structured = None
482
+
483
+ # Accumulate token usage if the structured response carries it.
484
+ try:
485
+ tu = getattr(structured, "token_usage", None) if structured is not None else None
486
+ if tu is not None:
487
+ prof.total_input_tokens += int(getattr(tu, "prompt_tokens", 0) or 0)
488
+ prof.total_output_tokens += int(getattr(tu, "completion_tokens", 0) or 0)
489
+ except Exception:
490
+ pass
491
+
492
+ confidence = structured.confidence if structured is not None else unc.mean()
493
+
494
+ # 3. Tool calls — read responder.last_tool_calls if it has them
495
+ tool_calls_this_call = []
496
+ if hasattr(responder, "last_tool_calls"):
497
+ tc = list(responder.last_tool_calls or [])
498
+ for entry in tc:
499
+ tool_calls_this_call.append(
500
+ {
501
+ "ts_wall_ms": int(time.time() * 1000),
502
+ "query": query,
503
+ **entry,
504
+ }
505
+ )
506
+ prof.tool_calls.append(tool_calls_this_call[-1])
507
+
508
+ # 4. Memory diff
509
+ try:
510
+ mem_after = _snapshot_working_memory(prof.agent.working_memory())
511
+ except Exception:
512
+ mem_after = {}
513
+ diff = _memory_diff(mem_before, mem_after)
514
+ prof.memory_states.append(
515
+ {
516
+ "ts_wall_ms": int(time.time() * 1000),
517
+ "query": query,
518
+ "before": mem_before,
519
+ "after": mem_after,
520
+ "diff": diff,
521
+ }
522
+ )
523
+
524
+ # 5. Structured response
525
+ struct_dict: dict[str, Any] | None = None
526
+ if structured is not None:
527
+ struct_dict = structured.to_dict()
528
+ prof.structured.append({"query": query, **struct_dict})
529
+
530
+ # 6. Panel vote
531
+ vote = {
532
+ "query": query,
533
+ "confidence": round(float(confidence), 4),
534
+ "posterior_mean": round(unc.mean(), 4),
535
+ "reasoning": (structured.reasoning if structured else ""),
536
+ "n_eff": prof.persona_info.get("n_eff", 10),
537
+ "via": "llm" if structured else "synthetic",
538
+ "elapsed_ms": round(elapsed_ms, 1),
539
+ "n_tool_calls": len(tool_calls_this_call),
540
+ }
541
+ prof.panel_votes.append(vote)
542
+ prof.record_timeline(
543
+ "vote",
544
+ f"conf={confidence:.2f} on '{query[:50]}'",
545
+ query=query,
546
+ confidence=confidence,
547
+ n_tool_calls=len(tool_calls_this_call),
548
+ )
549
+
550
+ return result
551
+
552
+
553
+ def _snapshot_working_memory(mem: dict) -> dict[str, Any]:
554
+ """Shallow-copy working memory, deep-copying only mutable fields.
555
+
556
+ `peer_claims` is a large list written only by subscription callbacks,
557
+ never modified by the responder — skip its deepcopy to avoid copying
558
+ hundreds of dicts on every LLM call.
559
+ """
560
+ return {k: (copy.deepcopy(v) if k != "peer_claims" else v) for k, v in mem.items()}
561
+
562
+
563
+ def _memory_diff(before: dict, after: dict) -> dict[str, Any]:
564
+ added: list[str] = []
565
+ removed: list[str] = []
566
+ changed: list[str] = []
567
+ keys = set(before) | set(after)
568
+ for k in sorted(keys):
569
+ if k not in before:
570
+ added.append(k)
571
+ elif k not in after:
572
+ removed.append(k)
573
+ elif before[k] != after[k]:
574
+ changed.append(k)
575
+ return {"added": added, "removed": removed, "changed": changed}
576
+
577
+
578
+ # ---------------------------------------------------------------------------
579
+ # OpenRouter / Anthropic client builder (reuses pattern from msft scenario)
580
+ # ---------------------------------------------------------------------------
581
+
582
+
583
+ def _make_openrouter_client(api_key: str):
584
+ from openai import AsyncOpenAI
585
+
586
+ return AsyncOpenAI(
587
+ base_url="https://openrouter.ai/api/v1",
588
+ api_key=api_key,
589
+ default_headers={
590
+ "HTTP-Referer": "https://github.com/ZySec-AI/hypermind",
591
+ "X-Title": "HyperMind Persona Panel",
592
+ },
593
+ )
594
+
595
+
596
+ def _build_responder_for_persona(
597
+ persona: str,
598
+ api_key: str,
599
+ *,
600
+ llm_model: str,
601
+ enable_tools: bool,
602
+ persona_info_override: dict[str, Any] | None = None,
603
+ swarm_tools: list[str] | None = None,
604
+ ) -> Any:
605
+ """Build a per-persona LLM responder with optional tool calling.
606
+
607
+ ``persona_info_override`` (optional) replaces the lookup against
608
+ ``PERSONAS`` with a custom dict (e.g. user-defined persona, or
609
+ swarm-bias-modified system prompt).
610
+
611
+ ``swarm_tools`` (optional list of tool names): when provided, it
612
+ overrides the per-persona ``tool_affinity`` — every member of a
613
+ swarm shares the same explicitly-declared tool set. Pass ``[]`` to
614
+ disable tools entirely on a swarm.
615
+ """
616
+ info = persona_info_override or PERSONAS[persona]
617
+ client = _make_openrouter_client(api_key)
618
+
619
+ if enable_tools:
620
+ tools: list[Tool] = []
621
+
622
+ # Try to load tools from the namespace registry first.
623
+ try:
624
+ import importlib as _importlib
625
+
626
+ from .tool_registry import (
627
+ decode_credential as _decode,
628
+ )
629
+ from .tool_registry import (
630
+ get_activated_tool as _get_activated,
631
+ )
632
+ from .tool_registry import (
633
+ get_tool_def as _get_def,
634
+ )
635
+ from .tool_registry import (
636
+ list_active_tool_slugs as _active_slugs,
637
+ )
638
+
639
+ active_slugs = set(_active_slugs())
640
+ affinity = info.get("tool_affinity", "mixed")
641
+ # Legacy stub names map to "mixed" — they're intents, not specific slugs.
642
+ _legacy = {"web_search", "retrieve_document", "query_knowledge_base"}
643
+
644
+ if swarm_tools is not None:
645
+ # Filter swarm tools: keep registry slugs; legacy stubs → all active tools
646
+ direct = set(swarm_tools) & active_slugs
647
+ has_legacy = bool(set(swarm_tools) & _legacy)
648
+ wanted = (active_slugs if has_legacy else set()) | direct
649
+ elif affinity == "none":
650
+ wanted = set()
651
+ elif affinity in ({"mixed"} | _legacy):
652
+ # "mixed" or any legacy affinity → use all active tools
653
+ wanted = active_slugs
654
+ else:
655
+ wanted = {affinity} & active_slugs
656
+
657
+ for slug in sorted(wanted):
658
+ activated = _get_activated(slug)
659
+ tool_def = _get_def(slug)
660
+ if not activated or not tool_def:
661
+ continue
662
+ creds = {k: _decode(v) for k, v in activated.credentials.items()}
663
+ try:
664
+ mod = _importlib.import_module(tool_def.handler_module)
665
+ _creds_capture = creds
666
+
667
+ def _make_handler(_m=mod, _c=_creds_capture):
668
+ def _handler(args):
669
+ return _m.handle(args, _c)
670
+ return _handler
671
+
672
+ handler = _make_handler()
673
+ except ImportError:
674
+ def handler(args):
675
+ return json.dumps({"error": "handler unavailable"})
676
+ tools.append(
677
+ Tool(
678
+ name=slug,
679
+ description=tool_def.description,
680
+ parameters_schema=tool_def.parameters_schema,
681
+ handler=handler,
682
+ )
683
+ )
684
+ except Exception as _tool_reg_err:
685
+ import traceback as _tb
686
+
687
+ print(
688
+ f"[tool_registry] ERROR building tools: {_tool_reg_err}\n{_tb.format_exc()}",
689
+ flush=True,
690
+ )
691
+
692
+ # Backwards-compat fallback: if no registry tools, use hardcoded stubs.
693
+ if not tools:
694
+ if swarm_tools is not None:
695
+ wanted_stub = set(swarm_tools)
696
+ if "web_search" in wanted_stub:
697
+ tools.append(web_search())
698
+ if "retrieve_document" in wanted_stub:
699
+ tools.append(retrieve_document())
700
+ if "query_knowledge_base" in wanted_stub:
701
+ tools.append(query_knowledge_base())
702
+ else:
703
+ affinity = info.get("tool_affinity", "mixed")
704
+ if affinity in ("web_search", "mixed"):
705
+ tools.append(web_search())
706
+ if affinity in ("retrieve_document", "mixed"):
707
+ tools.append(retrieve_document())
708
+ if affinity in ("query_knowledge_base", "mixed"):
709
+ tools.append(query_knowledge_base())
710
+
711
+ # Always inject the Relata memory recall tool when a memory store is attached.
712
+ if os.getenv("RELATA_URL"):
713
+ from .tool_handlers.relata_recall import relata_recall as _rr_def
714
+ tools.append(Tool(
715
+ name="relata_recall",
716
+ description="Search this agent's persistent memory for past learnings and research.",
717
+ parameters_schema=_rr_def()["function"]["parameters"],
718
+ handler=lambda args, _agent=agent: __import__(
719
+ "hypermind.simlab.tool_handlers.relata_recall", fromlist=["handle_relata_recall"]
720
+ ).handle_relata_recall(args, agent=_agent),
721
+ ))
722
+
723
+ return ToolCallingResponder(
724
+ client=client,
725
+ model=llm_model,
726
+ tools=tools,
727
+ system_prompt=info["system"],
728
+ effective_n=info["n_eff"],
729
+ timeout_s=45,
730
+ )
731
+
732
+ return OpenAIResponder(
733
+ client=client,
734
+ model=llm_model,
735
+ system_prompt=info["system"],
736
+ effective_n=info["n_eff"],
737
+ timeout_s=30,
738
+ )
739
+
740
+
741
+ def _build_synthetic_responder():
742
+ from hypermind.responders import SyntheticResponder
743
+
744
+ return SyntheticResponder()
745
+
746
+
747
+ # ---------------------------------------------------------------------------
748
+ # Persona identity + Relata memory helpers
749
+ # ---------------------------------------------------------------------------
750
+
751
+
752
+ def _persona_seed(persona_slug: str) -> bytes:
753
+ """Deterministic 32-byte seed for a persona slug — stable identity across sims."""
754
+ return hashlib.sha256(f"hypermind-persona-v1:{persona_slug}".encode()).digest()
755
+
756
+
757
+ def _attach_relata_memory(agent: "HyperMindAgent", *, namespace_id: str) -> None:
758
+ """Attach a RelataMemoryStore to agent if RELATA_URL is set. Best-effort."""
759
+ url = os.getenv("RELATA_URL")
760
+ if not url:
761
+ return
762
+ try:
763
+ from hypermind.mind.memory_store import RelataMemoryStore
764
+ store = RelataMemoryStore(
765
+ url,
766
+ namespace_id=namespace_id,
767
+ agent_kid=agent.keypair.kid,
768
+ purpose="agent_notes",
769
+ )
770
+ store.open()
771
+ agent.attach_memory(store)
772
+ except Exception:
773
+ pass
774
+
775
+
776
+ # ---------------------------------------------------------------------------
777
+ # Cohort setup
778
+ # ---------------------------------------------------------------------------
779
+
780
+
781
+ def _build_profiles(
782
+ agents: list[HyperMindAgent],
783
+ *,
784
+ personas: list[str],
785
+ replicas: int,
786
+ persona_replicas: dict[str, int] | None = None,
787
+ persona_info_override: dict[str, dict[str, Any]] | None = None,
788
+ ) -> list[AgentProfile]:
789
+ """Bind ``HyperMindAgent`` instances to persona profiles.
790
+
791
+ ``persona_replicas`` (optional) overrides the per-persona replica
792
+ count — used by swarms where members can have uneven counts.
793
+ ``persona_info_override`` (optional) injects custom-persona dicts
794
+ for slugs that aren't in the global ``PERSONAS`` constant, or
795
+ swaps in a swarm-bias-modified system prompt for built-ins.
796
+ """
797
+ profiles: list[AgentProfile] = []
798
+ it = iter(agents)
799
+ info_lookup: dict[str, dict[str, Any]] = persona_info_override or {}
800
+ rep_lookup: dict[str, int] = persona_replicas or {}
801
+ for persona_name in personas:
802
+ info = info_lookup.get(persona_name) or PERSONAS[persona_name]
803
+ n = rep_lookup.get(persona_name, replicas)
804
+ for idx in range(1, n + 1):
805
+ agent = next(it, None)
806
+ if agent is None:
807
+ break
808
+ prof = AgentProfile(
809
+ agent=agent,
810
+ name=f"{persona_name}-{idx:02d}",
811
+ persona=persona_name,
812
+ persona_info=info,
813
+ )
814
+ # Attach persistent Relata memory (no-op when RELATA_URL unset).
815
+ _attach_relata_memory(agent, namespace_id="simlab-persistent")
816
+ # Seed working memory with persona's pre-known facts.
817
+ mem = agent.working_memory()
818
+ mem["persona"] = persona_name
819
+ mem["starting_facts"] = list(info["starting_memory"])
820
+ mem["facts_learned"] = []
821
+ # S4 — Cross-sim warm start: hydrate posteriors from
822
+ # KernelStore so this persona's *prior* sims' calibration
823
+ # carries forward. First-ever sim of a persona is a no-op.
824
+ try:
825
+ from hypermind.knowledge.kernel_store import KernelStore
826
+
827
+ state = KernelStore.load(persona_name)
828
+ if state.posteriors:
829
+ mem.setdefault("posteriors", {}).update(state.posteriors)
830
+ prof.record_timeline(
831
+ "init",
832
+ f"warm-start: loaded {len(state.posteriors)} prior "
833
+ f"posteriors (across {state.n_sims} prior sims)",
834
+ )
835
+ except Exception:
836
+ pass
837
+ prof.record_timeline(
838
+ "init",
839
+ f"{info['label']}: seeded with {len(info['starting_memory'])} starting facts",
840
+ )
841
+ profiles.append(prof)
842
+ return profiles
843
+
844
+
845
+ # ---------------------------------------------------------------------------
846
+ # Question loading
847
+ # ---------------------------------------------------------------------------
848
+
849
+
850
+ def _load_questions(path: str) -> list[dict[str, Any]]:
851
+ p = Path(path)
852
+ if p.exists():
853
+ return json.loads(p.read_text())
854
+ # Try the bundled package question_sets/ directory (works after pip install)
855
+ pkg_q = Path(__file__).resolve().parent / "question_sets" / f"{path}.json"
856
+ if pkg_q.exists():
857
+ return json.loads(pkg_q.read_text())
858
+ # Try the dev-source bench/question_sets/ (walking up from this file)
859
+ for parent in Path(__file__).resolve().parents:
860
+ candidate = parent / "bench" / "question_sets" / f"{path}.json"
861
+ if candidate.exists():
862
+ return json.loads(candidate.read_text())
863
+ if (parent / "pyproject.toml").exists():
864
+ break
865
+ raise FileNotFoundError(f"question set not found: {path}")
866
+
867
+
868
+ # ---------------------------------------------------------------------------
869
+ # Per-question panel + per-agent vote
870
+ # ---------------------------------------------------------------------------
871
+
872
+
873
+ def _peer_reasoning_summary(profiles: list[AgentProfile], query: str, skip_kid: bytes) -> str:
874
+ """Build an anonymised round-0 reasoning summary for a deliberation round.
875
+
876
+ Each agent sees what its peers said *anonymised* by archetype + a
877
+ confidence histogram, so it can revise without anchoring on identity.
878
+ """
879
+ answers: list[tuple[str, float, str]] = []
880
+ for p in profiles:
881
+ if p.agent.keypair.kid == skip_kid:
882
+ continue
883
+ last = next(
884
+ (
885
+ v
886
+ for v in reversed(p.panel_votes)
887
+ if v.get("query") == query and v.get("confidence") is not None
888
+ ),
889
+ None,
890
+ )
891
+ if last is None:
892
+ continue
893
+ reasoning = (last.get("reasoning", "") or "")[:400].strip()
894
+ answers.append((p.persona, last["confidence"], reasoning))
895
+ if not answers:
896
+ return ""
897
+ confs = [a[1] for a in answers]
898
+ mean = sum(confs) / len(confs)
899
+ lines = [
900
+ f"PEER PANEL ROUND 0 — {len(answers)} other analysts answered this question.",
901
+ f" Mean confidence: {mean:.0%} · Range: {min(confs):.0%}–{max(confs):.0%}",
902
+ " A sample of their reasoning (anonymised):",
903
+ ]
904
+ # Sample up to 5 different perspectives
905
+ seen_personas: set[str] = set()
906
+ for persona, conf, reasoning in answers:
907
+ if persona in seen_personas:
908
+ continue
909
+ seen_personas.add(persona)
910
+ if reasoning:
911
+ lines.append(f" - [{persona}] {conf:.0%}: {reasoning}")
912
+ if len(seen_personas) >= 5:
913
+ break
914
+ lines.append("")
915
+ lines.append(
916
+ "Now reconsider the question. You may keep, qualify, or rebut your answer. "
917
+ "If your view shifted, say briefly why."
918
+ )
919
+ return "\n".join(lines)
920
+
921
+
922
+ async def _ask_panel(
923
+ profiles: list[AgentProfile],
924
+ question: dict,
925
+ responder: TracingPersonaResponder,
926
+ *,
927
+ timeout_s: float = 45,
928
+ publish_claims: bool = True,
929
+ enable_disputes: bool = True,
930
+ ) -> dict[str, Any]:
931
+ """Dispatch the question to every agent in parallel; aggregate.
932
+
933
+ When ``publish_claims=True``, every agent publishes a signed CLAIM
934
+ after answering — a sealed, citeable, audit-logged statement that
935
+ every other peer in the room can read. When ``enable_disputes=True``,
936
+ agents whose answer differs by ≥30pp from the panel mean file a
937
+ bonded DISPUTE against the highest-confidence opposing claim.
938
+ """
939
+ query = question.get("query", "")
940
+ qid = question.get("id", "")
941
+ ground_truth = question.get("ground_truth")
942
+
943
+ async def _one(prof):
944
+ try:
945
+ await asyncio.wait_for(
946
+ responder(prof.agent.keypair.kid, query),
947
+ timeout=timeout_s,
948
+ )
949
+ except TimeoutError:
950
+ prof.record_timeline("error", f"responder timeout after {timeout_s}s")
951
+ except asyncio.CancelledError:
952
+ prof.record_timeline("error", "responder cancelled")
953
+ raise
954
+ except Exception as exc:
955
+ prof.record_timeline("error", str(exc)[:120])
956
+
957
+ t0 = time.perf_counter()
958
+ # Outer wait_for is a hard ceiling — per-agent timeouts above are the
959
+ # primary cap. `return_exceptions=True` ensures one slow/failed agent
960
+ # never aborts the panel run.
961
+ try:
962
+ await asyncio.wait_for(
963
+ asyncio.gather(*[_one(p) for p in profiles], return_exceptions=True),
964
+ timeout=timeout_s * len(profiles) + 30,
965
+ )
966
+ except TimeoutError:
967
+ # Catastrophic: every agent stalled past its own timeout AND the
968
+ # generous outer ceiling. Record on every profile that didn't
969
+ # already record an error and continue with whatever votes landed.
970
+ for prof in profiles:
971
+ if not any(t.get("kind") == "error" for t in prof.agent_timeline[-3:]):
972
+ prof.record_timeline("error", "panel timeout")
973
+ elapsed_ms = (time.perf_counter() - t0) * 1000
974
+
975
+ # Collect this question's votes from each profile
976
+ answers: list[tuple[AgentProfile, dict]] = []
977
+ for p in profiles:
978
+ last = next(
979
+ (v for v in reversed(p.panel_votes) if v.get("query") == query),
980
+ None,
981
+ )
982
+ if last is not None and "confidence" in last:
983
+ answers.append((p, last))
984
+
985
+ if not answers:
986
+ return {
987
+ "id": qid,
988
+ "query": query,
989
+ "ground_truth": ground_truth,
990
+ "n_responses": 0,
991
+ "elapsed_ms": elapsed_ms,
992
+ }
993
+
994
+ confidences = [a[1]["confidence"] for a in answers]
995
+ mean_conf = sum(confidences) / len(confidences)
996
+ sorted_pa = sorted(answers, key=lambda x: x[1]["confidence"])
997
+
998
+ # ---- Publish each agent's answer as a signed CLAIM -----------------
999
+ # Every agent publishes its answer to the namespace's transparency
1000
+ # log so other agents (and any external auditor) can see it.
1001
+ # We also write the answer + reasoning into the agent's working
1002
+ # memory so subsequent questions can reference what they previously
1003
+ # said — that's the "local learning" loop.
1004
+ claim_records: list[dict[str, Any]] = []
1005
+ if publish_claims:
1006
+ async def _publish_one(prof: "AgentProfile", vote: dict) -> "dict | None":
1007
+ try:
1008
+ payload = {
1009
+ "kind": "panel_answer",
1010
+ "query": query,
1011
+ "confidence": vote["confidence"],
1012
+ "reasoning": vote.get("reasoning", "")[:2000],
1013
+ "persona": prof.persona,
1014
+ }
1015
+ kid = await prof.agent.publish_claim(
1016
+ payload=payload,
1017
+ claim_type="eval_result",
1018
+ uncertainty=Uncertainty.beta(
1019
+ max(1.0, vote["confidence"] * vote.get("n_eff", 10)),
1020
+ max(1.0, (1 - vote["confidence"]) * vote.get("n_eff", 10)),
1021
+ ),
1022
+ topic=TOPIC,
1023
+ )
1024
+ record = {
1025
+ "kid": kid.hex(),
1026
+ "agent": prof.name,
1027
+ "persona": prof.persona,
1028
+ "query": query,
1029
+ "confidence": vote["confidence"],
1030
+ "round": 0,
1031
+ "ts_wall_ms": int(time.time() * 1000),
1032
+ }
1033
+ # Update the agent's working memory — local learning
1034
+ mem = prof.agent.working_memory()
1035
+ facts = mem.setdefault("facts_learned", [])
1036
+ facts.append(
1037
+ {
1038
+ "ts_wall_ms": int(time.time() * 1000),
1039
+ "fact": f"I answered '{query[:60]}…' at {vote['confidence']:.0%}",
1040
+ "source": "self-assertion",
1041
+ "claim_kid": kid.hex(),
1042
+ }
1043
+ )
1044
+ prof.published_claims = getattr(prof, "published_claims", [])
1045
+ prof.published_claims.append(record)
1046
+ prof.record_timeline(
1047
+ "publish_claim",
1048
+ f"Sealed answer at {vote['confidence']:.0%} → kid {kid.hex()[:12]}…",
1049
+ claim_kid=kid.hex(),
1050
+ confidence=vote["confidence"],
1051
+ )
1052
+ return record
1053
+ except Exception as exc:
1054
+ prof.record_timeline("publish_error", str(exc)[:120])
1055
+ return None
1056
+
1057
+ _pub_results = await asyncio.gather(
1058
+ *[_publish_one(p, v) for p, v in answers], return_exceptions=True
1059
+ )
1060
+ claim_records = [r for r in _pub_results if isinstance(r, dict)]
1061
+
1062
+ # ---- Citations: agents who agree with someone else cite that claim --
1063
+ # An agent "cites" another when its confidence is within ±10pp of
1064
+ # another agent's claim from a different persona. We surface this in
1065
+ # the dashboard's conversation feed.
1066
+ citations: list[dict[str, Any]] = []
1067
+ for prof, vote in answers:
1068
+ my_conf = vote["confidence"]
1069
+ my_kid = next(
1070
+ (c["kid"] for c in claim_records if c["agent"] == prof.name),
1071
+ None,
1072
+ )
1073
+ if my_kid is None:
1074
+ continue
1075
+ # Find someone from a different persona whose confidence is within ±10pp
1076
+ for c in claim_records:
1077
+ if c["agent"] == prof.name or c["persona"] == prof.persona:
1078
+ continue
1079
+ if abs(c["confidence"] - my_conf) > 0.10:
1080
+ continue
1081
+ citations.append(
1082
+ {
1083
+ "from_agent": prof.name,
1084
+ "from_persona": prof.persona,
1085
+ "to_agent": c["agent"],
1086
+ "to_persona": c["persona"],
1087
+ "to_claim_kid": c["kid"],
1088
+ "their_conf": c["confidence"],
1089
+ "my_conf": my_conf,
1090
+ "delta": round(abs(c["confidence"] - my_conf), 4),
1091
+ "query": query,
1092
+ }
1093
+ )
1094
+ prof.record_timeline(
1095
+ "cite",
1096
+ f"Cited {c['agent']} (Δ{abs(c['confidence'] - my_conf):.0%})",
1097
+ target_kid=c["kid"],
1098
+ target_agent=c["agent"],
1099
+ )
1100
+ break # one citation per question per agent is enough
1101
+
1102
+ # ---- Disputes: agents who strongly disagree (≥30pp) file a dispute -
1103
+ disputes_filed: list[dict[str, Any]] = []
1104
+ if enable_disputes:
1105
+ async def _dispute_one(prof: "AgentProfile", vote: dict) -> "dict | None":
1106
+ my_conf = vote["confidence"]
1107
+ opposing = [c for c in claim_records if c["agent"] != prof.name]
1108
+ if not opposing:
1109
+ return None
1110
+ extreme = max(opposing, key=lambda c: abs(c["confidence"] - my_conf))
1111
+ delta = abs(extreme["confidence"] - my_conf)
1112
+ if delta < 0.30:
1113
+ return None
1114
+ try:
1115
+ target_kid_bytes = bytes.fromhex(extreme["kid"])
1116
+ dispute_kid = await prof.agent.dispute(
1117
+ target_kid_bytes,
1118
+ reason=(
1119
+ f"As a {prof.persona}, I answered {my_conf:.0%} but "
1120
+ f"{extreme['agent']} ({extreme['persona']}) answered "
1121
+ f"{extreme['confidence']:.0%}. {prof.persona_info.get('label', prof.persona)} "
1122
+ f"perspective contests this."
1123
+ ),
1124
+ bond_topic_scope=TOPIC,
1125
+ )
1126
+ prof.record_timeline(
1127
+ "dispute",
1128
+ f"Disputed {extreme['agent']} (Δ{delta:.0%})",
1129
+ target_kid=extreme["kid"],
1130
+ target_agent=extreme["agent"],
1131
+ )
1132
+ return {
1133
+ "from_agent": prof.name,
1134
+ "from_persona": prof.persona,
1135
+ "target_agent": extreme["agent"],
1136
+ "target_persona": extreme["persona"],
1137
+ "target_kid": extreme["kid"],
1138
+ "dispute_kid": dispute_kid.hex(),
1139
+ "delta": round(delta, 4),
1140
+ "query": query,
1141
+ "ts_wall_ms": int(time.time() * 1000),
1142
+ }
1143
+ except Exception as exc:
1144
+ prof.record_timeline("dispute_error", str(exc)[:120])
1145
+ return None
1146
+
1147
+ _disp_results = await asyncio.gather(
1148
+ *[_dispute_one(p, v) for p, v in answers], return_exceptions=True
1149
+ )
1150
+ disputes_filed = [r for r in _disp_results if isinstance(r, dict)]
1151
+
1152
+ return {
1153
+ "id": qid,
1154
+ "query": query,
1155
+ "ground_truth": ground_truth,
1156
+ "n_responses": len(answers),
1157
+ "mean_confidence": round(mean_conf, 4),
1158
+ "min_confidence": round(min(confidences), 4),
1159
+ "max_confidence": round(max(confidences), 4),
1160
+ "spread": round(max(confidences) - min(confidences), 4),
1161
+ "lowest": {"agent": sorted_pa[0][0].name, "confidence": sorted_pa[0][1]["confidence"]},
1162
+ "highest": {"agent": sorted_pa[-1][0].name, "confidence": sorted_pa[-1][1]["confidence"]},
1163
+ "elapsed_ms": round(elapsed_ms, 1),
1164
+ "claims": claim_records,
1165
+ "citations": citations,
1166
+ "disputes": disputes_filed,
1167
+ "per_agent": [
1168
+ {
1169
+ "agent": p.name,
1170
+ "persona": p.persona,
1171
+ "confidence": v["confidence"],
1172
+ "reasoning": v.get("reasoning", "")[:2000],
1173
+ }
1174
+ for p, v in answers
1175
+ ],
1176
+ }
1177
+
1178
+
1179
+ # ---------------------------------------------------------------------------
1180
+ # Debug formatter (Rich, emoji-prefixed)
1181
+ # ---------------------------------------------------------------------------
1182
+
1183
+
1184
+ def _print_per_agent_block(console: Console, prof: AgentProfile, vote: dict) -> None:
1185
+ """Print a non-tech-friendly per-agent block after they answer."""
1186
+ info = prof.persona_info
1187
+ emoji = info.get("emoji", "🎭")
1188
+ conf = vote.get("confidence", 0.0)
1189
+ n_tools = vote.get("n_tool_calls", 0)
1190
+ elapsed_ms = vote.get("elapsed_ms", 0)
1191
+ reasoning = (vote.get("reasoning", "") or "").strip()
1192
+ if len(reasoning) > 500:
1193
+ reasoning = reasoning[:500] + "…"
1194
+
1195
+ head = (
1196
+ f"{emoji} [bold cyan]{prof.name}[/bold cyan] "
1197
+ f"[white]conf={conf:.2f}[/white] "
1198
+ f"[dim]{elapsed_ms:.0f}ms · tools={n_tools}[/dim]"
1199
+ )
1200
+ console.print(head)
1201
+
1202
+ # Tool calls just made on this query
1203
+ recent_tools = [t for t in prof.tool_calls if t.get("query") == vote.get("query")]
1204
+ for t in recent_tools[-5:]: # cap visible
1205
+ tool_name = t.get("tool", "?")
1206
+ args = t.get("args_json", "")
1207
+ if len(args) > 80:
1208
+ args = args[:80] + "…"
1209
+ console.print(f" ↳ [yellow]{tool_name}[/yellow]([dim]{args}[/dim])")
1210
+
1211
+ if reasoning:
1212
+ console.print(f" 💭 [italic]{reasoning}[/italic]")
1213
+
1214
+ # Memory diff
1215
+ last_mem = prof.memory_states[-1] if prof.memory_states else None
1216
+ if last_mem and last_mem.get("diff"):
1217
+ diff = last_mem["diff"]
1218
+ if diff["added"] or diff["changed"]:
1219
+ tags = []
1220
+ if diff["added"]:
1221
+ tags.append(f"added={diff['added']}")
1222
+ if diff["changed"]:
1223
+ tags.append(f"changed={diff['changed']}")
1224
+ console.print(f" 📌 [magenta]memory:[/magenta] [dim]{', '.join(tags)}[/dim]")
1225
+ console.print()
1226
+
1227
+
1228
+ def _print_panel_diff(console: Console, panel_record: dict) -> None:
1229
+ """Print a non-tech disagreement summary after every panel."""
1230
+ console.print()
1231
+ console.rule(
1232
+ f"[bold]Q: {panel_record['query'][:70]}[/bold]",
1233
+ style="cyan",
1234
+ )
1235
+ n = panel_record.get("n_responses", 0)
1236
+ if n == 0:
1237
+ console.print("[red]No responses[/red]")
1238
+ return
1239
+ mean = panel_record.get("mean_confidence", 0)
1240
+ spread = panel_record.get("spread", 0)
1241
+ console.print(
1242
+ f"[bold]Panel mean: {mean:.0%}[/bold] "
1243
+ f"[dim]spread: {spread:.0%} across {n} answers · "
1244
+ f"{panel_record.get('elapsed_ms', 0):.0f}ms[/dim]"
1245
+ )
1246
+ high = panel_record.get("highest", {})
1247
+ low = panel_record.get("lowest", {})
1248
+ if high and low:
1249
+ console.print(
1250
+ f" ⬆ Most YES: [green]{high.get('agent', '?')}[/green] "
1251
+ f"{high.get('confidence', 0):.0%}"
1252
+ )
1253
+ console.print(
1254
+ f" ⬇ Most NO: [red]{low.get('agent', '?')}[/red] {low.get('confidence', 0):.0%}"
1255
+ )
1256
+ if panel_record.get("ground_truth") is not None:
1257
+ gt = panel_record["ground_truth"]
1258
+ delta = abs(mean - gt)
1259
+ flag = (
1260
+ "[green]✓ within target[/green]"
1261
+ if delta < 0.10
1262
+ else f"[yellow]Δ={delta:.0%} from target {gt:.0%}[/yellow]"
1263
+ )
1264
+ console.print(f" 🎯 Calibration: {flag}")
1265
+ console.print()
1266
+
1267
+
1268
+ # ---------------------------------------------------------------------------
1269
+ # Trace serialisation
1270
+ # ---------------------------------------------------------------------------
1271
+
1272
+
1273
+ def _trace_to_dict(trace: Trace) -> dict[str, Any]:
1274
+ # Build a unified, chronologically-sorted "conversation feed" of all
1275
+ # protocol events: claims, citations, disputes. This is what powers
1276
+ # the dashboard's Conversation view.
1277
+ feed: list[dict[str, Any]] = []
1278
+ for p in trace.profiles:
1279
+ for c in p.published_claims:
1280
+ feed.append({**c, "kind": "claim"})
1281
+ for r in trace.panel_records:
1282
+ for cite in r.get("citations", []):
1283
+ feed.append({**cite, "kind": "citation", "ts_wall_ms": r.get("ts_wall_ms", 0)})
1284
+ for d in r.get("disputes", []):
1285
+ feed.append({**d, "kind": "dispute"})
1286
+ feed.sort(key=lambda x: x.get("ts_wall_ms", 0))
1287
+
1288
+ total_claims = sum(len(p.published_claims) for p in trace.profiles)
1289
+ total_citations = sum(len(r.get("citations", [])) for r in trace.panel_records)
1290
+ total_disputes = sum(len(r.get("disputes", [])) for r in trace.panel_records)
1291
+
1292
+ total_llm_calls = sum(p.n_llm_calls for p in trace.profiles)
1293
+ total_input_tokens = sum(p.total_input_tokens for p in trace.profiles)
1294
+ total_output_tokens = sum(p.total_output_tokens for p in trace.profiles)
1295
+ estimated_cost_usd = _estimate_cost(
1296
+ trace.llm_model or "",
1297
+ total_input_tokens,
1298
+ total_output_tokens,
1299
+ )
1300
+
1301
+ # Collective IQ score (P9 / P10)
1302
+ collective_iq = compute_collective_iq(trace)
1303
+
1304
+ return {
1305
+ "started_at": trace.started_at,
1306
+ "finished_at": trace.finished_at,
1307
+ "elapsed_s": round(trace.finished_at - trace.started_at, 4),
1308
+ "topic": trace.topic,
1309
+ "use_real_llm": trace.use_real_llm,
1310
+ "llm_model": trace.llm_model,
1311
+ "n_agents": len(trace.profiles),
1312
+ "n_questions": len(trace.questions),
1313
+ "total_tool_calls": trace.total_tool_calls,
1314
+ "total_llm_calls": total_llm_calls,
1315
+ "total_input_tokens": total_input_tokens,
1316
+ "total_output_tokens": total_output_tokens,
1317
+ "estimated_cost_usd": estimated_cost_usd,
1318
+ "total_claims": total_claims,
1319
+ "total_citations": total_citations,
1320
+ "total_disputes": total_disputes,
1321
+ "conversation_feed": feed,
1322
+ # ── Collective intelligence wiring (P1–P10) ──
1323
+ "collective_iq": collective_iq,
1324
+ "deliberation_records": trace.deliberation_records,
1325
+ "oracle_resolutions": trace.oracle_resolutions,
1326
+ "syntheses": trace.syntheses,
1327
+ "vouches": trace.vouches,
1328
+ "captoken_events": trace.captoken_events,
1329
+ "audit_replay": trace.audit_replay,
1330
+ "reputation_snapshots": trace.reputation_snapshots,
1331
+ # ── Phase timing breakdown (KPI #1) ──
1332
+ "setup_s": round(trace.setup_s, 3),
1333
+ "deliberation_s": round(trace.deliberation_s, 3),
1334
+ "oracle_s": round(trace.oracle_s, 3),
1335
+ "synthesis_s": round(trace.synthesis_s, 3),
1336
+ "n_deliberation_rounds": trace.n_deliberation_rounds,
1337
+ "questions_timing": trace.questions_timing,
1338
+ "questions": trace.questions,
1339
+ "panel_records": trace.panel_records,
1340
+ "personas": {
1341
+ name: {
1342
+ "label": info["label"],
1343
+ "emoji": info.get("emoji", "🎭"),
1344
+ "tool_affinity": info.get("tool_affinity"),
1345
+ "system_prompt": info.get("system"),
1346
+ "starting_memory": info.get("starting_memory", []),
1347
+ }
1348
+ for name, info in PERSONAS.items()
1349
+ if name in {p.persona for p in trace.profiles}
1350
+ },
1351
+ "agents": [
1352
+ {
1353
+ "name": p.name,
1354
+ "persona": p.persona,
1355
+ "kid": p.agent.keypair.kid.hex(),
1356
+ "did_key": p.agent.keypair.did_key,
1357
+ "starting_memory": list(p.persona_info.get("starting_memory", [])),
1358
+ # Full persona_info dict including the (possibly
1359
+ # swarm-bias-modified) system prompt, label, emoji,
1360
+ # tool_affinity, n_eff. Lets the UI show *exactly* what
1361
+ # this agent was instantiated with, even if a swarm
1362
+ # appended a shared bias to a built-in persona.
1363
+ "persona_info": dict(p.persona_info),
1364
+ "panel_votes": p.panel_votes,
1365
+ "tool_calls": p.tool_calls,
1366
+ "memory_states": [
1367
+ {
1368
+ "ts_wall_ms": m["ts_wall_ms"],
1369
+ "query": m["query"],
1370
+ "diff": m["diff"],
1371
+ }
1372
+ for m in p.memory_states
1373
+ ],
1374
+ "final_memory": p.agent.working_memory(),
1375
+ "structured": p.structured,
1376
+ "agent_timeline": p.agent_timeline,
1377
+ "published_claims": p.published_claims,
1378
+ "n_panel_votes": len(p.panel_votes),
1379
+ "n_tool_calls": len(p.tool_calls),
1380
+ "n_claims_published": len(p.published_claims),
1381
+ "n_llm_calls": p.n_llm_calls,
1382
+ "input_tokens": p.total_input_tokens,
1383
+ "output_tokens": p.total_output_tokens,
1384
+ }
1385
+ for p in trace.profiles
1386
+ ],
1387
+ }
1388
+
1389
+
1390
+ def _flush_sim_memory_to_relata(profiles: "list[AgentProfile]", *, sim_id: str) -> None:
1391
+ """Flush each agent's facts_learned to Relata after a sim completes.
1392
+
1393
+ Best-effort: any exception is swallowed so Relata unavailability never
1394
+ blocks a sim. Requires RELATA_URL to be set; no-ops otherwise.
1395
+ """
1396
+ url = os.getenv("RELATA_URL")
1397
+ if not url:
1398
+ return
1399
+ try:
1400
+ from hypermind.mind.memory_store import RelataMemoryStore
1401
+ except ImportError:
1402
+ return
1403
+ for prof in profiles:
1404
+ try:
1405
+ facts = prof.agent.working_memory().get("facts_learned", [])
1406
+ if not facts:
1407
+ continue
1408
+ items = [
1409
+ f.get("fact", str(f)) if isinstance(f, dict) else str(f)
1410
+ for f in facts
1411
+ ]
1412
+ kid_bytes = bytes(prof.agent.keypair.kid)
1413
+ store = RelataMemoryStore(
1414
+ url,
1415
+ namespace_id=f"simlab-{sim_id[:12]}",
1416
+ agent_kid=kid_bytes,
1417
+ purpose="agent_notes",
1418
+ )
1419
+ store.open()
1420
+ store.remember_batch(items)
1421
+ store.close()
1422
+ except Exception:
1423
+ pass
1424
+
1425
+
1426
+ def _write_html_viewer(html_path: str, json_filename: str) -> None:
1427
+ """Deprecated: the static HTML debugger has been removed.
1428
+
1429
+ Modern usage: ``hmctl simlab`` boots a Vue 3 SPA + ASGI app that
1430
+ lists, streams, and compares runs. This function is a no-op and
1431
+ is kept only so the legacy ``--html`` CLI flag does not crash.
1432
+ """
1433
+ return None
1434
+
1435
+
1436
+ # ===========================================================================
1437
+ # COLLECTIVE INTELLIGENCE WIRING — P1 through P10 from the architecture audit
1438
+ # ===========================================================================
1439
+ #
1440
+ # These helpers wire the protocol primitives that make the panel
1441
+ # behave as a collective rather than a parallel pool of LLMs:
1442
+ #
1443
+ # P1 deliberation rounds (run_deliberation_phase)
1444
+ # P2 peer claim subscription (setup_peer_subscriptions)
1445
+ # P3 oracle resolution + backprop (run_oracle_phase)
1446
+ # P4 synthesize() w/ parent_kids (synthesize_panel_verdict)
1447
+ # P5 prior-self memory injection (inject_prior_self_into_responder)
1448
+ # P6 vouch chain (run_vouch_phase)
1449
+ # P7 CapToken gates (mint_oracle_captoken + demonstrate_refusal)
1450
+ # P8 audit_export + replay (capture_and_replay_audit)
1451
+ # P9/P10 dashboard surface (computed in trace_to_dict)
1452
+
1453
+
1454
+ async def setup_peer_subscriptions(profiles: list[AgentProfile]) -> int:
1455
+ """P2 — make every agent subscribe to the topic so they hear each other.
1456
+
1457
+ When peer_X publishes a claim, this agent's working_memory["peer_claims"]
1458
+ grows with the new claim's metadata. Subsequent LLM responder calls see
1459
+ a 'peer claims' section in their context.
1460
+ """
1461
+ n_subs = 0
1462
+
1463
+ def _make_callback(prof: AgentProfile):
1464
+ async def _on_claim(event: Any) -> None:
1465
+ try:
1466
+ stmt = getattr(event, "statement", None)
1467
+ if stmt is None:
1468
+ return
1469
+ # Skip our own claims
1470
+ if stmt.issuer_kid == prof.agent.keypair.kid:
1471
+ return
1472
+ # Decode payload to find another agent's panel answer
1473
+ import cbor2
1474
+
1475
+ try:
1476
+ decoded = cbor2.loads(stmt.payload)
1477
+ except Exception:
1478
+ return
1479
+ inner = decoded.get("payload", {}) if isinstance(decoded, dict) else {}
1480
+ if not isinstance(inner, dict) or inner.get("kind") != "panel_answer":
1481
+ return
1482
+ mem = prof.agent.working_memory()
1483
+ peer_claims = mem.setdefault("peer_claims", [])
1484
+ peer_claims.append(
1485
+ {
1486
+ "issuer_kid_hex": stmt.issuer_kid.hex()[:24],
1487
+ "persona": inner.get("persona", "unknown"),
1488
+ "query": inner.get("query", ""),
1489
+ "confidence": inner.get("confidence"),
1490
+ "reasoning": (inner.get("reasoning", "") or "")[:400],
1491
+ "claim_kid_hex": stmt.kid.hex(),
1492
+ "ts_wall_ms": int(time.time() * 1000),
1493
+ }
1494
+ )
1495
+ # Bound the list so memory doesn't grow unboundedly
1496
+ if len(peer_claims) > 1000:
1497
+ peer_claims[:] = peer_claims[-1000:]
1498
+ except Exception:
1499
+ pass
1500
+
1501
+ return _on_claim
1502
+
1503
+ for p in profiles:
1504
+ try:
1505
+ sub = await p.agent.subscribe(TOPIC, _make_callback(p))
1506
+ if sub is not None:
1507
+ n_subs += 1
1508
+ except Exception:
1509
+ pass
1510
+ return n_subs
1511
+
1512
+
1513
+ async def run_deliberation_phase(
1514
+ profiles: list[AgentProfile],
1515
+ question: dict,
1516
+ responder: TracingPersonaResponder,
1517
+ panel_record_round0: dict,
1518
+ *,
1519
+ n_extra_rounds: int = 1,
1520
+ convergence_threshold: float = 0.05,
1521
+ ) -> list[dict[str, Any]]:
1522
+ """P1 — after round-0, run additional rounds where each agent sees an
1523
+ anonymised peer reasoning summary and may revise.
1524
+
1525
+ Returns a list of round records: [{round_idx, mean, spread, deltas: [...]}].
1526
+ Round-0 is included as the first entry.
1527
+ """
1528
+ rounds: list[dict[str, Any]] = []
1529
+
1530
+ # Round 0 — already happened
1531
+ rounds.append(
1532
+ {
1533
+ "round_idx": 0,
1534
+ "mean": panel_record_round0.get("mean_confidence"),
1535
+ "spread": panel_record_round0.get("spread"),
1536
+ "n_responses": panel_record_round0.get("n_responses"),
1537
+ }
1538
+ )
1539
+
1540
+ query = question.get("query", "")
1541
+ last_mean = panel_record_round0.get("mean_confidence", 0)
1542
+ last_spread = panel_record_round0.get("spread", 1.0)
1543
+
1544
+ for r in range(1, n_extra_rounds + 1):
1545
+ # Prepare per-agent contextualised query with peer summary
1546
+ _round_results: list[tuple[AgentProfile, float, str]] = [] # (prof, conf, reasoning)
1547
+
1548
+ async def _re_elicit(prof: AgentProfile):
1549
+ # Skip agents that already converged in the previous round
1550
+ if r > 1:
1551
+ votes_for_q = [v for v in prof.panel_votes if query[-50:] in (v.get("query") or "")]
1552
+ if len(votes_for_q) >= 2:
1553
+ delta = abs(votes_for_q[-1].get("confidence", 0) - votes_for_q[-2].get("confidence", 0))
1554
+ if delta < 0.03:
1555
+ return # converged — skip this LLM call
1556
+ peer_summary = _peer_reasoning_summary(profiles, query, prof.agent.keypair.kid)
1557
+ ctx_query = (
1558
+ f"{peer_summary}\n\n--- ORIGINAL QUESTION ---\n{query}" if peer_summary else query
1559
+ )
1560
+ try:
1561
+ await asyncio.wait_for(
1562
+ responder(prof.agent.keypair.kid, ctx_query),
1563
+ timeout=45.0,
1564
+ )
1565
+ except TimeoutError:
1566
+ pass
1567
+ except asyncio.CancelledError:
1568
+ raise
1569
+ except Exception:
1570
+ pass
1571
+
1572
+ await asyncio.gather(*[_re_elicit(p) for p in profiles], return_exceptions=True)
1573
+
1574
+ # Collect each profile's *latest* vote on this query (the round-r answer)
1575
+ confs: list[float] = []
1576
+ deltas: list[dict[str, Any]] = []
1577
+ for p in profiles:
1578
+ votes_for_q = [
1579
+ v
1580
+ for v in p.panel_votes
1581
+ if v.get("query")
1582
+ in (
1583
+ query,
1584
+ f"{query}",
1585
+ )
1586
+ or query[-50:] in (v.get("query", "") or "")
1587
+ ]
1588
+ # The newly-appended one will be at the end if peer_summary was prefixed
1589
+ # Take the LATEST vote regardless of exact-query-match (deliberation
1590
+ # appends a contextualised query, but we still want the latest pick).
1591
+ if not votes_for_q:
1592
+ # fallback: just take the absolute latest vote
1593
+ votes_for_q = p.panel_votes[-1:] if p.panel_votes else []
1594
+ if not votes_for_q:
1595
+ continue
1596
+ latest = votes_for_q[-1]
1597
+ if "confidence" not in latest:
1598
+ continue
1599
+ new_conf = latest["confidence"]
1600
+ confs.append(new_conf)
1601
+ # Compare to round-(r-1)
1602
+ prior = next(
1603
+ (
1604
+ v
1605
+ for v in reversed(p.panel_votes[:-1])
1606
+ if v.get("query") == query and "confidence" in v
1607
+ ),
1608
+ None,
1609
+ )
1610
+ prior_conf = prior["confidence"] if prior else None
1611
+ deltas.append(
1612
+ {
1613
+ "agent": p.name,
1614
+ "persona": p.persona,
1615
+ "prior": prior_conf,
1616
+ "new": new_conf,
1617
+ "delta": (new_conf - prior_conf) if prior_conf is not None else 0.0,
1618
+ }
1619
+ )
1620
+
1621
+ if not confs:
1622
+ break
1623
+ new_mean = sum(confs) / len(confs)
1624
+ new_spread = max(confs) - min(confs)
1625
+ rounds.append(
1626
+ {
1627
+ "round_idx": r,
1628
+ "mean": round(new_mean, 4),
1629
+ "spread": round(new_spread, 4),
1630
+ "n_responses": len(confs),
1631
+ "deltas": deltas,
1632
+ }
1633
+ )
1634
+ # Convergence: if mean shifted < threshold AND spread tightened, stop.
1635
+ if abs(new_mean - last_mean) < convergence_threshold and new_spread <= last_spread:
1636
+ break
1637
+ last_mean = new_mean
1638
+ last_spread = new_spread
1639
+
1640
+ return rounds
1641
+
1642
+
1643
+ async def synthesize_panel_verdict(
1644
+ anchor_agent: HyperMindAgent,
1645
+ panel_record: dict,
1646
+ ) -> dict[str, Any] | None:
1647
+ """P4 — anchor agent seals the panel verdict as a synthesis with parent_kids
1648
+ pointing at every contributing claim.
1649
+ """
1650
+ claims = panel_record.get("claims", []) or []
1651
+ if not claims:
1652
+ return None
1653
+ parent_kids = [bytes.fromhex(c["kid"]) for c in claims if c.get("kid")]
1654
+ mean = panel_record.get("mean_confidence", 0.5)
1655
+ n_eff = max(2.0, float(len(claims)))
1656
+ try:
1657
+ synth_kid = await anchor_agent.synthesize(
1658
+ parent_kids=parent_kids,
1659
+ summary_payload={
1660
+ "kind": "panel_synthesis",
1661
+ "query": panel_record.get("query", ""),
1662
+ "mean_confidence": mean,
1663
+ "spread": panel_record.get("spread"),
1664
+ "n_contributing_claims": len(parent_kids),
1665
+ },
1666
+ uncertainty=Uncertainty.beta(
1667
+ max(1.0, mean * n_eff * 5),
1668
+ max(1.0, (1 - mean) * n_eff * 5),
1669
+ ),
1670
+ )
1671
+ return {
1672
+ "synthesis_kid": synth_kid.hex(),
1673
+ "anchor": anchor_agent.keypair.kid.hex()[:24],
1674
+ "n_parents": len(parent_kids),
1675
+ "mean": mean,
1676
+ "query": panel_record.get("query", ""),
1677
+ "ts_wall_ms": int(time.time() * 1000),
1678
+ }
1679
+ except Exception:
1680
+ return None
1681
+
1682
+
1683
+ def generate_followup_questions(
1684
+ panel_records: list[dict],
1685
+ oracle_resolutions: list[dict] | None = None,
1686
+ *,
1687
+ top_k: int = 10,
1688
+ spread_threshold: float = 0.25,
1689
+ low_conf_threshold: float = 0.45,
1690
+ ) -> list[dict]:
1691
+ """Generate autonomous follow-up questions from simulation results (spec §20.5 / P7).
1692
+
1693
+ Heuristic triggers (no LLM required):
1694
+ - high_spread: panel disagreed (spread > spread_threshold)
1695
+ - dispute: claims were actively disputed and not resolved
1696
+ - low_confidence: panel was ambiguous (mean < low_conf_threshold and > 1-low_conf_threshold)
1697
+ - gap: question referenced in reasoning but never directly asked
1698
+
1699
+ Returns list of dicts with: text, rationale, uncertainty_score, source_question, trigger.
1700
+ """
1701
+ resolved_queries: set[str] = {r["oracle"] for r in (oracle_resolutions or [])}
1702
+ followups: list[dict] = []
1703
+
1704
+ for rec in panel_records:
1705
+ query = rec.get("query", "")
1706
+ mean_conf = rec.get("mean_confidence", 0.5)
1707
+ spread = rec.get("spread", 0.0)
1708
+ disputes = rec.get("disputes", [])
1709
+ is_resolved = query in resolved_queries
1710
+
1711
+ # Trigger 1: high spread — panel disagreed significantly
1712
+ if spread >= spread_threshold:
1713
+ uncertainty_score = min(1.0, spread * 2.5)
1714
+ followups.append(
1715
+ {
1716
+ "text": (
1717
+ f"Given the panel disagreement on '{query[:120]}' "
1718
+ f"(confidence spread: {spread:.0%}), what specific evidence "
1719
+ f"or data source would most effectively resolve this uncertainty?"
1720
+ ),
1721
+ "rationale": (
1722
+ f"Panel spread was {spread:.0%} — agents disagreed significantly. "
1723
+ f"Mean confidence: {mean_conf:.0%}. Investigating the root cause "
1724
+ f"of disagreement would sharpen the collective estimate."
1725
+ ),
1726
+ "uncertainty_score": uncertainty_score,
1727
+ "source_question": query,
1728
+ "trigger": "high_spread",
1729
+ }
1730
+ )
1731
+
1732
+ # Trigger 2: active disputes not resolved
1733
+ if disputes and not is_resolved:
1734
+ n_disputes = len(disputes)
1735
+ uncertainty_score = min(1.0, 0.4 + n_disputes * 0.15)
1736
+ disputors = list({d.get("from_persona", "") for d in disputes[:3]})
1737
+ followups.append(
1738
+ {
1739
+ "text": (
1740
+ f"The claim '{query[:120]}' triggered {n_disputes} dispute(s). "
1741
+ f"What is the strongest argument against the majority position, "
1742
+ f"and what evidence would settle it?"
1743
+ ),
1744
+ "rationale": (
1745
+ f"{n_disputes} agent(s) disputed this claim without resolution. "
1746
+ f"Contesting personas: {', '.join(disputors[:3])}. "
1747
+ f"Targeted investigation of the counterargument could resolve the dispute."
1748
+ ),
1749
+ "uncertainty_score": uncertainty_score,
1750
+ "source_question": query,
1751
+ "trigger": "dispute",
1752
+ }
1753
+ )
1754
+
1755
+ # Trigger 3: ambiguous confidence (neither clearly yes nor clearly no)
1756
+ if (
1757
+ low_conf_threshold <= mean_conf <= (1.0 - low_conf_threshold)
1758
+ and spread < spread_threshold
1759
+ ):
1760
+ uncertainty_score = 1.0 - abs(mean_conf - 0.5) * 4
1761
+ followups.append(
1762
+ {
1763
+ "text": (
1764
+ f"The panel was ambiguous on '{query[:120]}' "
1765
+ f"(mean confidence: {mean_conf:.0%}). "
1766
+ f"What additional context or time horizon would shift this toward a clearer verdict?"
1767
+ ),
1768
+ "rationale": (
1769
+ f"Mean confidence {mean_conf:.0%} is in the ambiguous zone "
1770
+ f"(neither YES nor NO). The panel is uncertain rather than divided. "
1771
+ f"Probing assumptions or seeking base-rate data may resolve ambiguity."
1772
+ ),
1773
+ "uncertainty_score": uncertainty_score,
1774
+ "source_question": query,
1775
+ "trigger": "low_confidence",
1776
+ }
1777
+ )
1778
+
1779
+ # Trigger 4: knowledge gaps — terms in reasoning that look like unanswered questions
1780
+ asked_queries = {rec.get("query", "").lower() for rec in panel_records}
1781
+ for rec in panel_records:
1782
+ for agent_rec in rec.get("per_agent", []):
1783
+ reasoning = agent_rec.get("reasoning", "")
1784
+ # Heuristic: sentences ending with "?" that aren't the original query
1785
+ import re
1786
+
1787
+ sub_questions = re.findall(r"[^.!?]*\?", reasoning)
1788
+ for sq in sub_questions:
1789
+ sq_clean = sq.strip()
1790
+ if (
1791
+ len(sq_clean) > 20
1792
+ and sq_clean.lower() not in asked_queries
1793
+ and not any(sq_clean.lower() in q for q in asked_queries)
1794
+ ):
1795
+ followups.append(
1796
+ {
1797
+ "text": sq_clean,
1798
+ "rationale": (
1799
+ f"Surfaced from agent reasoning on '{rec.get('query', '')[:80]}'. "
1800
+ f"This sub-question was raised but not directly investigated."
1801
+ ),
1802
+ "uncertainty_score": 0.6,
1803
+ "source_question": rec.get("query", ""),
1804
+ "trigger": "gap",
1805
+ }
1806
+ )
1807
+ asked_queries.add(sq_clean.lower()) # dedup
1808
+
1809
+ # Sort by uncertainty_score descending, deduplicate by source_question+trigger
1810
+ seen: set[tuple] = set()
1811
+ deduped: list[dict] = []
1812
+ for fq in sorted(followups, key=lambda x: x["uncertainty_score"], reverse=True):
1813
+ key = (fq["source_question"], fq["trigger"])
1814
+ if key not in seen:
1815
+ seen.add(key)
1816
+ deduped.append(fq)
1817
+
1818
+ return deduped[:top_k]
1819
+
1820
+
1821
+ async def run_vouch_phase(profiles: list[AgentProfile], topic: str) -> list[dict[str, Any]]:
1822
+ """P6 — bootstrap trust before questions: each oracle vouches for one
1823
+ agent per other persona. Returns a list of vouch records.
1824
+
1825
+ Persona scenario doesn't have a built-in 'oracle' role, so we treat
1826
+ the *first* agent of each persona as a 'persona elder' that vouches
1827
+ for the first agents of the other personas at low strength.
1828
+ """
1829
+ vouch_records: list[dict[str, Any]] = []
1830
+ by_persona: dict[str, list[AgentProfile]] = {}
1831
+ for p in profiles:
1832
+ by_persona.setdefault(p.persona, []).append(p)
1833
+
1834
+ elders = [v[0] for v in by_persona.values()]
1835
+ if len(elders) < 2:
1836
+ return []
1837
+
1838
+ for voucher in elders:
1839
+ # Vouch for one elder per other persona (low strength to keep cap visible)
1840
+ for vouchee in elders:
1841
+ if vouchee.persona == voucher.persona:
1842
+ continue
1843
+ try:
1844
+ voucher.agent._world.reputation.vouch(
1845
+ voucher.agent.keypair.kid,
1846
+ vouchee.agent.keypair.kid,
1847
+ topic,
1848
+ strength=0.3,
1849
+ )
1850
+ # Snapshot reputation after to record the move
1851
+ snap = voucher.agent._world.reputation.snapshot(
1852
+ vouchee.agent.keypair.kid,
1853
+ topic,
1854
+ )
1855
+ vouch_records.append(
1856
+ {
1857
+ "voucher": voucher.name,
1858
+ "voucher_persona": voucher.persona,
1859
+ "vouchee": vouchee.name,
1860
+ "vouchee_persona": vouchee.persona,
1861
+ "topic": topic,
1862
+ "strength": 0.3,
1863
+ "vouchee_score_after": round(snap.score, 4),
1864
+ "ts_wall_ms": int(time.time() * 1000),
1865
+ }
1866
+ )
1867
+ except Exception:
1868
+ pass
1869
+ return vouch_records
1870
+
1871
+
1872
+ async def run_oracle_phase(
1873
+ profiles: list[AgentProfile],
1874
+ panel_records: list[dict],
1875
+ *,
1876
+ role_oracle_persona: str = "pattern-matcher-historian",
1877
+ ) -> list[dict[str, Any]]:
1878
+ """P3 — for every question with a ground_truth, an oracle agent resolves
1879
+ each per-agent claim with the truth bool. The kernel updates each
1880
+ publisher's reputation in the topic. Returns a list of resolution
1881
+ records for the dashboard.
1882
+
1883
+ We pick the *first* agent of ``role_oracle_persona`` and set its role
1884
+ to "oracle". Their CapToken (P7) gates the resolution.
1885
+ """
1886
+ oracle = next((p for p in profiles if p.persona == role_oracle_persona), None)
1887
+ if oracle is None:
1888
+ oracle = profiles[0]
1889
+ oracle.agent.role = "oracle"
1890
+ # S5 — late-binding role update on the persona registry so the
1891
+ # canvas role-ring and tooltip reflect the oracle assignment.
1892
+ try:
1893
+ from . import _persona_registry
1894
+
1895
+ # Find sim_id from the recorder if available (registry stores it
1896
+ # under whatever sim_id was used at remember() time; we just
1897
+ # update the in-flight bucket by walking).
1898
+ # The simpler path: update under all sim_ids that contain this
1899
+ # agent (in practice exactly one).
1900
+ kid_hex = oracle.agent.keypair.kid.hex()
1901
+ with _persona_registry._LOCK:
1902
+ for _sim_id, bucket in _persona_registry._REGISTRY.items():
1903
+ if kid_hex in bucket:
1904
+ bucket[kid_hex]["role"] = "oracle"
1905
+ if oracle.name in bucket:
1906
+ bucket[oracle.name]["role"] = "oracle"
1907
+ except Exception:
1908
+ pass
1909
+
1910
+ # CapToken minted in main(); we just use it implicitly via agent.cap_token.
1911
+
1912
+ async def _resolve_one(claim: dict, outcome_bool: bool, gt: float) -> "dict | None":
1913
+ try:
1914
+ kid_bytes = bytes.fromhex(claim["kid"])
1915
+ resolved_kid = await oracle.agent.oracle_resolve(
1916
+ kid_bytes,
1917
+ outcome=outcome_bool,
1918
+ evidence_cid=b"\xde\xad\xbe\xef" * 8,
1919
+ backprop=True,
1920
+ )
1921
+ return {
1922
+ "oracle": oracle.name,
1923
+ "resolved_kid": claim["kid"],
1924
+ "publisher": claim.get("agent"),
1925
+ "publisher_persona": claim.get("persona"),
1926
+ "outcome": outcome_bool,
1927
+ "ground_truth": gt,
1928
+ "predicted_confidence": claim.get("confidence"),
1929
+ "resolution_kid": resolved_kid.hex() if resolved_kid else None,
1930
+ "ts_wall_ms": int(time.time() * 1000),
1931
+ }
1932
+ except Exception:
1933
+ return None
1934
+
1935
+ _tasks = [
1936
+ _resolve_one(claim, gt >= 0.5, gt)
1937
+ for rec in panel_records
1938
+ for gt_raw in [rec.get("ground_truth")]
1939
+ if gt_raw is not None
1940
+ for gt in [float(gt_raw)]
1941
+ for claim in (rec.get("claims") or [])
1942
+ ]
1943
+ _res = await asyncio.gather(*_tasks, return_exceptions=True)
1944
+ resolutions: list[dict[str, Any]] = [r for r in _res if isinstance(r, dict)]
1945
+ return resolutions
1946
+
1947
+
1948
+ def mint_oracle_captoken(oracle_agent: HyperMindAgent, topic: str) -> dict[str, Any]:
1949
+ """P7 — mint a CapToken authorising oracle_resolution on this topic only.
1950
+
1951
+ Returns a metadata dict for the dashboard. The token itself is
1952
+ attached to the agent as ``agent.cap_token``.
1953
+ """
1954
+ try:
1955
+ from hypermind.capability import CapToken, Caveat
1956
+
1957
+ token = CapToken.mint(
1958
+ issuer_keypair=oracle_agent.keypair,
1959
+ subject=oracle_agent.keypair.kid,
1960
+ caveats=[
1961
+ Caveat.topic(topic),
1962
+ Caveat.claim_type("oracle_resolution"),
1963
+ ],
1964
+ )
1965
+ oracle_agent.cap_token = token
1966
+ return {
1967
+ "issuer_kid": oracle_agent.keypair.kid.hex()[:24],
1968
+ "topic": topic,
1969
+ "claim_type": "oracle_resolution",
1970
+ "minted_at_ms": int(time.time() * 1000),
1971
+ }
1972
+ except Exception as exc:
1973
+ return {"error": str(exc)[:160]}
1974
+
1975
+
1976
+ async def attempt_unauthorised_resolve(
1977
+ non_oracle: AgentProfile,
1978
+ target_kid: bytes,
1979
+ ) -> dict[str, Any]:
1980
+ """P7 — demonstrate the security gate by having a non-oracle agent
1981
+ try oracle_resolve. The role check raises AuthError; we record this
1982
+ as an audit event for the dashboard.
1983
+ """
1984
+ try:
1985
+ await non_oracle.agent.oracle_resolve(
1986
+ target_kid,
1987
+ outcome=True,
1988
+ evidence_cid=b"\x00" * 32,
1989
+ )
1990
+ return {"attempted": non_oracle.name, "blocked": False, "error": None}
1991
+ except Exception as exc:
1992
+ return {
1993
+ "attempted": non_oracle.name,
1994
+ "blocked": True,
1995
+ "error": str(exc)[:200],
1996
+ "error_type": type(exc).__name__,
1997
+ }
1998
+
1999
+
2000
+ async def capture_and_replay_audit(
2001
+ anchor_agent: HyperMindAgent,
2002
+ audit_path: str | None = None,
2003
+ ) -> dict[str, Any]:
2004
+ """P8 — export the audit log, run replay_audit_log(), surface the
2005
+ determinism verdict.
2006
+ """
2007
+ try:
2008
+ from hypermind.eval.replay import replay_audit_log
2009
+
2010
+ lines: list[bytes] = []
2011
+ async for line in anchor_agent.audit_export():
2012
+ lines.append(line if isinstance(line, bytes) else line.encode())
2013
+ if audit_path:
2014
+ try:
2015
+ Path(audit_path).write_bytes(b"\n".join(lines))
2016
+ except Exception:
2017
+ pass
2018
+ result = await replay_audit_log(lines, verify_signatures=True)
2019
+ return {
2020
+ "n_records": result.total_records,
2021
+ "replayed": result.replayed_records,
2022
+ "signature_failures": result.signature_failures,
2023
+ "brier_matches": result.brier_matches,
2024
+ "brier_mismatches": result.brier_mismatches,
2025
+ "deterministic": result.deterministic,
2026
+ "audit_path": audit_path,
2027
+ }
2028
+ except Exception as exc:
2029
+ return {"error": str(exc)[:200]}
2030
+
2031
+
2032
+ def snapshot_reputations(
2033
+ profiles: list[AgentProfile], topic: str, label: str
2034
+ ) -> list[dict[str, Any]]:
2035
+ """Capture per-agent reputation at a point in time (label = 'before',
2036
+ 'after_vouch', 'after_oracle'). Used by the dashboard's Trust tab to
2037
+ plot reputation movement."""
2038
+ snaps: list[dict[str, Any]] = []
2039
+ for p in profiles:
2040
+ try:
2041
+ snap = p.agent._world.reputation.snapshot(p.agent.keypair.kid, topic)
2042
+ snaps.append(
2043
+ {
2044
+ "agent": p.name,
2045
+ "persona": p.persona,
2046
+ "label": label,
2047
+ "score": round(snap.score, 4),
2048
+ "n_observations": snap.n_observations,
2049
+ "ts_wall_ms": int(time.time() * 1000),
2050
+ }
2051
+ )
2052
+ except Exception:
2053
+ pass
2054
+ return snaps
2055
+
2056
+
2057
+ def compute_collective_iq(trace: Trace) -> dict[str, Any]:
2058
+ """P10 — single 'collective intelligence' score combining four signals."""
2059
+ convergence = 0.0 # how much spread tightened across rounds
2060
+ if trace.deliberation_records:
2061
+ for d in trace.deliberation_records:
2062
+ rounds = d.get("rounds", [])
2063
+ if len(rounds) >= 2:
2064
+ first = rounds[0].get("spread", 1.0) or 1.0
2065
+ last = rounds[-1].get("spread", first) or first
2066
+ convergence += max(0.0, (first - last) / first)
2067
+ convergence = min(1.0, convergence / max(1, len(trace.deliberation_records)))
2068
+
2069
+ calibration = 0.0
2070
+ n_calib = 0
2071
+ for r in trace.panel_records:
2072
+ gt = r.get("ground_truth")
2073
+ mean = r.get("mean_confidence")
2074
+ if gt is None or mean is None:
2075
+ continue
2076
+ calibration += 1.0 - min(1.0, abs(mean - gt) * 2) # Δ1.0 → 0; Δ0.0 → 1
2077
+ n_calib += 1
2078
+ if n_calib:
2079
+ calibration /= n_calib
2080
+
2081
+ n_claims = sum(len(p.published_claims) for p in trace.profiles) or 1
2082
+ n_links = sum(
2083
+ len(r.get("citations", [])) + len(r.get("disputes", [])) for r in trace.panel_records
2084
+ )
2085
+ coordination_density = min(1.0, n_links / max(1, n_claims))
2086
+
2087
+ rep_motion = 0.0
2088
+ snaps_before = trace.reputation_snapshots.get("before", [])
2089
+ snaps_after = trace.reputation_snapshots.get("after_oracle", [])
2090
+ by_agent_after = {s["agent"]: s["score"] for s in snaps_after}
2091
+ if snaps_before:
2092
+ deltas = []
2093
+ for s in snaps_before:
2094
+ after = by_agent_after.get(s["agent"])
2095
+ if after is not None:
2096
+ deltas.append(abs(after - s["score"]))
2097
+ if deltas:
2098
+ rep_motion = min(1.0, (sum(deltas) / len(deltas)) * 4)
2099
+
2100
+ iq = convergence * 0.4 + calibration * 0.3 + coordination_density * 0.2 + rep_motion * 0.1
2101
+
2102
+ # S3 — EIG/Q (Effective Information Gain per Question, in bits).
2103
+ # Replaces the legacy `score` as the headline number; legacy fields
2104
+ # are kept for back-compat. See eval/swarm_iq for the formula.
2105
+ from hypermind.eval.swarm_iq import effective_information_gain_per_question
2106
+
2107
+ eig = effective_information_gain_per_question(
2108
+ trace.panel_records,
2109
+ trace.deliberation_records,
2110
+ )
2111
+
2112
+ # KPI #3 — convergence trajectory + per-question timing.
2113
+ spread_trajectory: list[float] = []
2114
+ convergence_round: int | None = None
2115
+ for d in trace.deliberation_records:
2116
+ for rnd in d.get("rounds", []) or []:
2117
+ s = rnd.get("spread")
2118
+ if not isinstance(s, (int, float)):
2119
+ continue
2120
+ spread_trajectory.append(round(float(s), 4))
2121
+ if convergence_round is None and float(s) < 0.10:
2122
+ convergence_round = len(spread_trajectory) # 1-based
2123
+
2124
+ elapsed_s = max(0.0, trace.finished_at - trace.started_at) if trace.finished_at else 0.0
2125
+ n_q = max(1, len(trace.panel_records))
2126
+ if trace.questions_timing:
2127
+ per_q = [
2128
+ float(q.get("elapsed_s", 0.0))
2129
+ for q in trace.questions_timing
2130
+ if isinstance(q.get("elapsed_s"), (int, float))
2131
+ ]
2132
+ mean_question_elapsed_s = (
2133
+ round(sum(per_q) / len(per_q), 4) if per_q else round(elapsed_s / n_q, 4)
2134
+ )
2135
+ else:
2136
+ mean_question_elapsed_s = round(elapsed_s / n_q, 4)
2137
+
2138
+ # time_to_convergence_s: prefer wall-clock to the converging round if
2139
+ # questions_timing carries per-round ts_wall_ms; else use total elapsed.
2140
+ time_to_convergence_s: float = round(elapsed_s, 4)
2141
+ if convergence_round is not None and trace.questions_timing:
2142
+ started_ms = trace.started_at * 1000.0
2143
+ seen = 0
2144
+ found = False
2145
+ for q in trace.questions_timing:
2146
+ if found:
2147
+ break
2148
+ for r in q.get("rounds") or []:
2149
+ seen += 1
2150
+ if seen == convergence_round:
2151
+ ts = r.get("ts_wall_ms")
2152
+ if isinstance(ts, (int, float)):
2153
+ time_to_convergence_s = round((float(ts) - started_ms) / 1000.0, 4)
2154
+ found = True
2155
+ break
2156
+
2157
+ return {
2158
+ "score": round(iq, 4),
2159
+ "convergence": round(convergence, 4),
2160
+ "calibration": round(calibration, 4),
2161
+ "coordination_density": round(coordination_density, 4),
2162
+ "reputation_motion": round(rep_motion, 4),
2163
+ # S3 — primary metric:
2164
+ "eig_per_q": [None if (v != v) else round(v, 4) for v in eig["eig_per_q"]],
2165
+ "eig_mean": None if (eig["eig_mean"] != eig["eig_mean"]) else round(eig["eig_mean"], 4),
2166
+ "eig_baseline_h": [round(h, 4) for h in eig["baseline_round0_h"]],
2167
+ "eig_n_scored": eig["n_scored"],
2168
+ # KPI #3 — convergence + timing:
2169
+ "spread_trajectory": spread_trajectory,
2170
+ "convergence_round": convergence_round,
2171
+ "time_to_convergence_s": time_to_convergence_s,
2172
+ "mean_question_elapsed_s": mean_question_elapsed_s,
2173
+ }
2174
+
2175
+
2176
+ # ---------------------------------------------------------------------------
2177
+ # Main
2178
+ # ---------------------------------------------------------------------------
2179
+
2180
+
2181
+ async def _main_impl(args: argparse.Namespace, *, recorder: Any = None) -> None:
2182
+ """Internal implementation of the persona-panel sim. Called by main()."""
2183
+ console = Console()
2184
+ trace = Trace(started_at=time.perf_counter())
2185
+
2186
+ # Decide question source: free-text topic vs bundled question set.
2187
+ topic = (getattr(args, "topic", "") or "").strip()
2188
+ if topic:
2189
+ _pregenerated = getattr(args, "_pregenerated_questions", None)
2190
+ if _pregenerated:
2191
+ questions = _pregenerated
2192
+ else:
2193
+ from hypermind.simlab.topic_questions import generate_questions_for_topic
2194
+
2195
+ n_q = int(getattr(args, "n_questions", 8) or 8)
2196
+ questions = await generate_questions_for_topic(
2197
+ topic,
2198
+ n_q,
2199
+ model=getattr(args, "llm_model", None),
2200
+ no_llm=bool(getattr(args, "no_llm", False)),
2201
+ )
2202
+ trace.topic = topic # surfaced in trace.json for the UI
2203
+ else:
2204
+ questions = _load_questions(args.questions)
2205
+ trace.questions = list(questions)
2206
+
2207
+ # Build the responder pool — one per persona
2208
+ api_key = os.environ.get("OPENROUTER_API_KEY", "").strip()
2209
+ use_real_llm = bool(api_key) and not args.no_llm
2210
+ trace.use_real_llm = use_real_llm
2211
+
2212
+ # ── Resolve panel composition ──────────────────────────────────
2213
+ # Three layered modes (highest priority first):
2214
+ # 1. swarm slug → load saved Swarm; uses its members + tools + bias
2215
+ # 2. personas_override list → ad-hoc list of slugs, each at args.replicas
2216
+ # 3. count fallback → first N built-in personas (legacy behavior)
2217
+ swarm_obj = None
2218
+ persona_replicas: dict[str, int] = {}
2219
+ persona_info_override: dict[str, dict[str, Any]] = {}
2220
+ swarm_tools_override: list[str] | None = None
2221
+
2222
+ swarm_slug = getattr(args, "swarm", None)
2223
+ personas_override = getattr(args, "personas_override", None)
2224
+
2225
+ if swarm_slug:
2226
+ from hypermind.simlab.personas import get_persona as _get_persona
2227
+ from hypermind.simlab.swarms import get_swarm
2228
+
2229
+ swarm_obj = get_swarm(swarm_slug)
2230
+ if swarm_obj is None:
2231
+ raise SystemExit(f"unknown swarm: {swarm_slug!r}")
2232
+ persona_names = []
2233
+ for m in swarm_obj.members:
2234
+ persona_names.append(m.persona_slug)
2235
+ persona_replicas[m.persona_slug] = m.replicas
2236
+ spec = _get_persona(m.persona_slug)
2237
+ if spec is None:
2238
+ raise SystemExit(
2239
+ f"swarm {swarm_slug!r} references unknown persona {m.persona_slug!r}",
2240
+ )
2241
+ info = {
2242
+ "label": spec.label,
2243
+ "emoji": spec.emoji,
2244
+ "system": (
2245
+ spec.system + "\n\n" + swarm_obj.bias_prompt
2246
+ if swarm_obj.bias_prompt
2247
+ else spec.system
2248
+ ),
2249
+ "starting_memory": list(spec.starting_memory),
2250
+ "tool_affinity": spec.tool_affinity,
2251
+ "n_eff": spec.n_eff,
2252
+ }
2253
+ persona_info_override[m.persona_slug] = info
2254
+ swarm_tools_override = list(swarm_obj.tools) if swarm_obj.tools else []
2255
+ elif personas_override:
2256
+ from hypermind.simlab.personas import get_persona as _get_persona
2257
+
2258
+ persona_names = list(personas_override)
2259
+ for slug in persona_names:
2260
+ spec = _get_persona(slug)
2261
+ if spec is None:
2262
+ raise SystemExit(f"unknown persona slug: {slug!r}")
2263
+ persona_info_override[slug] = {
2264
+ "label": spec.label,
2265
+ "emoji": spec.emoji,
2266
+ "system": spec.system,
2267
+ "starting_memory": list(spec.starting_memory),
2268
+ "tool_affinity": spec.tool_affinity,
2269
+ "n_eff": spec.n_eff,
2270
+ }
2271
+ persona_replicas[slug] = args.replicas
2272
+ else:
2273
+ persona_names = list(PERSONAS.keys())[: args.personas]
2274
+
2275
+ n_agents = sum(persona_replicas.get(p, args.replicas) for p in persona_names)
2276
+
2277
+ console.print(
2278
+ Panel.fit(
2279
+ f"[bold]Persona Panel Simulator[/bold]\n"
2280
+ f"[dim]{n_agents} agents · {len(persona_names)} personas × {args.replicas} replicas · "
2281
+ f"{len(questions)} questions[/dim]",
2282
+ border_style="cyan",
2283
+ title="🎭 PERSONA PANEL",
2284
+ )
2285
+ )
2286
+ console.print()
2287
+
2288
+ if use_real_llm:
2289
+ console.print(
2290
+ f" [green]✓ LLM mode[/green] model={args.llm_model} "
2291
+ f"tools={'on' if args.enable_tools else 'off'}",
2292
+ )
2293
+ else:
2294
+ console.print(
2295
+ " [yellow]Synthetic mode[/yellow] (set OPENROUTER_API_KEY for real LLMs)",
2296
+ )
2297
+ trace.llm_model = args.llm_model if use_real_llm else "synthetic"
2298
+ console.print()
2299
+
2300
+ # Persona table
2301
+ pt = Table(show_header=True, box=None, padding=(0, 2))
2302
+ pt.add_column("Persona")
2303
+ pt.add_column("Replicas", justify="right")
2304
+ pt.add_column("Tool affinity", style="yellow")
2305
+ pt.add_column("Starting facts", style="dim")
2306
+ for p in persona_names:
2307
+ info = persona_info_override.get(p) or PERSONAS[p]
2308
+ pt.add_row(
2309
+ f"{info['emoji']} {info['label']}",
2310
+ str(persona_replicas.get(p, args.replicas)),
2311
+ info.get("tool_affinity", "—"),
2312
+ str(len(info["starting_memory"])),
2313
+ )
2314
+ console.print(pt)
2315
+ if swarm_obj is not None:
2316
+ console.print(
2317
+ f" [cyan]Swarm:[/cyan] {swarm_obj.name} "
2318
+ f"({swarm_obj.total_agents} agents · "
2319
+ f"tools={swarm_obj.tools or 'none'})"
2320
+ )
2321
+ console.print()
2322
+
2323
+ # Build cohort
2324
+ _namespace = getattr(args, "namespace", NAMESPACE) or NAMESPACE
2325
+ agents = HyperMindAgent.testbed(
2326
+ agents=n_agents,
2327
+ room=ROOM,
2328
+ namespace=_namespace,
2329
+ recorder=recorder, # I0: scoped recorder so events carry sim_id natively
2330
+ )
2331
+ profiles = _build_profiles(
2332
+ agents,
2333
+ personas=persona_names,
2334
+ replicas=args.replicas,
2335
+ persona_replicas=persona_replicas or None,
2336
+ persona_info_override=persona_info_override or None,
2337
+ )
2338
+ trace.profiles = profiles
2339
+ profiles_by_kid = {p.agent.keypair.kid: p for p in profiles}
2340
+
2341
+ # Publish (sim_id, agent_name) -> persona/emoji to the in-process
2342
+ # registry so the SSE stream can enrich events for the live Activity
2343
+ # canvas. ``args.sim_id`` is set by ``run_persona_sim``; we skip
2344
+ # registration if it isn't present (e.g. a direct legacy invocation).
2345
+ def _infer_role_from_persona(slug: str) -> str:
2346
+ """S5 — map a SimLab persona slug onto a mind/roles.py role.
2347
+
2348
+ Heuristic, kept deterministic: sceptical/contrarian → sceptic;
2349
+ historian/pattern-matcher → synthesist; journalist/curious →
2350
+ scout; lawyer/risk-averse → mediator; otherwise generalist.
2351
+ Oracle is assigned explicitly later by _scenario_impl.
2352
+ """
2353
+ s = (slug or "").lower()
2354
+ if "scept" in s or "contrarian" in s or "sceptic" in s:
2355
+ return "sceptic"
2356
+ if "histor" in s or "synthes" in s or "pattern" in s:
2357
+ return "synthesist"
2358
+ if "journal" in s or "curious" in s or "scout" in s:
2359
+ return "scout"
2360
+ if "lawyer" in s or "risk" in s or "mediat" in s:
2361
+ return "mediator"
2362
+ return "generalist"
2363
+
2364
+ try:
2365
+ sim_id_for_registry = getattr(args, "sim_id", None)
2366
+ if sim_id_for_registry:
2367
+ from . import _persona_registry
2368
+
2369
+ for prof in profiles:
2370
+ emoji = str(prof.persona_info.get("emoji", "") or "")
2371
+ role = _infer_role_from_persona(prof.persona)
2372
+ # Events carry agent_id=keypair.kid.hex(); also register
2373
+ # by friendly name so events that use the friendly name
2374
+ # (e.g. trace-replay) work too.
2375
+ kid_hex = prof.agent.keypair.kid.hex()
2376
+ _persona_registry.remember(
2377
+ sim_id_for_registry,
2378
+ kid_hex,
2379
+ persona=prof.persona,
2380
+ persona_emoji=emoji,
2381
+ display_name=prof.name,
2382
+ role=role,
2383
+ )
2384
+ _persona_registry.remember(
2385
+ sim_id_for_registry,
2386
+ prof.name,
2387
+ persona=prof.persona,
2388
+ persona_emoji=emoji,
2389
+ display_name=prof.name,
2390
+ role=role,
2391
+ )
2392
+ except Exception:
2393
+ pass
2394
+
2395
+ # Build per-persona responders. When a swarm is active and overrides
2396
+ # the tool list, we pass it down so every member shares the same
2397
+ # tools regardless of their per-persona ``tool_affinity``.
2398
+ if use_real_llm:
2399
+ responders = {
2400
+ persona: _build_responder_for_persona(
2401
+ persona,
2402
+ api_key,
2403
+ llm_model=args.llm_model,
2404
+ enable_tools=args.enable_tools,
2405
+ persona_info_override=persona_info_override.get(persona),
2406
+ swarm_tools=swarm_tools_override,
2407
+ )
2408
+ for persona in persona_names
2409
+ }
2410
+ else:
2411
+ synth = _build_synthetic_responder()
2412
+ responders = {persona: synth for persona in persona_names}
2413
+
2414
+ responder = TracingPersonaResponder(profiles_by_kid, responders)
2415
+
2416
+ # ── Collective intelligence wiring ──
2417
+ # P2: every agent subscribes to the topic so they hear each other's claims
2418
+ n_subs = await setup_peer_subscriptions(profiles)
2419
+ console.print(f" [green]✓[/green] {n_subs} agents subscribed to peer claims (P2)")
2420
+
2421
+ # P6: vouch round before any questions, building bootstrap trust
2422
+ trace.vouches = await run_vouch_phase(profiles, TOPIC)
2423
+ console.print(f" [green]✓[/green] {len(trace.vouches)} vouches between persona elders (P6)")
2424
+
2425
+ # Snapshot reputations BEFORE any work
2426
+ trace.reputation_snapshots["before"] = snapshot_reputations(profiles, TOPIC, "before")
2427
+
2428
+ # P7: pick the oracle, mint a CapToken authorising oracle_resolution
2429
+ oracle_persona = "pattern-matcher-historian"
2430
+ oracle_prof = next((p for p in profiles if p.persona == oracle_persona), profiles[0])
2431
+ captoken_meta = mint_oracle_captoken(oracle_prof.agent, TOPIC)
2432
+ if captoken_meta:
2433
+ trace.captoken_events.append({"kind": "minted", **captoken_meta})
2434
+ console.print(" [green]✓[/green] CapToken minted for oracle (P7)")
2435
+
2436
+ # Pick an anchor (synthesiser): any non-oracle agent
2437
+ anchor_prof = next((p for p in profiles if p.persona != oracle_persona), profiles[0])
2438
+
2439
+ # S1 — Active question selection scoring.
2440
+ # The InformationGainPlanner scores every question by expected entropy
2441
+ # reduction. We do NOT reorder the iteration here (downstream replay
2442
+ # and trace consumers key on q_idx ordering); instead we score each
2443
+ # question against the running swarm posterior and emit a
2444
+ # `question_chosen` recorder event so the UI's "Now asking" strip can
2445
+ # show the swarm reasoning about its own ignorance.
2446
+ from hypermind.mind.active import CandidateQuestion, InformationGainPlanner
2447
+
2448
+ eig_planner = InformationGainPlanner()
2449
+ swarm_posteriors: dict[str, float] = {} # qid → running consensus
2450
+
2451
+ def _emit_question_chosen(
2452
+ chosen_idx: int,
2453
+ candidates: list[CandidateQuestion],
2454
+ scores: list[float],
2455
+ ) -> None:
2456
+ """Best-effort: emit one recorder event per question chosen so
2457
+ SimLive's NowAsking strip can render the swarm's selection
2458
+ reasoning. Never breaks the run if it throws."""
2459
+ try:
2460
+ ranked = sorted(zip(scores, candidates, strict=False), key=lambda x: x[0], reverse=True)
2461
+ top = ranked[0] if ranked else (0.0, None)
2462
+ runner_up = ranked[1] if len(ranked) > 1 else (0.0, None)
2463
+ chosen = candidates[chosen_idx]
2464
+ recorder_to_use = recorder if recorder is not None else None
2465
+ if recorder_to_use is None:
2466
+ from hypermind.observability.recorder import get_recorder
2467
+
2468
+ recorder_to_use = get_recorder()
2469
+ recorder_to_use.emit(
2470
+ namespace=_namespace,
2471
+ agent_id="planner",
2472
+ event_type="question_chosen",
2473
+ q_idx=chosen_idx,
2474
+ qid=chosen.qid,
2475
+ eig_score=round(scores[chosen_idx], 4),
2476
+ top_eig=round(float(top[0]), 4),
2477
+ runner_up_eig=round(float(runner_up[0]), 4),
2478
+ top_qid=top[1].qid if top[1] else "",
2479
+ runner_up_qid=runner_up[1].qid if runner_up[1] else "",
2480
+ why=("max-EIG" if chosen_idx == candidates.index(top[1]) else "file-order")
2481
+ if top[1]
2482
+ else "file-order",
2483
+ )
2484
+ except Exception:
2485
+ pass
2486
+
2487
+ # KPI #1 — record setup phase time (from Trace.started_at to first question)
2488
+ trace.setup_s = time.perf_counter() - trace.started_at
2489
+
2490
+ # Iterate questions
2491
+ try:
2492
+ for q_idx, question in enumerate(questions):
2493
+ _q_t0 = time.perf_counter()
2494
+ if args.debug:
2495
+ console.rule(f"[bold]Question {q_idx + 1}/{len(questions)}[/bold]", style="dim")
2496
+ console.print(f"[bold cyan]{question.get('query', '')}[/bold cyan]")
2497
+ console.print()
2498
+
2499
+ # S1 — Score remaining questions and emit question_chosen.
2500
+ try:
2501
+ remaining = list(enumerate(questions))[q_idx:]
2502
+ cands = [
2503
+ CandidateQuestion(
2504
+ qid=str(q.get("id") or f"q{i}"),
2505
+ topic=ROOM,
2506
+ expected_posterior=swarm_posteriors.get(
2507
+ str(q.get("id") or f"q{i}"),
2508
+ 0.5,
2509
+ ),
2510
+ )
2511
+ for i, q in remaining
2512
+ ]
2513
+ scores = [eig_planner.score_question(c) for c in cands]
2514
+ # Chosen index in the local `cands` list is always 0 since we
2515
+ # iterate in file order; we still emit the score so the UI
2516
+ # can show "this question's EIG was X bits".
2517
+ _emit_question_chosen(0, cands, scores)
2518
+ except Exception:
2519
+ pass
2520
+
2521
+ # Round 0: parallel cold-elicit
2522
+ try:
2523
+ panel_record = await _ask_panel(profiles, question, responder)
2524
+ except TimeoutError:
2525
+ panel_record = {
2526
+ "id": question.get("id", ""),
2527
+ "query": question.get("query", ""),
2528
+ "ground_truth": question.get("ground_truth"),
2529
+ "n_responses": 0,
2530
+ "elapsed_ms": 0.0,
2531
+ "error": "panel timeout",
2532
+ }
2533
+ trace.panel_records.append(panel_record)
2534
+ # S1 — Update running posterior so next question's EIG score
2535
+ # reflects what the swarm has just learned.
2536
+ try:
2537
+ qid_running = str(question.get("id") or f"q{q_idx}")
2538
+ m = panel_record.get("mean_confidence")
2539
+ if isinstance(m, (int, float)):
2540
+ swarm_posteriors[qid_running] = float(m)
2541
+ except Exception:
2542
+ pass
2543
+
2544
+ # P1: deliberation rounds (re-elicit with peer reasoning summaries)
2545
+ if args.deliberation_rounds > 1:
2546
+ _delib_t0 = time.perf_counter()
2547
+ rounds = await run_deliberation_phase(
2548
+ profiles,
2549
+ question,
2550
+ responder,
2551
+ panel_record,
2552
+ n_extra_rounds=args.deliberation_rounds - 1,
2553
+ )
2554
+ trace.deliberation_s += time.perf_counter() - _delib_t0
2555
+ trace.n_deliberation_rounds += len(rounds)
2556
+ trace.deliberation_records.append(
2557
+ {
2558
+ "question_id": question.get("id"),
2559
+ "query": question.get("query"),
2560
+ "rounds": rounds,
2561
+ }
2562
+ )
2563
+ # Update panel_record claims with final post-deliberation
2564
+ # confidences so Brier scoring and the UI reflect what each
2565
+ # agent actually believed after seeing peer reasoning.
2566
+ final_round = rounds[-1] if rounds else None
2567
+ if final_round and final_round.get("round_idx", 0) > 0:
2568
+ final_round_n = final_round["round_idx"]
2569
+ # Build a name→final_conf lookup from the deltas
2570
+ final_conf_by_agent: dict[str, float] = {}
2571
+ for delta in final_round.get("deltas") or []:
2572
+ if delta.get("new") is not None:
2573
+ final_conf_by_agent[delta["agent"]] = delta["new"]
2574
+ # Update claims in place; stamp the round they ended on
2575
+ for claim in panel_record.get("claims") or []:
2576
+ agent_name = claim.get("agent", "")
2577
+ if agent_name in final_conf_by_agent:
2578
+ claim["confidence"] = final_conf_by_agent[agent_name]
2579
+ claim["round"] = final_round_n
2580
+ # Recompute panel-level mean/spread from updated claims
2581
+ updated_confs = [
2582
+ c["confidence"]
2583
+ for c in (panel_record.get("claims") or [])
2584
+ if c.get("confidence") is not None
2585
+ ]
2586
+ if updated_confs:
2587
+ panel_record["mean_confidence"] = round(sum(updated_confs) / len(updated_confs), 4)
2588
+ panel_record["spread"] = round(max(updated_confs) - min(updated_confs), 4)
2589
+
2590
+ # P4: synthesise the panel verdict with parent_kids citation chain
2591
+ _synth_t0 = time.perf_counter()
2592
+ synth = await synthesize_panel_verdict(anchor_prof.agent, panel_record)
2593
+ trace.synthesis_s += time.perf_counter() - _synth_t0
2594
+ if synth is not None:
2595
+ panel_record["synthesis_kid"] = synth.get("synthesis_kid")
2596
+ trace.syntheses.append(synth)
2597
+
2598
+ # KPI #1 — per-question elapsed
2599
+ trace.questions_timing.append(
2600
+ {
2601
+ "question": question.get("query", ""),
2602
+ "elapsed_s": round(time.perf_counter() - _q_t0, 3),
2603
+ }
2604
+ )
2605
+
2606
+ if args.debug:
2607
+ # Per-agent debug blocks
2608
+ for prof in profiles:
2609
+ last_vote = next(
2610
+ (
2611
+ v
2612
+ for v in reversed(prof.panel_votes)
2613
+ if v.get("query") == question.get("query")
2614
+ ),
2615
+ None,
2616
+ )
2617
+ if last_vote is None or "confidence" not in last_vote:
2618
+ continue
2619
+ _print_per_agent_block(console, prof, last_vote)
2620
+ _print_panel_diff(console, panel_record)
2621
+ else:
2622
+ # Compact one-line summary
2623
+ console.print(
2624
+ f" Q{q_idx + 1}/{len(questions)}: "
2625
+ f"mean={panel_record.get('mean_confidence', 0):.0%} "
2626
+ f"spread={panel_record.get('spread', 0):.0%} "
2627
+ f"({panel_record.get('elapsed_ms', 0):.0f}ms)",
2628
+ )
2629
+
2630
+ trace.total_tool_calls = sum(len(p.tool_calls) for p in profiles)
2631
+
2632
+ # P3: oracle resolution closes the trust loop
2633
+ _oracle_t0 = time.perf_counter()
2634
+ trace.oracle_resolutions = await run_oracle_phase(
2635
+ profiles,
2636
+ trace.panel_records,
2637
+ role_oracle_persona=oracle_persona,
2638
+ )
2639
+ trace.oracle_s += time.perf_counter() - _oracle_t0
2640
+ if trace.oracle_resolutions:
2641
+ console.print(
2642
+ f" [green]✓[/green] {len(trace.oracle_resolutions)} oracle resolutions "
2643
+ f"updated reputation (P3)"
2644
+ )
2645
+
2646
+ # P7: demonstrate CapToken refusal — non-oracle attempts oracle_resolve
2647
+ if trace.panel_records and trace.panel_records[0].get("claims"):
2648
+ target = bytes.fromhex(trace.panel_records[0]["claims"][0]["kid"])
2649
+ non_oracle = next(p for p in profiles if p is not oracle_prof)
2650
+ refusal = await attempt_unauthorised_resolve(non_oracle, target)
2651
+ trace.captoken_events.append({"kind": "refusal_attempt", **refusal})
2652
+ if refusal.get("blocked"):
2653
+ console.print(" [green]✓[/green] CapToken blocked unauthorised resolve (P7)")
2654
+
2655
+ # Snapshot reputations AFTER oracle phase
2656
+ trace.reputation_snapshots["after_oracle"] = snapshot_reputations(
2657
+ profiles,
2658
+ TOPIC,
2659
+ "after_oracle",
2660
+ )
2661
+
2662
+ # P8: capture audit log + replay determinism check
2663
+ audit_path = (
2664
+ (Path(args.trace).parent / "persona_audit.jsonl").as_posix() if args.trace else None
2665
+ )
2666
+ trace.audit_replay = await capture_and_replay_audit(profiles[0].agent, audit_path)
2667
+ if trace.audit_replay.get("deterministic") is True:
2668
+ console.print(
2669
+ f" [green]✓[/green] Audit replay deterministic: "
2670
+ f"{trace.audit_replay.get('replayed', 0)} records (P8)"
2671
+ )
2672
+
2673
+ finally:
2674
+ for a in agents:
2675
+ await a.close()
2676
+
2677
+ trace.finished_at = time.perf_counter()
2678
+
2679
+ # S4 — Cross-sim memory: at sim end, walk per-persona Brier outcomes
2680
+ # from oracle_resolutions and persist into KernelStore so the next
2681
+ # sim of this persona starts with calibrated priors. This also
2682
+ # increments `n_sims` so the Lineage / Home / SimList views can show
2683
+ # "this swarm has been running for N sims" copy.
2684
+ try:
2685
+ from hypermind.eval.calibration import brier_score as _brier
2686
+ from hypermind.knowledge.kernel_store import KernelStore
2687
+
2688
+ # Group oracle_resolutions by persona → topic → (predicted, outcome).
2689
+ per_persona_topic: dict[tuple[str, str], list[float]] = {}
2690
+ for res in trace.oracle_resolutions:
2691
+ persona = res.get("publisher_persona")
2692
+ if not persona:
2693
+ continue
2694
+ topic = ROOM
2695
+ pred = res.get("predicted_confidence")
2696
+ outcome = res.get("ground_truth")
2697
+ if pred is None or outcome is None:
2698
+ continue
2699
+ try:
2700
+ b = _brier(float(pred), float(outcome))
2701
+ except (TypeError, ValueError):
2702
+ continue
2703
+ per_persona_topic.setdefault((str(persona), topic), []).append(b)
2704
+ # One update per (persona, topic) — pass the mean of this sim's
2705
+ # observations so a single noisy outcome doesn't dominate.
2706
+ seen_personas: set[str] = set()
2707
+ for (persona, topic), briers in per_persona_topic.items():
2708
+ if not briers:
2709
+ continue
2710
+ mean_b = sum(briers) / len(briers)
2711
+ KernelStore.update(persona, topic, brier_observation=mean_b)
2712
+ seen_personas.add(persona)
2713
+ # Bump n_sims for every persona that participated (regardless of
2714
+ # whether their oracle outcomes were resolvable).
2715
+ for prof in trace.profiles:
2716
+ if prof.persona in seen_personas:
2717
+ continue
2718
+ seen_personas.add(prof.persona)
2719
+ KernelStore.update(prof.persona, ROOM, n_sims_increment=0)
2720
+ for persona in seen_personas:
2721
+ KernelStore.update(persona, ROOM, n_sims_increment=1)
2722
+ except Exception:
2723
+ pass
2724
+
2725
+ # Flush per-agent facts_learned to Relata (if RELATA_URL is configured)
2726
+ _flush_sim_memory_to_relata(trace.profiles, sim_id=getattr(args, "sim_id", ""))
2727
+
2728
+ # Write outputs
2729
+ if args.trace:
2730
+ Path(args.trace).write_text(json.dumps(_trace_to_dict(trace), indent=2, default=str))
2731
+ console.print(f"\n [green]✓[/green] JSON trace → [bold]{args.trace}[/bold]")
2732
+ if args.html:
2733
+ try:
2734
+ json_filename = Path(args.trace).name if args.trace else "persona_trace.json"
2735
+ _write_html_viewer(args.html, json_filename)
2736
+ console.print(f" [green]✓[/green] HTML debugger → [bold]{args.html}[/bold]")
2737
+ except FileNotFoundError as exc:
2738
+ console.print(f" [yellow]⚠ {exc}[/yellow]")
2739
+
2740
+ # Final summary
2741
+ console.print()
2742
+ console.print(
2743
+ f" [dim]Total: {len(profiles)} agents · "
2744
+ f"{len(questions)} questions · "
2745
+ f"{trace.total_tool_calls} tool calls · "
2746
+ f"{trace.finished_at - trace.started_at:.2f}s[/dim]"
2747
+ )
2748
+
2749
+
2750
+ async def main(args: argparse.Namespace, *, recorder: Any = None) -> None:
2751
+ """Run a persona-panel sim.
2752
+
2753
+ ``recorder`` (I0): optional pre-scoped Recorder injected by the SimLab
2754
+ runner. When provided, it is threaded into the testbed cohort so every
2755
+ agent's events carry ``namespace_id=sim_id`` natively. Default
2756
+ ``None`` preserves the legacy lazy-init behaviour for direct callers.
2757
+
2758
+ Any unhandled exception is caught here, the sim status is set to
2759
+ ``state="failed"`` (so status.json never stays as ``state="running"``
2760
+ indefinitely), and the exception is re-raised so the caller can log it.
2761
+ """
2762
+ sim_id = getattr(args, "sim_id", None)
2763
+ try:
2764
+ await _main_impl(args, recorder=recorder)
2765
+ except Exception as e:
2766
+ if sim_id:
2767
+ try:
2768
+ from .registry import update_status as _update_status
2769
+
2770
+ _update_status(sim_id, state="failed", error=str(e), finished_at=time.time())
2771
+ except Exception:
2772
+ pass
2773
+ raise
2774
+
2775
+
2776
+ # Module-level argparse is intentionally not present here — this file is now
2777
+ # package-internal. The CLI shim lives in src/hypermind/simlab/scenario.py
2778
+ # (and a thin wrapper in examples/scenario_persona_panel.py for the dev tree).