hypermind 0.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. hmctl/__init__.py +7 -0
  2. hmctl/cli.py +947 -0
  3. hypermind/__init__.py +208 -0
  4. hypermind/_deprecations.py +108 -0
  5. hypermind/agent.py +2896 -0
  6. hypermind/agent_card.py +127 -0
  7. hypermind/api/__init__.py +32 -0
  8. hypermind/api/app.py +686 -0
  9. hypermind/capability.py +923 -0
  10. hypermind/consult.py +493 -0
  11. hypermind/consult_bias.py +112 -0
  12. hypermind/contract_net.py +219 -0
  13. hypermind/crypto/__init__.py +21 -0
  14. hypermind/crypto/cose_encrypt.py +126 -0
  15. hypermind/crypto/dkg.py +246 -0
  16. hypermind/crypto/domain.py +59 -0
  17. hypermind/crypto/frost.py +771 -0
  18. hypermind/crypto/hashing.py +38 -0
  19. hypermind/crypto/hlc.py +163 -0
  20. hypermind/crypto/hpke.py +277 -0
  21. hypermind/crypto/kms.py +196 -0
  22. hypermind/crypto/kms_backends/__init__.py +56 -0
  23. hypermind/crypto/kms_backends/aws.py +128 -0
  24. hypermind/crypto/kms_backends/azure.py +114 -0
  25. hypermind/crypto/kms_backends/gcp.py +97 -0
  26. hypermind/crypto/kms_backends/vault.py +120 -0
  27. hypermind/crypto/namespace_root.py +204 -0
  28. hypermind/crypto/pq.py +152 -0
  29. hypermind/crypto/room_epoch.py +97 -0
  30. hypermind/crypto/signing.py +245 -0
  31. hypermind/deliberation.py +355 -0
  32. hypermind/did_web.py +256 -0
  33. hypermind/dispute.py +858 -0
  34. hypermind/errors.py +218 -0
  35. hypermind/eval/__init__.py +49 -0
  36. hypermind/eval/calibration.py +307 -0
  37. hypermind/eval/comparison.py +251 -0
  38. hypermind/eval/replay.py +202 -0
  39. hypermind/eval/swarm_iq.py +718 -0
  40. hypermind/knowledge/__init__.py +17 -0
  41. hypermind/knowledge/citation_graph.py +141 -0
  42. hypermind/knowledge/correlated_agreement.py +167 -0
  43. hypermind/knowledge/embedding_registry.py +115 -0
  44. hypermind/knowledge/kernel_store.py +197 -0
  45. hypermind/knowledge/rekor.py +173 -0
  46. hypermind/knowledge/reputation.py +364 -0
  47. hypermind/knowledge/swarm_memory.py +523 -0
  48. hypermind/knowledge/transparency.py +409 -0
  49. hypermind/mcp/__init__.py +60 -0
  50. hypermind/mcp/bridge.py +151 -0
  51. hypermind/mcp/client.py +142 -0
  52. hypermind/mcp/provenance.py +214 -0
  53. hypermind/mcp/relata_tools.py +152 -0
  54. hypermind/mcp/server.py +248 -0
  55. hypermind/mcp/transport.py +206 -0
  56. hypermind/mind/__init__.py +150 -0
  57. hypermind/mind/active.py +234 -0
  58. hypermind/mind/belief.py +369 -0
  59. hypermind/mind/deliberator.py +625 -0
  60. hypermind/mind/diversity.py +144 -0
  61. hypermind/mind/evolve.py +184 -0
  62. hypermind/mind/federation.py +154 -0
  63. hypermind/mind/goals.py +117 -0
  64. hypermind/mind/integration.py +190 -0
  65. hypermind/mind/mediator.py +74 -0
  66. hypermind/mind/memory_store.py +129 -0
  67. hypermind/mind/policy.py +307 -0
  68. hypermind/mind/records.py +260 -0
  69. hypermind/mind/roles.py +190 -0
  70. hypermind/mind/sybil_guard.py +41 -0
  71. hypermind/mind/world_model.py +166 -0
  72. hypermind/namespace.py +515 -0
  73. hypermind/namespace_policy.py +254 -0
  74. hypermind/observability/__init__.py +25 -0
  75. hypermind/observability/asgi.py +214 -0
  76. hypermind/observability/cost.py +277 -0
  77. hypermind/observability/otel_export.py +200 -0
  78. hypermind/observability/recorder.py +344 -0
  79. hypermind/observability/swarm_trace.py +438 -0
  80. hypermind/pin_policy.py +116 -0
  81. hypermind/py.typed +0 -0
  82. hypermind/responders/__init__.py +87 -0
  83. hypermind/responders/anthropic.py +159 -0
  84. hypermind/responders/base.py +162 -0
  85. hypermind/responders/openai.py +199 -0
  86. hypermind/responders/router.py +254 -0
  87. hypermind/responders/structured.py +287 -0
  88. hypermind/responders/tools.py +444 -0
  89. hypermind/revocation.py +270 -0
  90. hypermind/rule_ids.py +135 -0
  91. hypermind/schemas/encrypted_statement.cddl +28 -0
  92. hypermind/schemas/namespace_accept.cddl +20 -0
  93. hypermind/schemas/namespace_create.cddl +25 -0
  94. hypermind/schemas/namespace_founding_attest.cddl +30 -0
  95. hypermind/schemas/namespace_invite.cddl +22 -0
  96. hypermind/schemas/wire-v0.1.cddl +73 -0
  97. hypermind/simlab/__init__.py +15 -0
  98. hypermind/simlab/_persona_registry.py +93 -0
  99. hypermind/simlab/_scenario_impl.py +2778 -0
  100. hypermind/simlab/app.py +2538 -0
  101. hypermind/simlab/backends/__init__.py +27 -0
  102. hypermind/simlab/backends/anchor.py +88 -0
  103. hypermind/simlab/backends/local.py +32 -0
  104. hypermind/simlab/backends/server.py +79 -0
  105. hypermind/simlab/cli_command.py +108 -0
  106. hypermind/simlab/config.py +106 -0
  107. hypermind/simlab/deployment.py +26 -0
  108. hypermind/simlab/knowledge.py +716 -0
  109. hypermind/simlab/learning.py +295 -0
  110. hypermind/simlab/modes/__init__.py +115 -0
  111. hypermind/simlab/modes/_eval_real_llm.py +373 -0
  112. hypermind/simlab/modes/aar.py +611 -0
  113. hypermind/simlab/modes/backtest.py +610 -0
  114. hypermind/simlab/modes/binary_forecast.py +69 -0
  115. hypermind/simlab/modes/conformance.py +1869 -0
  116. hypermind/simlab/modes/evaluation.py +2271 -0
  117. hypermind/simlab/modes/governance.py +648 -0
  118. hypermind/simlab/modes/redteam.py +534 -0
  119. hypermind/simlab/modes/tabletop.py +797 -0
  120. hypermind/simlab/modes/tournament.py +448 -0
  121. hypermind/simlab/modes/whatif.py +523 -0
  122. hypermind/simlab/namespace.py +125 -0
  123. hypermind/simlab/personas.py +477 -0
  124. hypermind/simlab/prompt_generator.py +172 -0
  125. hypermind/simlab/question_sets/geopolitics_q20.json +122 -0
  126. hypermind/simlab/question_sets/governance_q5.json +67 -0
  127. hypermind/simlab/question_sets/msft_outlook_q10.json +62 -0
  128. hypermind/simlab/question_sets/whatif_q3.json +38 -0
  129. hypermind/simlab/registry.py +325 -0
  130. hypermind/simlab/runner.py +239 -0
  131. hypermind/simlab/scenario.py +147 -0
  132. hypermind/simlab/swarms.py +180 -0
  133. hypermind/simlab/tool_handlers/__init__.py +4 -0
  134. hypermind/simlab/tool_handlers/alpha_vantage.py +39 -0
  135. hypermind/simlab/tool_handlers/brave.py +46 -0
  136. hypermind/simlab/tool_handlers/newsapi.py +50 -0
  137. hypermind/simlab/tool_handlers/openweather.py +46 -0
  138. hypermind/simlab/tool_handlers/pinecone.py +52 -0
  139. hypermind/simlab/tool_handlers/qdrant.py +57 -0
  140. hypermind/simlab/tool_handlers/query_knowledge_base.py +21 -0
  141. hypermind/simlab/tool_handlers/relata_recall.py +61 -0
  142. hypermind/simlab/tool_handlers/retrieve_document.py +21 -0
  143. hypermind/simlab/tool_handlers/serper.py +46 -0
  144. hypermind/simlab/tool_handlers/tavily.py +53 -0
  145. hypermind/simlab/tool_handlers/weaviate.py +65 -0
  146. hypermind/simlab/tool_handlers/wikipedia.py +64 -0
  147. hypermind/simlab/tool_handlers/wolfram.py +48 -0
  148. hypermind/simlab/tool_handlers/yahoo_finance.py +49 -0
  149. hypermind/simlab/tool_registry.py +568 -0
  150. hypermind/simlab/topic_adapter.py +338 -0
  151. hypermind/simlab/topic_questions.py +259 -0
  152. hypermind/storage/__init__.py +69 -0
  153. hypermind/storage/backend.py +71 -0
  154. hypermind/storage/relata.py +245 -0
  155. hypermind/storage/sqlite.py +258 -0
  156. hypermind/sync.py +191 -0
  157. hypermind/tal.py +175 -0
  158. hypermind/tasks.py +334 -0
  159. hypermind/testing.py +16 -0
  160. hypermind/tools/__init__.py +32 -0
  161. hypermind/tools/registry.py +61 -0
  162. hypermind/transport/__init__.py +22 -0
  163. hypermind/transport/anti_entropy.py +708 -0
  164. hypermind/transport/bus.py +206 -0
  165. hypermind/transport/knows_delta.py +204 -0
  166. hypermind/transport/libp2p.py +298 -0
  167. hypermind/transport/placement.py +58 -0
  168. hypermind/transport/tcp.py +797 -0
  169. hypermind/transport/tls_profile.py +417 -0
  170. hypermind/types.py +59 -0
  171. hypermind/uncertainty.py +222 -0
  172. hypermind/wire.py +897 -0
  173. hypermind/workflow/__init__.py +72 -0
  174. hypermind/workflow/engine.py +655 -0
  175. hypermind/workflow/plan.py +182 -0
  176. hypermind/workflow/repair.py +203 -0
  177. hypermind-0.11.0.dist-info/METADATA +524 -0
  178. hypermind-0.11.0.dist-info/RECORD +181 -0
  179. hypermind-0.11.0.dist-info/WHEEL +4 -0
  180. hypermind-0.11.0.dist-info/entry_points.txt +2 -0
  181. hypermind-0.11.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,716 @@
