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,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)
|