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,448 @@
1
+ """Decision Tournament mode — pairwise bracket with Condorcet winner.
2
+
3
+ The user submits 3–6 named *options* (acquire vs build vs partner;
4
+ candidate A vs B vs C; vendor 1 vs 2 vs 3) plus a list of *criteria*
5
+ (cost, time-to-value, risk, fit). The mode runs a round-robin
6
+ tournament: every option faces every other option, evaluated against
7
+ each criterion. Each match produces a signed receipt.
8
+
9
+ The aggregate result includes:
10
+
11
+ * a **Condorcet winner** (option that beats every other option in
12
+ pairwise comparisons), if one exists
13
+ * a **Borda count** ranking (sum of wins)
14
+ * a per-criterion sensitivity matrix — *"if you weighted criterion
15
+ X higher, would the winner change?"*
16
+ * per-match dissents preserved as first-class records
17
+
18
+ The headline UI visual is the bracket: a grid of pairwise matches,
19
+ each a small card with criterion-by-criterion scores and a winner
20
+ chip. Click a match to see the receipt and dissent.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+ import json
27
+ import time
28
+ from dataclasses import asdict, dataclass
29
+ from itertools import combinations
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ from . import ModeSpec, register
34
+
35
+ # --- Result types ----------------------------------------------------------
36
+
37
+
38
+ @dataclass
39
+ class CriterionScore:
40
+ criterion: str
41
+ option_a_score: float # 0..1
42
+ option_b_score: float # 0..1
43
+ weight: float # >= 0; user-provided; defaults to 1.0
44
+
45
+
46
+ @dataclass
47
+ class MatchDissent:
48
+ role: str
49
+ counter_winner: str
50
+ rationale: str
51
+
52
+
53
+ @dataclass
54
+ class Match:
55
+ match_id: str
56
+ option_a: str
57
+ option_b: str
58
+ scores: list[CriterionScore]
59
+ winner: str # option_a | option_b | tie
60
+ margin: float # weighted score difference, signed (+ favours a)
61
+ receipt_kid: str # synthetic receipt identifier
62
+ dissents: list[MatchDissent]
63
+
64
+
65
+ @dataclass
66
+ class TournamentSummary:
67
+ winner_condorcet: str | None
68
+ winner_borda: str
69
+ borda_counts: dict[str, int]
70
+ pairwise_table: dict[str, dict[str, str]] # row beats col? "win"|"loss"|"tie"|"-"
71
+ sensitivity_pivots: list[dict[str, Any]] # criterion → {weight_threshold, new_winner}
72
+ n_options: int
73
+ n_criteria: int
74
+
75
+
76
+ # --- Input schema ----------------------------------------------------------
77
+
78
+ INPUT_SCHEMA: dict[str, Any] = {
79
+ "type": "object",
80
+ "title": "Decision Tournament",
81
+ "required": ["question", "options"],
82
+ "properties": {
83
+ "question": {
84
+ "type": "string",
85
+ "title": "Decision",
86
+ "format": "textarea",
87
+ "description": (
88
+ "What are you deciding? e.g. 'Acquire vs build vs partner "
89
+ "for the AI assistant initiative.'"
90
+ ),
91
+ },
92
+ "options": {
93
+ "type": "array",
94
+ "title": "Options (3–6, comma-separated)",
95
+ "description": "Named alternatives to compare.",
96
+ "examples": ["acquire-anthropic, build-in-house, partner-with-vendor"],
97
+ "default": [],
98
+ },
99
+ "criteria": {
100
+ "type": "array",
101
+ "title": "Criteria (each: 'name=weight', weight optional)",
102
+ "description": (
103
+ "e.g. 'cost=2,time=1,risk=2,fit=1'. Higher weight = "
104
+ "more decisive in the verdict. Default weight is 1."
105
+ ),
106
+ "examples": ["cost=2", "time-to-value=1", "regulatory-risk=3", "team-fit=1"],
107
+ "default": [],
108
+ },
109
+ },
110
+ }
111
+
112
+
113
+ RESULT_SCHEMA: dict[str, Any] = {
114
+ "type": "object",
115
+ "properties": {
116
+ "mode": {"type": "string", "const": "decision-tournament"},
117
+ "question": {"type": "string"},
118
+ "options": {"type": "array"},
119
+ "matches": {"type": "array"},
120
+ "summary": {"type": "object"},
121
+ },
122
+ }
123
+
124
+
125
+ # --- Parsing ---------------------------------------------------------------
126
+
127
+
128
+ def _parse_options(raw: list[str] | str) -> list[str]:
129
+ if isinstance(raw, str):
130
+ raw = [raw]
131
+ out: list[str] = []
132
+ for item in raw:
133
+ for piece in str(item).split(","):
134
+ piece = piece.strip()
135
+ if piece and piece not in out:
136
+ out.append(piece)
137
+ return out[:6]
138
+
139
+
140
+ def _parse_criteria(raw: list[str] | str) -> list[tuple[str, float]]:
141
+ if isinstance(raw, str):
142
+ raw = [raw]
143
+ out: list[tuple[str, float]] = []
144
+ seen: set[str] = set()
145
+ for item in raw:
146
+ for piece in str(item).split(","):
147
+ piece = piece.strip()
148
+ if not piece:
149
+ continue
150
+ name, _, weight_str = piece.partition("=")
151
+ name = name.strip()
152
+ try:
153
+ weight = float(weight_str) if weight_str else 1.0
154
+ except ValueError:
155
+ weight = 1.0
156
+ if name and name not in seen:
157
+ out.append((name, weight))
158
+ seen.add(name)
159
+ return out
160
+
161
+
162
+ def _seed_criteria_for_question(q: str) -> list[tuple[str, float]]:
163
+ """Default criteria when the user gives none."""
164
+ h = q.lower()
165
+ if "acquire" in h or "build" in h or "partner" in h or "vendor" in h:
166
+ return [("cost", 2.0), ("time-to-value", 1.0), ("strategic-fit", 2.0), ("risk", 1.5)]
167
+ if "candidate" in h or "hire" in h:
168
+ return [("technical-fit", 2.0), ("culture-fit", 2.0), ("ramp-time", 1.0), ("cost", 1.0)]
169
+ return [("value", 2.0), ("cost", 1.5), ("risk", 1.5), ("speed", 1.0)]
170
+
171
+
172
+ # --- Synthetic scoring -----------------------------------------------------
173
+
174
+
175
+ def _option_score(option: str, criterion: str) -> float:
176
+ """Map (option, criterion) → [0,1] deterministically. Includes a
177
+ structural lean (e.g. "build" tends to score high on strategic-fit
178
+ but low on time-to-value) plus a hash-based unique fingerprint."""
179
+ structural = 0.5
180
+ o = option.lower()
181
+ c = criterion.lower()
182
+ if "build" in o:
183
+ structural += {
184
+ "strategic-fit": +0.20,
185
+ "cost": -0.15,
186
+ "time-to-value": -0.20,
187
+ "risk": -0.10,
188
+ "value": +0.15,
189
+ }.get(c, 0.0)
190
+ if "acquire" in o:
191
+ structural += {
192
+ "time-to-value": +0.20,
193
+ "cost": -0.20,
194
+ "strategic-fit": +0.05,
195
+ "risk": -0.05,
196
+ "speed": +0.20,
197
+ }.get(c, 0.0)
198
+ if "partner" in o:
199
+ structural += {
200
+ "cost": +0.15,
201
+ "risk": +0.15,
202
+ "strategic-fit": -0.10,
203
+ "time-to-value": +0.10,
204
+ }.get(c, 0.0)
205
+ h = int(hashlib.sha256(f"{option}::{criterion}".encode()).hexdigest(), 16)
206
+ fingerprint = ((h % 41) - 20) / 100.0 # [-0.20, 0.20]
207
+ return max(0.0, min(1.0, structural + fingerprint))
208
+
209
+
210
+ def _resolve_match(
211
+ option_a: str,
212
+ option_b: str,
213
+ criteria: list[tuple[str, float]],
214
+ ) -> tuple[list[CriterionScore], str, float]:
215
+ """Run a single pairwise match. Returns (scores, winner, margin)."""
216
+ scores: list[CriterionScore] = []
217
+ weighted_diff = 0.0
218
+ total_weight = 0.0
219
+ for name, weight in criteria:
220
+ a = _option_score(option_a, name)
221
+ b = _option_score(option_b, name)
222
+ scores.append(
223
+ CriterionScore(
224
+ criterion=name,
225
+ option_a_score=round(a, 3),
226
+ option_b_score=round(b, 3),
227
+ weight=weight,
228
+ )
229
+ )
230
+ weighted_diff += weight * (a - b)
231
+ total_weight += weight
232
+ normalised_margin = weighted_diff / max(total_weight, 0.001)
233
+ if abs(normalised_margin) < 0.02:
234
+ return scores, "tie", round(normalised_margin, 4)
235
+ winner = option_a if normalised_margin > 0 else option_b
236
+ return scores, winner, round(normalised_margin, 4)
237
+
238
+
239
+ def _match_dissents(
240
+ scores: list[CriterionScore],
241
+ option_a: str,
242
+ option_b: str,
243
+ winner: str,
244
+ ) -> list[MatchDissent]:
245
+ """A criterion-as-dissenter: any criterion where the *loser* scored
246
+ materially higher (margin ≥ 0.15) becomes a dissent record."""
247
+ out: list[MatchDissent] = []
248
+ if winner == "tie":
249
+ return out
250
+ for s in scores:
251
+ loser_score = s.option_b_score if winner == option_a else s.option_a_score
252
+ winner_score = s.option_a_score if winner == option_a else s.option_b_score
253
+ loser = option_b if winner == option_a else option_a
254
+ if loser_score - winner_score >= 0.15:
255
+ out.append(
256
+ MatchDissent(
257
+ role=f"criterion:{s.criterion}",
258
+ counter_winner=loser,
259
+ rationale=(
260
+ f"On {s.criterion}, {loser} scored "
261
+ f"{loser_score:.2f} vs winner's {winner_score:.2f}. "
262
+ f"If this criterion were weighted higher, the verdict "
263
+ f"would flip."
264
+ ),
265
+ )
266
+ )
267
+ return out
268
+
269
+
270
+ def _condorcet_winner(
271
+ options: list[str],
272
+ pairwise: dict[str, dict[str, str]],
273
+ ) -> str | None:
274
+ for o in options:
275
+ beats_all = True
276
+ for other in options:
277
+ if other == o:
278
+ continue
279
+ if pairwise.get(o, {}).get(other) != "win":
280
+ beats_all = False
281
+ break
282
+ if beats_all:
283
+ return o
284
+ return None
285
+
286
+
287
+ def _borda_counts(matches: list[Match], options: list[str]) -> dict[str, int]:
288
+ counts: dict[str, int] = {o: 0 for o in options}
289
+ for m in matches:
290
+ if m.winner in counts:
291
+ counts[m.winner] += 1
292
+ return counts
293
+
294
+
295
+ def _sensitivity_pivots(
296
+ options: list[str],
297
+ matches: list[Match],
298
+ criteria: list[tuple[str, float]],
299
+ current_winner: str,
300
+ ) -> list[dict[str, Any]]:
301
+ """For each criterion, ask: at what weight does the overall winner
302
+ change? Approximation: re-rank Borda counts under doubled weight on
303
+ that criterion, see if the new winner differs.
304
+
305
+ This is a synthetic mirror of "what would change my mind?"
306
+ """
307
+ pivots: list[dict[str, Any]] = []
308
+ for name, _ in criteria:
309
+ boosted_borda: dict[str, int] = {o: 0 for o in options}
310
+ for m in matches:
311
+ # Re-resolve with this criterion doubled.
312
+ new_diff = 0.0
313
+ new_w = 0.0
314
+ for s in m.scores:
315
+ w = s.weight * 2.0 if s.criterion == name else s.weight
316
+ new_diff += w * (s.option_a_score - s.option_b_score)
317
+ new_w += w
318
+ margin = new_diff / max(new_w, 0.001)
319
+ if abs(margin) < 0.02:
320
+ continue
321
+ new_winner = m.option_a if margin > 0 else m.option_b
322
+ boosted_borda[new_winner] += 1
323
+ new_overall = max(boosted_borda.items(), key=lambda kv: kv[1])[0]
324
+ if new_overall != current_winner:
325
+ pivots.append(
326
+ {
327
+ "criterion": name,
328
+ "current_winner": current_winner,
329
+ "new_winner": new_overall,
330
+ "explanation": (
331
+ f"If {name!r} were weighted ~2x higher, the overall "
332
+ f"winner would shift from {current_winner!r} to "
333
+ f"{new_overall!r}."
334
+ ),
335
+ }
336
+ )
337
+ return pivots
338
+
339
+
340
+ # --- Run function ----------------------------------------------------------
341
+
342
+
343
+ async def _run_tournament(args: Any, recorder: Any | None = None) -> None:
344
+ payload = getattr(args, "mode_payload", None) or {}
345
+ question = (payload.get("question") or "").strip() or (
346
+ "Acquire vs build vs partner for the new AI capability."
347
+ )
348
+ options = _parse_options(payload.get("options") or [])
349
+ if len(options) < 2:
350
+ # Demo-friendly fallback so the SPA always renders something.
351
+ options = ["acquire", "build", "partner"]
352
+ criteria = _parse_criteria(payload.get("criteria") or [])
353
+ if not criteria:
354
+ criteria = _seed_criteria_for_question(question)
355
+
356
+ started_at = time.time()
357
+ salt = hashlib.sha256(question.encode("utf-8")).hexdigest()[:16]
358
+
359
+ matches: list[Match] = []
360
+ pairwise: dict[str, dict[str, str]] = {o: {} for o in options}
361
+
362
+ for i, (a, b) in enumerate(combinations(options, 2), start=1):
363
+ scores, winner, margin = _resolve_match(a, b, criteria)
364
+ dissents = _match_dissents(scores, a, b, winner)
365
+ receipt_kid = hashlib.sha256(
366
+ f"match::{salt}::{a}::{b}".encode(),
367
+ ).hexdigest()[:24]
368
+ m = Match(
369
+ match_id=f"M-{i:03d}",
370
+ option_a=a,
371
+ option_b=b,
372
+ scores=scores,
373
+ winner=winner,
374
+ margin=margin,
375
+ receipt_kid=receipt_kid,
376
+ dissents=dissents,
377
+ )
378
+ matches.append(m)
379
+ if winner == "tie":
380
+ pairwise[a][b] = "tie"
381
+ pairwise[b][a] = "tie"
382
+ else:
383
+ loser = b if winner == a else a
384
+ pairwise[winner][loser] = "win"
385
+ pairwise[loser][winner] = "loss"
386
+
387
+ # Self-cells = "-"
388
+ for o in options:
389
+ pairwise[o][o] = "-"
390
+
391
+ borda = _borda_counts(matches, options)
392
+ winner_borda = max(borda.items(), key=lambda kv: kv[1])[0]
393
+ winner_condorcet = _condorcet_winner(options, pairwise)
394
+ sensitivity = _sensitivity_pivots(options, matches, criteria, winner_borda)
395
+
396
+ summary = TournamentSummary(
397
+ winner_condorcet=winner_condorcet,
398
+ winner_borda=winner_borda,
399
+ borda_counts=borda,
400
+ pairwise_table=pairwise,
401
+ sensitivity_pivots=sensitivity,
402
+ n_options=len(options),
403
+ n_criteria=len(criteria),
404
+ )
405
+
406
+ elapsed = time.time() - started_at
407
+ trace = {
408
+ "mode": "decision-tournament",
409
+ "started_at": started_at,
410
+ "elapsed_s": round(elapsed, 3),
411
+ "n_agents": len(options) + len(criteria),
412
+ "n_questions": len(matches),
413
+ "question": question,
414
+ "options": options,
415
+ "criteria": [{"name": n, "weight": w} for n, w in criteria],
416
+ "matches": [asdict(m) for m in matches],
417
+ "summary": asdict(summary),
418
+ "collective_iq": {
419
+ "score": round(
420
+ max(borda.values()) / max(len(matches), 1),
421
+ 4,
422
+ ),
423
+ },
424
+ }
425
+
426
+ Path(args.trace).write_text(json.dumps(trace, indent=2, default=str))
427
+
428
+
429
+ # --- Registration ----------------------------------------------------------
430
+
431
+ SPEC = ModeSpec(
432
+ slug="decision-tournament",
433
+ label="Decision Tournament",
434
+ description=(
435
+ "Round-robin pairwise tournament across N options and weighted "
436
+ "criteria. Produces a Condorcet winner (when one exists), "
437
+ "Borda count ranking, and a sensitivity panel: 'if you weighted "
438
+ "criterion X higher, would the winner change?'"
439
+ ),
440
+ input_schema=INPUT_SCHEMA,
441
+ result_schema=RESULT_SCHEMA,
442
+ run_fn=_run_tournament,
443
+ uses_legacy_fields=False,
444
+ expected_roles=["scout", "sceptic", "synthesist", "mediator"],
445
+ )
446
+
447
+
448
+ register(SPEC)