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,295 @@
1
+ """Cross-sim learning metrics for the SimLab.
2
+
3
+ Exposes three pure functions that power the "Learning" tab in SimDetail:
4
+
5
+ find_related_sims(topic) — sims on a related topic, ranked by Brier
6
+ compute_knowledge_reuse(sim_id) — how much prior knowledge this sim reused
7
+ get_persona_trajectory(persona) — Brier trajectory across sims for one persona
8
+
9
+ These are read-only; they never mutate any registry record.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from typing import Any
16
+
17
+ from .registry import list_sims, load_status, load_trace, sim_dir
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Text-similarity helpers
21
+ # ---------------------------------------------------------------------------
22
+
23
+ _STOPWORDS = frozenset(
24
+ {
25
+ "a", "an", "the", "in", "on", "at", "to", "for", "of", "and", "or",
26
+ "but", "is", "are", "was", "were", "be", "been", "will", "would",
27
+ "could", "should", "may", "might", "do", "does", "did", "have",
28
+ "has", "had", "that", "this", "these", "those", "with", "by", "from",
29
+ "than", "more", "less", "very", "also", "as", "up", "about", "into",
30
+ "through", "between", "during", "can", "not", "if",
31
+ }
32
+ )
33
+
34
+ _TOKEN_RE = re.compile(r"[a-z0-9]+")
35
+
36
+
37
+ def _tokenize(text: str) -> set[str]:
38
+ tokens = _TOKEN_RE.findall(text.lower())
39
+ return {t for t in tokens if t not in _STOPWORDS and len(t) > 1}
40
+
41
+
42
+ def _jaccard(a: set[str], b: set[str]) -> float:
43
+ if not a and not b:
44
+ return 1.0
45
+ union = a | b
46
+ if not union:
47
+ return 0.0
48
+ return len(a & b) / len(union)
49
+
50
+
51
+ def _extract_persona_slug(agent_name: str) -> str:
52
+ """Strip trailing replica suffix ('-01', '-02', ...) from an agent name."""
53
+ return re.sub(r"-\d+$", "", agent_name)
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # find_related_sims
58
+ # ---------------------------------------------------------------------------
59
+
60
+
61
+ def find_related_sims(topic: str, *, limit: int = 5) -> list[dict]:
62
+ """Return up to *limit* completed sims on a related topic.
63
+
64
+ Exact-match sims are ranked before partial-match. Within each group
65
+ results are ordered by mean_brier ascending (best calibrated first).
66
+ Sims with no oracle resolutions are included but ranked last.
67
+ """
68
+ if not topic or not topic.strip():
69
+ return []
70
+
71
+ topic_norm = topic.strip()
72
+ topic_tokens = _tokenize(topic_norm)
73
+
74
+ results: list[dict] = []
75
+ for sim in list_sims(limit=2000):
76
+ if sim.state != "done":
77
+ continue
78
+ sim_topic = (sim.topic or "").strip()
79
+ if not sim_topic:
80
+ # Fall back to the topic stored in trace.json
81
+ trace = load_trace(sim.sim_id)
82
+ if trace:
83
+ sim_topic = (trace.get("topic") or "").strip()
84
+ if not sim_topic:
85
+ continue
86
+
87
+ # Jaccard similarity
88
+ sim_tokens = _tokenize(sim_topic)
89
+ j = _jaccard(topic_tokens, sim_tokens)
90
+ if j < 0.15:
91
+ continue
92
+
93
+ status = load_status(sim.sim_id)
94
+ mean_brier = status.get("mean_brier") # set by _handle_resolve_outcomes
95
+ n_resolved = status.get("n_resolved", 0)
96
+
97
+ results.append(
98
+ {
99
+ "sim_id": sim.sim_id,
100
+ "topic": sim_topic,
101
+ "similarity": round(j, 4),
102
+ "exact_match": sim_topic.lower() == topic_norm.lower(),
103
+ "mean_brier": mean_brier,
104
+ "n_resolved": n_resolved,
105
+ "n_questions": sim.n_questions,
106
+ "n_agents": sim.n_agents,
107
+ "finished_at": sim.finished_at,
108
+ "model": sim.model,
109
+ }
110
+ )
111
+
112
+ # Sort: exact match first, then by similarity desc, then by brier asc
113
+ # (None brier → rank after real Brier scores)
114
+ def _sort_key(r: dict) -> tuple:
115
+ exact = 0 if r["exact_match"] else 1
116
+ brier = r["mean_brier"] if r["mean_brier"] is not None else 9999.0
117
+ return (exact, -r["similarity"], brier)
118
+
119
+ results.sort(key=_sort_key)
120
+ return results[:limit]
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # compute_knowledge_reuse
125
+ # ---------------------------------------------------------------------------
126
+
127
+ _TOP_K = 5 # number of top-Brier personas selected when reuse kicks in
128
+
129
+
130
+ def compute_knowledge_reuse(sim_id: str) -> dict | None:
131
+ """Compute cross-sim learning stats for a completed sim.
132
+
133
+ Returns None if the sim directory doesn't exist or is not done.
134
+ Returns a dict with:
135
+ prior_sims_used int — number of related prior sims found
136
+ claims_available_from_prior int — total claims in those sims
137
+ n_personas int — persona replicas in this sim
138
+ n_questions int — questions in this sim
139
+ delib_rounds int — deliberation rounds (>=1)
140
+ actual_llm_calls int — estimated actual LLM calls
141
+ optimized_llm_calls int — estimated calls if TOP_K selection used
142
+ calls_saved_potential int — difference
143
+ pct_reduction float — fraction saved (0-1)
144
+ related_sims list — from find_related_sims (up to 5)
145
+ """
146
+ if not sim_dir(sim_id).exists():
147
+ return None
148
+ status = load_status(sim_id)
149
+ if status.get("state") != "done":
150
+ return None
151
+ trace = load_trace(sim_id)
152
+ if trace is None:
153
+ return None
154
+
155
+ topic = trace.get("topic", "")
156
+ n_agents: int = trace.get("n_agents") or 0
157
+ n_questions: int = trace.get("n_questions") or len(trace.get("panel_records") or [])
158
+ delib_rounds: int = trace.get("n_deliberation_rounds") or 1
159
+
160
+ # Actual LLM calls: each agent answers each question once per round
161
+ actual_llm_calls = n_agents * n_questions * max(1, delib_rounds)
162
+
163
+ # If we had selected only TOP_K agents based on prior Brier, how many calls?
164
+ top_k = min(_TOP_K, n_agents)
165
+ optimized_llm_calls = top_k * n_questions * max(1, delib_rounds)
166
+ calls_saved_potential = actual_llm_calls - optimized_llm_calls
167
+ pct_reduction = calls_saved_potential / actual_llm_calls if actual_llm_calls > 0 else 0.0
168
+
169
+ # Find related sims (exclude self)
170
+ related = [r for r in find_related_sims(topic, limit=6) if r["sim_id"] != sim_id][:5]
171
+ claims_available = 0
172
+ for rel in related:
173
+ rel_trace = load_trace(rel["sim_id"])
174
+ if rel_trace:
175
+ for pr in rel_trace.get("panel_records") or []:
176
+ claims_available += len(pr.get("claims") or [])
177
+
178
+ return {
179
+ "prior_sims_used": len(related),
180
+ "claims_available_from_prior": claims_available,
181
+ "n_personas": n_agents,
182
+ "n_questions": n_questions,
183
+ "delib_rounds": delib_rounds,
184
+ "actual_llm_calls": actual_llm_calls,
185
+ "optimized_llm_calls": optimized_llm_calls,
186
+ "calls_saved_potential": calls_saved_potential,
187
+ "pct_reduction": round(pct_reduction, 4),
188
+ "related_sims": related,
189
+ }
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # get_persona_trajectory
194
+ # ---------------------------------------------------------------------------
195
+
196
+
197
+ def get_persona_trajectory(persona: str) -> dict:
198
+ """Return the Brier trajectory for a persona slug across all completed sims.
199
+
200
+ The trajectory is a list of per-sim observations, sorted by sim finish time.
201
+ Each observation includes the mean Brier for that persona across all agents
202
+ whose name matches the slug (after stripping the replica suffix).
203
+
204
+ Returns:
205
+ {
206
+ "persona": str,
207
+ "n_sims": int,
208
+ "mean_brier_all": float | None, # lifetime mean across observations
209
+ "trajectory": [
210
+ {
211
+ "sim_id": str,
212
+ "topic": str,
213
+ "sim_n": int, # sequential run number (1-based)
214
+ "mean_brier": float,
215
+ "n_resolved": int,
216
+ "finished_at": int | None,
217
+ }
218
+ ]
219
+ }
220
+ """
221
+ observations: list[dict] = []
222
+ for sim in list_sims(limit=2000):
223
+ if sim.state != "done":
224
+ continue
225
+ trace = load_trace(sim.sim_id)
226
+ if trace is None:
227
+ continue
228
+
229
+ # Look at agent_brier_scores stored on trace
230
+ abs_scores: dict[str, Any] = trace.get("agent_brier_scores") or {}
231
+ persona_briers: list[float] = []
232
+ persona_n: int = 0
233
+
234
+ for agent_name, scores in abs_scores.items():
235
+ slug = _extract_persona_slug(agent_name)
236
+ if slug != persona:
237
+ continue
238
+ mb = scores.get("mean_brier")
239
+ n = scores.get("n", 0)
240
+ if mb is not None and n > 0:
241
+ persona_briers.append(mb)
242
+ persona_n += n
243
+
244
+ # Also scan per_agent columns in panel_records if no abs_scores yet
245
+ if not persona_briers:
246
+ panel_records = trace.get("panel_records") or []
247
+ resolved_panels = [pr for pr in panel_records if pr.get("ground_truth") is not None]
248
+ for pr in resolved_panels:
249
+ gt = float(pr.get("ground_truth", 0))
250
+ for claim in pr.get("claims") or []:
251
+ slug = _extract_persona_slug(claim.get("agent", ""))
252
+ if slug != persona:
253
+ continue
254
+ conf = claim.get("confidence")
255
+ if conf is not None:
256
+ try:
257
+ brier = (float(conf) - gt) ** 2
258
+ persona_briers.append(brier)
259
+ persona_n += 1
260
+ except (TypeError, ValueError):
261
+ pass
262
+
263
+ if not persona_briers:
264
+ continue
265
+
266
+ mean_brier = sum(persona_briers) / len(persona_briers)
267
+ observations.append(
268
+ {
269
+ "sim_id": sim.sim_id,
270
+ "topic": sim.topic or "",
271
+ "mean_brier": round(mean_brier, 6),
272
+ "n_resolved": persona_n,
273
+ "finished_at": sim.finished_at,
274
+ }
275
+ )
276
+
277
+ # Sort chronologically
278
+ observations.sort(key=lambda o: (o["finished_at"] or 0))
279
+
280
+ # Assign sequential run numbers
281
+ for i, obs in enumerate(observations, 1):
282
+ obs["sim_n"] = i
283
+
284
+ # Lifetime mean
285
+ if observations:
286
+ mean_brier_all = sum(o["mean_brier"] for o in observations) / len(observations)
287
+ else:
288
+ mean_brier_all = None
289
+
290
+ return {
291
+ "persona": persona,
292
+ "n_sims": len(observations),
293
+ "mean_brier_all": round(mean_brier_all, 6) if mean_brier_all is not None else None,
294
+ "trajectory": observations,
295
+ }
@@ -0,0 +1,115 @@
1
+ """Deliberation-mode registry for SimLab.
2
+
3
+ A *mode* selects the deliberation shape and result schema for a sim run.
4
+ The default mode is ``binary-forecast`` — the legacy persona-panel shape
5
+ (consensus probability vs. ``ground_truth ∈ [0,1]``). Additional modes
6
+ (``governance-committee``, ``what-if-tree``, ...) plug in here without
7
+ touching the engine entry point in :mod:`hypermind.simlab.scenario`.
8
+
9
+ Each mode is a :class:`ModeSpec` with:
10
+
11
+ * ``slug`` — stable identifier used by ``SimConfig.mode``.
12
+ * ``label`` — human-readable name for the SPA dropdown.
13
+ * ``input_schema`` — JSON schema (draft-2020-12 subset) describing the
14
+ fields the user submits via ``SimConfig.mode_payload``. ``None`` means
15
+ the mode reuses the legacy SimConfig fields directly.
16
+ * ``result_schema`` — JSON schema for the trace's mode-specific output.
17
+ * ``run_fn`` — async callable ``(args, recorder) -> None`` that
18
+ matches the signature of the legacy ``_scenario_impl.main``. The mode
19
+ is responsible for writing ``trace.json`` to ``args.trace``.
20
+
21
+ Public API:
22
+
23
+ register(spec) — register a mode (idempotent on slug)
24
+ get_mode(slug) -> ModeSpec — lookup; raises KeyError if unknown
25
+ list_modes() -> list[ModeSpec]
26
+ is_registered_mode(slug) -> bool
27
+
28
+ The default ``binary-forecast`` mode is registered at import time.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from collections.abc import Awaitable, Callable
34
+ from dataclasses import dataclass, field
35
+ from typing import Any
36
+
37
+ # Run signature: (args: SimpleNamespace-like, recorder: Recorder | None) -> None.
38
+ # Kept loose so modes can accept additional kwargs without touching this type.
39
+ ModeRunFn = Callable[..., Awaitable[None]]
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class ModeSpec:
44
+ slug: str
45
+ label: str
46
+ description: str
47
+ input_schema: dict[str, Any] | None
48
+ result_schema: dict[str, Any] | None
49
+ run_fn: ModeRunFn
50
+ # Whether this mode reuses the legacy SimConfig fields (personas,
51
+ # questions, n_questions, ...) for its inputs. ``False`` means the
52
+ # mode reads only ``mode_payload``. The schema-driven SimNew form
53
+ # uses this to decide whether to render legacy controls.
54
+ uses_legacy_fields: bool = True
55
+ # Optional list of role slugs the mode expects to be assignable.
56
+ expected_roles: list[str] = field(default_factory=list)
57
+
58
+ def to_dict(self) -> dict[str, Any]:
59
+ return {
60
+ "slug": self.slug,
61
+ "label": self.label,
62
+ "description": self.description,
63
+ "input_schema": self.input_schema,
64
+ "result_schema": self.result_schema,
65
+ "uses_legacy_fields": self.uses_legacy_fields,
66
+ "expected_roles": list(self.expected_roles),
67
+ }
68
+
69
+
70
+ _REGISTRY: dict[str, ModeSpec] = {}
71
+
72
+
73
+ def register(spec: ModeSpec) -> None:
74
+ """Register a mode. Re-registering the same slug overwrites — useful
75
+ in tests but should not happen in production code paths."""
76
+ _REGISTRY[spec.slug] = spec
77
+
78
+
79
+ def get_mode(slug: str) -> ModeSpec:
80
+ if slug not in _REGISTRY:
81
+ raise KeyError(f"unknown mode {slug!r}; registered: {sorted(_REGISTRY.keys())}")
82
+ return _REGISTRY[slug]
83
+
84
+
85
+ def list_modes() -> list[ModeSpec]:
86
+ return sorted(_REGISTRY.values(), key=lambda s: s.slug)
87
+
88
+
89
+ def is_registered_mode(slug: str) -> bool:
90
+ return slug in _REGISTRY
91
+
92
+
93
+ # --- Default registrations -------------------------------------------------
94
+ # Imported for side effect; each module calls ``register(...)`` at import.
95
+ from . import ( # noqa: E402
96
+ aar, # noqa: F401
97
+ backtest, # noqa: F401
98
+ binary_forecast, # noqa: F401
99
+ conformance, # noqa: F401
100
+ evaluation, # noqa: F401
101
+ governance, # noqa: F401
102
+ redteam, # noqa: F401
103
+ tabletop, # noqa: F401
104
+ tournament, # noqa: F401
105
+ whatif, # noqa: F401
106
+ )
107
+
108
+ __all__ = [
109
+ "ModeRunFn",
110
+ "ModeSpec",
111
+ "get_mode",
112
+ "is_registered_mode",
113
+ "list_modes",
114
+ "register",
115
+ ]