1
+ """Knowledge — federation memory aggregated across all sims.
2
+
3
+ Every sim under ``~/.hypermind/sims/`` produces durable, signed artefacts:
4
+ claims, citations, disputes, syntheses, vouches, oracle resolutions,
5
+ reputation snapshots. This module walks the registry, parses every
6
+ trace.json, and builds an in-memory index that the UI can drill into.
7
+
8
+ Public API:
9
+
10
+ build_knowledge_index() -> KnowledgeIndex
11
+ KnowledgeIndex.overview() → high-level KPIs
12
+ KnowledgeIndex.list_claims(…) → paginated, filterable
13
+ KnowledgeIndex.list_agents() → federation roster (cumulative stats)
14
+ KnowledgeIndex.list_topics() → category clusters
15
+ KnowledgeIndex.claim(kid) → full provenance (citations + disputes
16
+ + source sim + outcome if resolved)
17
+
18
+ The index is rebuilt lazily — call ``invalidate()`` after a sim
19
+ finishes to force a re-scan on the next read.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import threading
26
+ from collections import defaultdict
27
+ from dataclasses import dataclass, field
28
+ from typing import Any
29
+
30
+ from .registry import sims_dir
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Record shapes (compact — full trace stays on disk)
34
+ # ---------------------------------------------------------------------------
35
+
36
+
37
+ @dataclass
38
+ class ClaimRecord:
39
+ kid: str
40
+ agent: str
41
+ persona: str
42
+ query: str
43
+ confidence: float
44
+ ts_wall_ms: int
45
+ sim_id: str
46
+ citations_received: int = 0
47
+ disputes_received: int = 0
48
+ resolved_outcome: bool | None = None # None = not yet resolved
49
+ ground_truth: float | None = None
50
+ category: str = "general" # filled from question's category if available
51
+ reasoning: str = "" # the agent's freetext reasoning (when available)
52
+ sim_topic: str = "" # the sim's topic (when sim was topic-driven)
53
+ panel_mean: float | None = None # the panel's aggregate mean for this Q
54
+ panel_spread: float | None = None # spread σ for this Q
55
+
56
+ def to_dict(self) -> dict[str, Any]:
57
+ return {
58
+ "kid": self.kid,
59
+ "agent": self.agent,
60
+ "persona": self.persona,
61
+ "query": self.query,
62
+ "confidence": self.confidence,
63
+ "ts_wall_ms": self.ts_wall_ms,
64
+ "sim_id": self.sim_id,
65
+ "citations_received": self.citations_received,
66
+ "disputes_received": self.disputes_received,
67
+ "resolved_outcome": self.resolved_outcome,
68
+ "ground_truth": self.ground_truth,
69
+ "category": self.category,
70
+ "reasoning": self.reasoning,
71
+ "sim_topic": self.sim_topic,
72
+ "panel_mean": self.panel_mean,
73
+ "panel_spread": self.panel_spread,
74
+ }
75
+
76
+
77
+ @dataclass
78
+ class AgentAggregate:
79
+ name: str # persona-replica id, e.g. "optimist-economist-01"
80
+ persona: str
81
+ runs_participated: int = 0
82
+ claims_published: int = 0
83
+ citations_received: int = 0
84
+ disputes_received: int = 0
85
+ citations_made: int = 0
86
+ disputes_made: int = 0
87
+ vouches_received: int = 0
88
+ vouches_given: int = 0
89
+ oracle_correct: int = 0
90
+ oracle_total: int = 0
91
+ last_reputation: float | None = None
92
+ last_seen_ts_ms: int = 0
93
+ sims: set[str] = field(default_factory=set)
94
+
95
+ @property
96
+ def accuracy(self) -> float | None:
97
+ return self.oracle_correct / self.oracle_total if self.oracle_total else None
98
+
99
+ def to_dict(self) -> dict[str, Any]:
100
+ return {
101
+ "name": self.name,
102
+ "persona": self.persona,
103
+ "runs_participated": self.runs_participated,
104
+ "claims_published": self.claims_published,
105
+ "citations_received": self.citations_received,
106
+ "disputes_received": self.disputes_received,
107
+ "citations_made": self.citations_made,
108
+ "disputes_made": self.disputes_made,
109
+ "vouches_received": self.vouches_received,
110
+ "vouches_given": self.vouches_given,
111
+ "oracle_correct": self.oracle_correct,
112
+ "oracle_total": self.oracle_total,
113
+ "accuracy": self.accuracy,
114
+ "last_reputation": self.last_reputation,
115
+ "last_seen_ts_ms": self.last_seen_ts_ms,
116
+ "sims": sorted(self.sims),
117
+ }
118
+
119
+
120
+ @dataclass
121
+ class TopicAggregate:
122
+ topic: str # category slug (e.g. "regulation", "growth", "primary")
123
+ claim_count: int = 0
124
+ citation_count: int = 0
125
+ dispute_count: int = 0
126
+ mean_confidence_sum: float = 0.0 # accumulators; finalize on render
127
+ confidence_n: int = 0
128
+ sims: set[str] = field(default_factory=set)
129
+ sample_queries: list[str] = field(default_factory=list)
130
+
131
+ @property
132
+ def mean_confidence(self) -> float | None:
133
+ return self.mean_confidence_sum / self.confidence_n if self.confidence_n else None
134
+
135
+ @property
136
+ def contested_ratio(self) -> float:
137
+ return self.dispute_count / self.claim_count if self.claim_count else 0.0
138
+
139
+ def to_dict(self) -> dict[str, Any]:
140
+ return {
141
+ "topic": self.topic,
142
+ "claim_count": self.claim_count,
143
+ "citation_count": self.citation_count,
144
+ "dispute_count": self.dispute_count,
145
+ "mean_confidence": self.mean_confidence,
146
+ "contested_ratio": self.contested_ratio,
147
+ "n_sims": len(self.sims),
148
+ "sample_queries": self.sample_queries[:5],
149
+ }
150
+
151
+
152
+ @dataclass
153
+ class KnowledgeIndex:
154
+ claims: dict[str, ClaimRecord] = field(default_factory=dict)
155
+ agents: dict[str, AgentAggregate] = field(default_factory=dict)
156
+ topics: dict[str, TopicAggregate] = field(default_factory=dict)
157
+ # kid → list of citations/disputes against it (built once)
158
+ citations_to: dict[str, list[dict[str, Any]]] = field(default_factory=lambda: defaultdict(list))
159
+ disputes_to: dict[str, list[dict[str, Any]]] = field(default_factory=lambda: defaultdict(list))
160
+ n_sims: int = 0
161
+ last_built_ts_ms: int = 0
162
+
163
+ # ---- summaries ----
164
+ def overview(self) -> dict[str, Any]:
165
+ total_citations = sum(c.citations_received for c in self.claims.values())
166
+ total_disputes = sum(c.disputes_received for c in self.claims.values())
167
+ resolved = [c for c in self.claims.values() if c.resolved_outcome is not None]
168
+ accuracy = (
169
+ sum(1 for c in resolved if (c.confidence > 0.5) == c.resolved_outcome) / len(resolved)
170
+ if resolved
171
+ else None
172
+ )
173
+ # Top contributors
174
+ top_agents = sorted(self.agents.values(), key=lambda a: a.claims_published, reverse=True)[
175
+ :8
176
+ ]
177
+ top_topics = sorted(self.topics.values(), key=lambda t: t.claim_count, reverse=True)[:8]
178
+ return {
179
+ "n_sims": self.n_sims,
180
+ "n_agents": len(self.agents),
181
+ "n_claims": len(self.claims),
182
+ "n_resolved": len(resolved),
183
+ "total_citations": total_citations,
184
+ "total_disputes": total_disputes,
185
+ "n_topics": len(self.topics),
186
+ "panel_accuracy": accuracy,
187
+ "last_built_ts_ms": self.last_built_ts_ms,
188
+ "top_agents": [a.to_dict() for a in top_agents],
189
+ "top_topics": [t.to_dict() for t in top_topics],
190
+ }
191
+
192
+ def list_claims(
193
+ self,
194
+ *,
195
+ q: str = "",
196
+ persona: str = "",
197
+ topic: str = "",
198
+ sim_id: str = "",
199
+ limit: int = 100,
200
+ offset: int = 0,
201
+ ) -> dict[str, Any]:
202
+ items = list(self.claims.values())
203
+ if q:
204
+ ql = q.lower()
205
+ items = [c for c in items if ql in c.query.lower() or ql in c.kid.lower()]
206
+ if persona:
207
+ items = [c for c in items if c.persona == persona]
208
+ if topic:
209
+ items = [c for c in items if c.category == topic]
210
+ if sim_id:
211
+ items = [c for c in items if c.sim_id == sim_id]
212
+ items.sort(key=lambda c: c.ts_wall_ms, reverse=True)
213
+ total = len(items)
214
+ page = items[offset : offset + limit]
215
+ return {
216
+ "total": total,
217
+ "offset": offset,
218
+ "limit": limit,
219
+ "items": [c.to_dict() for c in page],
220
+ }
221
+
222
+ def list_agents(self) -> list[dict[str, Any]]:
223
+ return [
224
+ a.to_dict()
225
+ for a in sorted(
226
+ self.agents.values(),
227
+ key=lambda x: x.claims_published,
228
+ reverse=True,
229
+ )
230
+ ]
231
+
232
+ def list_topics(self) -> list[dict[str, Any]]:
233
+ return [
234
+ t.to_dict()
235
+ for t in sorted(
236
+ self.topics.values(),
237
+ key=lambda x: x.claim_count,
238
+ reverse=True,
239
+ )
240
+ ]
241
+
242
+ def ask(self, query: str, *, top_k: int = 20, min_overlap: int = 1) -> dict[str, Any]:
243
+ """Search across all claims with text similar to `query` and
244
+ compute an aggregated verdict. Plain word-overlap scoring — no
245
+ embeddings, no LLM round-trip. Cheap, deterministic, on-disk only.
246
+
247
+ Returns a payload the UI's "Ask the federation" tab renders:
248
+ {
249
+ query: str,
250
+ n_matched: int,
251
+ mean_confidence: float | None,
252
+ verdict_label: str,
253
+ agreement: str,
254
+ top_claims: [ClaimRecord.to_dict() ...], # most-relevant
255
+ sim_ids: [str ...], # contributing sims
256
+ personas: {persona: count, ...}, # who weighed in
257
+ }
258
+ """
259
+ if not query.strip():
260
+ return {
261
+ "query": query,
262
+ "n_matched": 0,
263
+ "items": [],
264
+ "sim_ids": [],
265
+ "mean_confidence": None,
266
+ "verdict_label": "",
267
+ "agreement": "",
268
+ "personas": {},
269
+ }
270
+
271
+ # Simple bag-of-words overlap. Lowercase, drop punctuation,
272
+ # drop tiny stopwords. Score = number of overlapping content words.
273
+ STOP = {
274
+ "the",
275
+ "a",
276
+ "an",
277
+ "of",
278
+ "to",
279
+ "in",
280
+ "on",
281
+ "is",
282
+ "are",
283
+ "be",
284
+ "will",
285
+ "and",
286
+ "or",
287
+ "for",
288
+ "with",
289
+ "vs",
290
+ "as",
291
+ "by",
292
+ "this",
293
+ "that",
294
+ "do",
295
+ "does",
296
+ "did",
297
+ "would",
298
+ "could",
299
+ "should",
300
+ "have",
301
+ "has",
302
+ "had",
303
+ "from",
304
+ "at",
305
+ "if",
306
+ "than",
307
+ "what",
308
+ "when",
309
+ "who",
310
+ "how",
311
+ }
312
+
313
+ def tokenize(text: str) -> set[str]:
314
+ t = ""
315
+ for ch in text.lower():
316
+ t += ch if ch.isalnum() else " "
317
+ return {w for w in t.split() if len(w) > 2 and w not in STOP}
318
+
319
+ q_words = tokenize(query)
320
+ if not q_words:
321
+ return {
322
+ "query": query,
323
+ "n_matched": 0,
324
+ "items": [],
325
+ "sim_ids": [],
326
+ "mean_confidence": None,
327
+ "verdict_label": "",
328
+ "agreement": "",
329
+ "personas": {},
330
+ }
331
+
332
+ scored: list[tuple[int, ClaimRecord]] = []
333
+ for c in self.claims.values():
334
+ cw = tokenize(c.query)
335
+ overlap = len(q_words & cw)
336
+ if overlap >= min_overlap:
337
+ scored.append((overlap, c))
338
+
339
+ scored.sort(key=lambda x: (-x[0], -x[1].ts_wall_ms))
340
+ matched = [c for _, c in scored[:top_k]]
341
+ if not matched:
342
+ return {
343
+ "query": query,
344
+ "n_matched": 0,
345
+ "items": [],
346
+ "sim_ids": [],
347
+ "mean_confidence": None,
348
+ "verdict_label": "NO MATCH",
349
+ "agreement": "no prior claims found",
350
+ "personas": {},
351
+ }
352
+
353
+ # Aggregate: confidence-weighted mean (don't double-count
354
+ # identical claim copies from replicas — group by query).
355
+ confidences = [c.confidence for c in matched]
356
+ mean = sum(confidences) / len(confidences)
357
+ # Spread = max - min as a quick "agreement" proxy
358
+ spread = (max(confidences) - min(confidences)) if confidences else 0.0
359
+
360
+ if mean >= 0.7:
361
+ label, agreement = "YES", "strong agreement"
362
+ elif mean >= 0.55:
363
+ label, agreement = "LEAN YES", "leans yes"
364
+ elif mean > 0.45:
365
+ label, agreement = "SPLIT", "no clear consensus"
366
+ elif mean > 0.3:
367
+ label, agreement = "LEAN NO", "leans no"
368
+ else:
369
+ label, agreement = "NO", "strong disagreement"
370
+
371
+ if spread < 0.15:
372
+ agreement = "strong consensus"
373
+ elif spread > 0.35:
374
+ agreement = "sharp disagreement"
375
+
376
+ personas: dict[str, int] = {}
377
+ sims: dict[str, int] = {}
378
+ for c in matched:
379
+ personas[c.persona] = personas.get(c.persona, 0) + 1
380
+ sims[c.sim_id] = sims.get(c.sim_id, 0) + 1
381
+
382
+ return {
383
+ "query": query,
384
+ "n_matched": len(scored),
385
+ "items": [c.to_dict() for c in matched],
386
+ "sim_ids": list(sims.keys()),
387
+ "mean_confidence": mean,
388
+ "spread": spread,
389
+ "verdict_label": label,
390
+ "agreement": agreement,
391
+ "personas": personas,
392
+ }
393
+
394
+ def claim(self, kid: str) -> dict[str, Any] | None:
395
+ c = self.claims.get(kid)
396
+ if c is None:
397
+ # Allow partial-prefix lookup since UI shows truncated kids
398
+ for k, rec in self.claims.items():
399
+ if k.startswith(kid):
400
+ c = rec
401
+ break
402
+ if c is None:
403
+ return None
404
+ return {
405
+ "claim": c.to_dict(),
406
+ "citations": self.citations_to.get(c.kid, []),
407
+ "disputes": self.disputes_to.get(c.kid, []),
408
+ }
409
+
410
+
411
+ # ---------------------------------------------------------------------------
412
+ # Builder
413
+ # ---------------------------------------------------------------------------
414
+
415
+
416
+ def _category_for_query(query: str, questions: list[dict]) -> str:
417
+ """Look up the question's category by query text (since claims only
418
+ carry the query, not the category)."""
419
+ for q in questions:
420
+ if q.get("query") == query:
421
+ return str(q.get("category", "general"))
422
+ return "general"
423
+
424
+
425
+ def _ingest_trace(idx: KnowledgeIndex, sim_id: str, trace: dict[str, Any]) -> None:
426
+ """Pull a single trace.json into the index. Side-effect: mutates idx."""
427
+ questions = trace.get("questions") or []
428
+ panel_records = trace.get("panel_records") or []
429
+
430
+ # Pre-build query→category lookup to avoid O(N×Q) linear scans
431
+ _query_to_cat: dict[str, str] = {
432
+ (q.get("query") or ""): str(q.get("category", "general"))
433
+ for q in questions
434
+ }
435
+
436
+ # Track which agents participated in this sim (for runs_participated)
437
+ sim_agents: set[str] = set()
438
+
439
+ # ---- Claims (from panel_records[].claims) ----
440
+ sim_topic = trace.get("topic") or ""
441
+ for pr in panel_records:
442
+ cat = _query_to_cat.get(pr.get("query") or "", "general")
443
+ # Build reasoning lookup: per_agent rows have name+reasoning;
444
+ # match to claims by agent name + query.
445
+ reasoning_by_agent: dict[str, str] = {}
446
+ for pa in pr.get("per_agent") or []:
447
+ name = pa.get("agent") or pa.get("name") or ""
448
+ r = pa.get("reasoning")
449
+ if name and r:
450
+ reasoning_by_agent[name] = r
451
+ panel_mean = pr.get("mean_confidence")
452
+ panel_spread = pr.get("spread")
453
+ for c in pr.get("claims") or []:
454
+ kid = c.get("kid")
455
+ if not kid:
456
+ continue
457
+ agent_name = c.get("agent", "")
458
+ rec = ClaimRecord(
459
+ kid=kid,
460
+ agent=agent_name,
461
+ persona=c.get("persona", ""),
462
+ query=c.get("query", ""),
463
+ confidence=float(c.get("confidence", 0.0)),
464
+ ts_wall_ms=int(c.get("ts_wall_ms", 0)),
465
+ sim_id=sim_id,
466
+ category=cat,
467
+ reasoning=reasoning_by_agent.get(agent_name, ""),
468
+ sim_topic=sim_topic,
469
+ panel_mean=panel_mean,
470
+ panel_spread=panel_spread,
471
+ )
472
+ idx.claims[kid] = rec
473
+ sim_agents.add(rec.agent)
474
+ # Touch agent aggregate
475
+ ag = idx.agents.setdefault(
476
+ rec.agent,
477
+ AgentAggregate(name=rec.agent, persona=rec.persona),
478
+ )
479
+ ag.persona = rec.persona # keep persona fresh
480
+ ag.claims_published += 1
481
+ ag.last_seen_ts_ms = max(ag.last_seen_ts_ms, rec.ts_wall_ms)
482
+ ag.sims.add(sim_id)
483
+ # Touch topic
484
+ tp = idx.topics.setdefault(cat, TopicAggregate(topic=cat))
485
+ tp.claim_count += 1
486
+ tp.mean_confidence_sum += rec.confidence
487
+ tp.confidence_n += 1
488
+ tp.sims.add(sim_id)
489
+ if pr.get("query") and pr["query"] not in tp.sample_queries:
490
+ tp.sample_queries.append(pr["query"])
491
+
492
+ # ---- Citations (panel_records[].citations) ----
493
+ for pr in panel_records:
494
+ cat = _query_to_cat.get(pr.get("query") or "", "general")
495
+ for c in pr.get("citations") or []:
496
+ target_kid = c.get("to_claim_kid")
497
+ from_agent = c.get("from_agent", "")
498
+ to_agent = c.get("to_agent", "")
499
+ sim_agents.add(from_agent)
500
+ sim_agents.add(to_agent)
501
+ entry = {
502
+ "sim_id": sim_id,
503
+ "from_agent": from_agent,
504
+ "from_persona": c.get("from_persona", ""),
505
+ "to_agent": to_agent,
506
+ "to_persona": c.get("to_persona", ""),
507
+ "to_claim_kid": target_kid,
508
+ "their_conf": c.get("their_conf"),
509
+ "my_conf": c.get("my_conf"),
510
+ "delta": c.get("delta"),
511
+ }
512
+ if target_kid:
513
+ idx.citations_to[target_kid].append(entry)
514
+ if target_kid in idx.claims:
515
+ idx.claims[target_kid].citations_received += 1
516
+ if to_agent:
517
+ idx.agents.setdefault(
518
+ to_agent,
519
+ AgentAggregate(name=to_agent, persona=c.get("to_persona", "")),
520
+ ).citations_received += 1
521
+ if from_agent:
522
+ idx.agents.setdefault(
523
+ from_agent,
524
+ AgentAggregate(name=from_agent, persona=c.get("from_persona", "")),
525
+ ).citations_made += 1
526
+ tp = idx.topics.setdefault(cat, TopicAggregate(topic=cat))
527
+ tp.citation_count += 1
528
+
529
+ # ---- Disputes (panel_records[].disputes) ----
530
+ for pr in panel_records:
531
+ cat = _query_to_cat.get(pr.get("query") or "", "general")
532
+ for d in pr.get("disputes") or []:
533
+ target_kid = d.get("target_claim_kid") or d.get("to_claim_kid")
534
+ from_agent = d.get("from_agent") or d.get("disputer", "")
535
+ target_agent = d.get("target_agent") or d.get("to_agent", "")
536
+ sim_agents.add(from_agent)
537
+ entry = {
538
+ "sim_id": sim_id,
539
+ "from_agent": from_agent,
540
+ "from_persona": d.get("from_persona", ""),
541
+ "target_agent": target_agent,
542
+ "target_persona": d.get("target_persona") or d.get("to_persona", ""),
543
+ "target_claim_kid": target_kid,
544
+ "delta": d.get("delta"),
545
+ }
546
+ if target_kid:
547
+ idx.disputes_to[target_kid].append(entry)
548
+ if target_kid in idx.claims:
549
+ idx.claims[target_kid].disputes_received += 1
550
+ if target_agent:
551
+ idx.agents.setdefault(
552
+ target_agent,
553
+ AgentAggregate(name=target_agent, persona=d.get("target_persona") or ""),
554
+ ).disputes_received += 1
555
+ if from_agent:
556
+ idx.agents.setdefault(
557
+ from_agent,
558
+ AgentAggregate(name=from_agent, persona=d.get("from_persona") or ""),
559
+ ).disputes_made += 1
560
+ tp = idx.topics.setdefault(cat, TopicAggregate(topic=cat))
561
+ tp.dispute_count += 1
562
+
563
+ # ---- Vouches ----
564
+ for v in trace.get("vouches") or []:
565
+ voucher = v.get("voucher", "")
566
+ vouchee = v.get("vouchee", "")
567
+ if vouchee:
568
+ idx.agents.setdefault(
569
+ vouchee,
570
+ AgentAggregate(name=vouchee, persona=v.get("vouchee_persona", "")),
571
+ ).vouches_received += 1
572
+ if voucher:
573
+ idx.agents.setdefault(
574
+ voucher,
575
+ AgentAggregate(name=voucher, persona=v.get("voucher_persona", "")),
576
+ ).vouches_given += 1
577
+
578
+ # ---- Oracle resolutions: outcome + accuracy ----
579
+ for r in trace.get("oracle_resolutions") or []:
580
+ kid = r.get("resolved_kid")
581
+ outcome = r.get("outcome")
582
+ gt = r.get("ground_truth")
583
+ publisher = r.get("publisher", "")
584
+ predicted = r.get("predicted_confidence", 0.5)
585
+ if kid and kid in idx.claims:
586
+ idx.claims[kid].resolved_outcome = bool(outcome) if outcome is not None else None
587
+ if gt is not None:
588
+ idx.claims[kid].ground_truth = float(gt)
589
+ if publisher:
590
+ ag = idx.agents.setdefault(
591
+ publisher,
592
+ AgentAggregate(name=publisher, persona=r.get("publisher_persona", "")),
593
+ )
594
+ ag.oracle_total += 1
595
+ if outcome is not None and (predicted > 0.5) == bool(outcome):
596
+ ag.oracle_correct += 1
597
+
598
+ # ---- Reputation: pick the latest after_oracle score per agent ----
599
+ rs = trace.get("reputation_snapshots") or {}
600
+ for snap in rs.get("after_oracle") or []:
601
+ name = snap.get("agent")
602
+ if not name:
603
+ continue
604
+ ag = idx.agents.setdefault(
605
+ name,
606
+ AgentAggregate(name=name, persona=snap.get("persona", "")),
607
+ )
608
+ ag.last_reputation = float(snap.get("score", 0.0))
609
+
610
+ # Mark every agent that participated in this sim
611
+ for name in sim_agents:
612
+ if name:
613
+ ag = idx.agents.setdefault(name, AgentAggregate(name=name, persona=""))
614
+ ag.runs_participated += 1
615
+ ag.sims.add(sim_id)
616
+
617
+
618
+ # ---------------------------------------------------------------------------
619
+ # Public builder + cache
620
+ # ---------------------------------------------------------------------------
621
+
622
+ _CACHE: KnowledgeIndex | None = None
623
+ _CACHE_LOCK = threading.Lock()
624
+ _CACHE_SEEN_SIMS: set[str] = set()
625
+ _INVALIDATION_COUNTER: int = 0
626
+ _CACHE_COUNTER_AT_BUILD: int = -1
627
+
628
+ # Per-sim trace cache: completed sims never change, so once parsed we
629
+ # keep the raw trace dict to avoid re-reading trace.json on every rebuild.
630
+ # Capped at 500 entries; oldest entry is evicted when full.
631
+ _knowledge_cache: dict[str, Any] = {} # sim_id -> parsed trace dict
632
+ _knowledge_cache_lock = threading.Lock()
633
+ _KNOWLEDGE_CACHE_MAX = 500
634
+
635
+
636
+ def _get_cached_claims(sim_id: str) -> Any:
637
+ with _knowledge_cache_lock:
638
+ return _knowledge_cache.get(sim_id)
639
+
640
+
641
+ def _set_cached_claims(sim_id: str, trace: Any) -> None:
642
+ with _knowledge_cache_lock:
643
+ if len(_knowledge_cache) >= _KNOWLEDGE_CACHE_MAX:
644
+ # Evict the oldest entry (first key in insertion order)
645
+ oldest = next(iter(_knowledge_cache))
646
+ del _knowledge_cache[oldest]
647
+ _knowledge_cache[sim_id] = trace
648
+
649
+
650
+ def build_knowledge_index() -> KnowledgeIndex:
651
+ """Walk the registry and build a fresh index. Cached; cheap if no
652
+ new sims arrived since the last build.
653
+ """
654
+ global _CACHE, _CACHE_SEEN_SIMS, _CACHE_COUNTER_AT_BUILD
655
+
656
+ # O(1) fast path — nothing has changed since last build
657
+ with _CACHE_LOCK:
658
+ if _CACHE is not None and _CACHE_COUNTER_AT_BUILD == _INVALIDATION_COUNTER:
659
+ return _CACHE
660
+
661
+ sdir = sims_dir()
662
+
663
+ # Snapshot which sim-dirs are currently on disk + their states.
664
+ # We rebuild fully if the set of done sims changed.
665
+ current_done: set[str] = set()
666
+ for d in sdir.iterdir():
667
+ if not d.is_dir():
668
+ continue
669
+ status_path = d / "status.json"
670
+ if not status_path.exists():
671
+ continue
672
+ try:
673
+ status = json.loads(status_path.read_text())
674
+ except (OSError, json.JSONDecodeError):
675
+ continue
676
+ if status.get("state") == "done" and (d / "trace.json").exists():
677
+ current_done.add(d.name)
678
+
679
+ # Second fast path — done-sim set unchanged
680
+ with _CACHE_LOCK:
681
+ if _CACHE is not None and current_done == _CACHE_SEEN_SIMS:
682
+ return _CACHE
683
+
684
+ # Build index OUTSIDE the lock so concurrent readers aren't blocked
685
+ idx = KnowledgeIndex()
686
+ for sim_id in sorted(current_done):
687
+ # Check the per-sim trace cache first — completed sims never change.
688
+ trace = _get_cached_claims(sim_id)
689
+ if trace is None:
690
+ trace_path = sdir / sim_id / "trace.json"
691
+ try:
692
+ trace = json.loads(trace_path.read_text())
693
+ except (OSError, json.JSONDecodeError):
694
+ continue
695
+ _set_cached_claims(sim_id, trace)
696
+ _ingest_trace(idx, sim_id, trace)
697
+ idx.n_sims = len(current_done)
698
+ import time
699
+ idx.last_built_ts_ms = int(time.time() * 1000)
700
+
701
+ # Swap under lock with double-check to avoid clobbering a concurrent build
702
+ with _CACHE_LOCK:
703
+ if _CACHE is None or current_done != _CACHE_SEEN_SIMS:
704
+ _CACHE = idx
705
+ _CACHE_SEEN_SIMS = current_done
706
+ _CACHE_COUNTER_AT_BUILD = _INVALIDATION_COUNTER
707
+ return idx
708
+
709
+
710
+ def invalidate() -> None:
711
+ """Force the next ``build_knowledge_index()`` call to rebuild."""
712
+ global _CACHE, _CACHE_SEEN_SIMS, _INVALIDATION_COUNTER
713
+ with _CACHE_LOCK:
714
+ _CACHE = None
715
+ _CACHE_SEEN_SIMS = set()
716
+ _INVALIDATION_COUNTER += 1