hypermind 0.11.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hmctl/__init__.py +7 -0
- hmctl/cli.py +947 -0
- hypermind/__init__.py +208 -0
- hypermind/_deprecations.py +108 -0
- hypermind/agent.py +2896 -0
- hypermind/agent_card.py +127 -0
- hypermind/api/__init__.py +32 -0
- hypermind/api/app.py +686 -0
- hypermind/capability.py +923 -0
- hypermind/consult.py +493 -0
- hypermind/consult_bias.py +112 -0
- hypermind/contract_net.py +219 -0
- hypermind/crypto/__init__.py +21 -0
- hypermind/crypto/cose_encrypt.py +126 -0
- hypermind/crypto/dkg.py +246 -0
- hypermind/crypto/domain.py +59 -0
- hypermind/crypto/frost.py +771 -0
- hypermind/crypto/hashing.py +38 -0
- hypermind/crypto/hlc.py +163 -0
- hypermind/crypto/hpke.py +277 -0
- hypermind/crypto/kms.py +196 -0
- hypermind/crypto/kms_backends/__init__.py +56 -0
- hypermind/crypto/kms_backends/aws.py +128 -0
- hypermind/crypto/kms_backends/azure.py +114 -0
- hypermind/crypto/kms_backends/gcp.py +97 -0
- hypermind/crypto/kms_backends/vault.py +120 -0
- hypermind/crypto/namespace_root.py +204 -0
- hypermind/crypto/pq.py +152 -0
- hypermind/crypto/room_epoch.py +97 -0
- hypermind/crypto/signing.py +245 -0
- hypermind/deliberation.py +355 -0
- hypermind/did_web.py +256 -0
- hypermind/dispute.py +858 -0
- hypermind/errors.py +218 -0
- hypermind/eval/__init__.py +49 -0
- hypermind/eval/calibration.py +307 -0
- hypermind/eval/comparison.py +251 -0
- hypermind/eval/replay.py +202 -0
- hypermind/eval/swarm_iq.py +718 -0
- hypermind/knowledge/__init__.py +17 -0
- hypermind/knowledge/citation_graph.py +141 -0
- hypermind/knowledge/correlated_agreement.py +167 -0
- hypermind/knowledge/embedding_registry.py +115 -0
- hypermind/knowledge/kernel_store.py +197 -0
- hypermind/knowledge/rekor.py +173 -0
- hypermind/knowledge/reputation.py +364 -0
- hypermind/knowledge/swarm_memory.py +523 -0
- hypermind/knowledge/transparency.py +409 -0
- hypermind/mcp/__init__.py +60 -0
- hypermind/mcp/bridge.py +151 -0
- hypermind/mcp/client.py +142 -0
- hypermind/mcp/provenance.py +214 -0
- hypermind/mcp/relata_tools.py +152 -0
- hypermind/mcp/server.py +248 -0
- hypermind/mcp/transport.py +206 -0
- hypermind/mind/__init__.py +150 -0
- hypermind/mind/active.py +234 -0
- hypermind/mind/belief.py +369 -0
- hypermind/mind/deliberator.py +625 -0
- hypermind/mind/diversity.py +144 -0
- hypermind/mind/evolve.py +184 -0
- hypermind/mind/federation.py +154 -0
- hypermind/mind/goals.py +117 -0
- hypermind/mind/integration.py +190 -0
- hypermind/mind/mediator.py +74 -0
- hypermind/mind/memory_store.py +129 -0
- hypermind/mind/policy.py +307 -0
- hypermind/mind/records.py +260 -0
- hypermind/mind/roles.py +190 -0
- hypermind/mind/sybil_guard.py +41 -0
- hypermind/mind/world_model.py +166 -0
- hypermind/namespace.py +515 -0
- hypermind/namespace_policy.py +254 -0
- hypermind/observability/__init__.py +25 -0
- hypermind/observability/asgi.py +214 -0
- hypermind/observability/cost.py +277 -0
- hypermind/observability/otel_export.py +200 -0
- hypermind/observability/recorder.py +344 -0
- hypermind/observability/swarm_trace.py +438 -0
- hypermind/pin_policy.py +116 -0
- hypermind/py.typed +0 -0
- hypermind/responders/__init__.py +87 -0
- hypermind/responders/anthropic.py +159 -0
- hypermind/responders/base.py +162 -0
- hypermind/responders/openai.py +199 -0
- hypermind/responders/router.py +254 -0
- hypermind/responders/structured.py +287 -0
- hypermind/responders/tools.py +444 -0
- hypermind/revocation.py +270 -0
- hypermind/rule_ids.py +135 -0
- hypermind/schemas/encrypted_statement.cddl +28 -0
- hypermind/schemas/namespace_accept.cddl +20 -0
- hypermind/schemas/namespace_create.cddl +25 -0
- hypermind/schemas/namespace_founding_attest.cddl +30 -0
- hypermind/schemas/namespace_invite.cddl +22 -0
- hypermind/schemas/wire-v0.1.cddl +73 -0
- hypermind/simlab/__init__.py +15 -0
- hypermind/simlab/_persona_registry.py +93 -0
- hypermind/simlab/_scenario_impl.py +2778 -0
- hypermind/simlab/app.py +2538 -0
- hypermind/simlab/backends/__init__.py +27 -0
- hypermind/simlab/backends/anchor.py +88 -0
- hypermind/simlab/backends/local.py +32 -0
- hypermind/simlab/backends/server.py +79 -0
- hypermind/simlab/cli_command.py +108 -0
- hypermind/simlab/config.py +106 -0
- hypermind/simlab/deployment.py +26 -0
- hypermind/simlab/knowledge.py +716 -0
- hypermind/simlab/learning.py +295 -0
- hypermind/simlab/modes/__init__.py +115 -0
- hypermind/simlab/modes/_eval_real_llm.py +373 -0
- hypermind/simlab/modes/aar.py +611 -0
- hypermind/simlab/modes/backtest.py +610 -0
- hypermind/simlab/modes/binary_forecast.py +69 -0
- hypermind/simlab/modes/conformance.py +1869 -0
- hypermind/simlab/modes/evaluation.py +2271 -0
- hypermind/simlab/modes/governance.py +648 -0
- hypermind/simlab/modes/redteam.py +534 -0
- hypermind/simlab/modes/tabletop.py +797 -0
- hypermind/simlab/modes/tournament.py +448 -0
- hypermind/simlab/modes/whatif.py +523 -0
- hypermind/simlab/namespace.py +125 -0
- hypermind/simlab/personas.py +477 -0
- hypermind/simlab/prompt_generator.py +172 -0
- hypermind/simlab/question_sets/geopolitics_q20.json +122 -0
- hypermind/simlab/question_sets/governance_q5.json +67 -0
- hypermind/simlab/question_sets/msft_outlook_q10.json +62 -0
- hypermind/simlab/question_sets/whatif_q3.json +38 -0
- hypermind/simlab/registry.py +325 -0
- hypermind/simlab/runner.py +239 -0
- hypermind/simlab/scenario.py +147 -0
- hypermind/simlab/swarms.py +180 -0
- hypermind/simlab/tool_handlers/__init__.py +4 -0
- hypermind/simlab/tool_handlers/alpha_vantage.py +39 -0
- hypermind/simlab/tool_handlers/brave.py +46 -0
- hypermind/simlab/tool_handlers/newsapi.py +50 -0
- hypermind/simlab/tool_handlers/openweather.py +46 -0
- hypermind/simlab/tool_handlers/pinecone.py +52 -0
- hypermind/simlab/tool_handlers/qdrant.py +57 -0
- hypermind/simlab/tool_handlers/query_knowledge_base.py +21 -0
- hypermind/simlab/tool_handlers/relata_recall.py +61 -0
- hypermind/simlab/tool_handlers/retrieve_document.py +21 -0
- hypermind/simlab/tool_handlers/serper.py +46 -0
- hypermind/simlab/tool_handlers/tavily.py +53 -0
- hypermind/simlab/tool_handlers/weaviate.py +65 -0
- hypermind/simlab/tool_handlers/wikipedia.py +64 -0
- hypermind/simlab/tool_handlers/wolfram.py +48 -0
- hypermind/simlab/tool_handlers/yahoo_finance.py +49 -0
- hypermind/simlab/tool_registry.py +568 -0
- hypermind/simlab/topic_adapter.py +338 -0
- hypermind/simlab/topic_questions.py +259 -0
- hypermind/storage/__init__.py +69 -0
- hypermind/storage/backend.py +71 -0
- hypermind/storage/relata.py +245 -0
- hypermind/storage/sqlite.py +258 -0
- hypermind/sync.py +191 -0
- hypermind/tal.py +175 -0
- hypermind/tasks.py +334 -0
- hypermind/testing.py +16 -0
- hypermind/tools/__init__.py +32 -0
- hypermind/tools/registry.py +61 -0
- hypermind/transport/__init__.py +22 -0
- hypermind/transport/anti_entropy.py +708 -0
- hypermind/transport/bus.py +206 -0
- hypermind/transport/knows_delta.py +204 -0
- hypermind/transport/libp2p.py +298 -0
- hypermind/transport/placement.py +58 -0
- hypermind/transport/tcp.py +797 -0
- hypermind/transport/tls_profile.py +417 -0
- hypermind/types.py +59 -0
- hypermind/uncertainty.py +222 -0
- hypermind/wire.py +897 -0
- hypermind/workflow/__init__.py +72 -0
- hypermind/workflow/engine.py +655 -0
- hypermind/workflow/plan.py +182 -0
- hypermind/workflow/repair.py +203 -0
- hypermind-0.11.0.dist-info/METADATA +524 -0
- hypermind-0.11.0.dist-info/RECORD +181 -0
- hypermind-0.11.0.dist-info/WHEEL +4 -0
- hypermind-0.11.0.dist-info/entry_points.txt +2 -0
- hypermind-0.11.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,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).
|