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,718 @@
1
+ """SwarmIQ — collective intelligence metrics.
2
+
3
+ The five normative metrics from docs/spec/20-swarm-intelligence.md §20.1.
4
+
5
+ The single most important number is ``wisdom_of_crowds_delta`` — when
6
+ negative, the swarm consensus out-thinks its best individual member.
7
+ That is the headline claim "world-best swarm intelligence" rests on.
8
+
9
+ All metrics are pure functions over an immutable EvalSet. No agent
10
+ mutation, no I/O, deterministic.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Iterable, Sequence
16
+ from dataclasses import dataclass, field
17
+ from typing import Any
18
+
19
+ from hypermind.eval.calibration import brier_score
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Prediction:
24
+ """A single agent's prediction on one question.
25
+
26
+ ``confidence`` is the agent's probability that the answer is True.
27
+ ``citations`` (optional) lets the diversity-payoff metric compute
28
+ panel Jaccard overlap.
29
+ """
30
+
31
+ agent_kid: bytes
32
+ question_id: str
33
+ confidence: float
34
+ citations: tuple[bytes, ...] = ()
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class GroundTruth:
39
+ question_id: str
40
+ outcome: float # 0.0 or 1.0 in v0.9b; v1.x may admit fractional
41
+
42
+
43
+ @dataclass
44
+ class EvalSet:
45
+ """A frozen evaluation set for SwarmIQ scoring."""
46
+
47
+ predictions: list[Prediction]
48
+ truths: list[GroundTruth]
49
+ consensus_kind: str = "mean" # "mean" | "weighted_mean"
50
+
51
+ def truth_map(self) -> dict[str, float]:
52
+ return {t.question_id: t.outcome for t in self.truths}
53
+
54
+ def per_agent(self) -> dict[bytes, list[Prediction]]:
55
+ out: dict[bytes, list[Prediction]] = {}
56
+ for p in self.predictions:
57
+ out.setdefault(bytes(p.agent_kid), []).append(p)
58
+ return out
59
+
60
+ def per_question(self) -> dict[str, list[Prediction]]:
61
+ out: dict[str, list[Prediction]] = {}
62
+ for p in self.predictions:
63
+ out.setdefault(p.question_id, []).append(p)
64
+ return out
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Core scoring
69
+ # ---------------------------------------------------------------------------
70
+
71
+
72
+ def consensus_confidence(
73
+ predictions: Sequence[Prediction],
74
+ weights: dict[bytes, float] | None = None,
75
+ ) -> float:
76
+ """Aggregate per-question predictions into a single consensus probability."""
77
+ if not predictions:
78
+ return 0.5
79
+ if weights:
80
+ total_w = 0.0
81
+ weighted = 0.0
82
+ for p in predictions:
83
+ w = weights.get(bytes(p.agent_kid), 1.0)
84
+ weighted += w * p.confidence
85
+ total_w += w
86
+ return weighted / total_w if total_w else 0.5
87
+ return sum(p.confidence for p in predictions) / len(predictions)
88
+
89
+
90
+ def mean_brier_for_agent(eval_set: EvalSet, agent_kid: bytes) -> float | None:
91
+ truths = eval_set.truth_map()
92
+ rows = [p for p in eval_set.predictions if bytes(p.agent_kid) == bytes(agent_kid)]
93
+ if not rows:
94
+ return None
95
+ scored = [
96
+ brier_score(p.confidence, truths[p.question_id]) for p in rows if p.question_id in truths
97
+ ]
98
+ return sum(scored) / len(scored) if scored else None
99
+
100
+
101
+ def mean_brier_for_consensus(
102
+ eval_set: EvalSet,
103
+ weights: dict[bytes, float] | None = None,
104
+ ) -> float:
105
+ truths = eval_set.truth_map()
106
+ rows: list[float] = []
107
+ for qid, preds in eval_set.per_question().items():
108
+ if qid not in truths:
109
+ continue
110
+ c = consensus_confidence(preds, weights=weights)
111
+ rows.append(brier_score(c, truths[qid]))
112
+ if not rows:
113
+ return float("nan")
114
+ return sum(rows) / len(rows)
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # Reportable metrics
119
+ # ---------------------------------------------------------------------------
120
+
121
+
122
+ @dataclass
123
+ class SwarmIQReport:
124
+ """Stable JSON-serialisable report. CI compares against a baseline."""
125
+
126
+ namespace: str
127
+ n_agents: int
128
+ n_questions: int
129
+ wisdom_of_crowds_delta: float # consensus_brier − best_member_brier
130
+ consensus_brier: float
131
+ best_member_brier: float
132
+ worst_member_brier: float
133
+ mean_member_brier: float
134
+ marginal_agent_value: dict[str, float] = field(default_factory=dict)
135
+ diversity_payoff_curve: list[tuple[float, float]] = field(default_factory=list)
136
+ time_to_truth_ticks: float | None = None
137
+ adversarial_robustness_score: float | None = None
138
+ metadata: dict[str, Any] = field(default_factory=dict)
139
+
140
+ def to_dict(self) -> dict[str, Any]:
141
+ return {
142
+ "namespace": self.namespace,
143
+ "n_agents": self.n_agents,
144
+ "n_questions": self.n_questions,
145
+ "wisdom_of_crowds_delta": self.wisdom_of_crowds_delta,
146
+ "consensus_brier": self.consensus_brier,
147
+ "best_member_brier": self.best_member_brier,
148
+ "worst_member_brier": self.worst_member_brier,
149
+ "mean_member_brier": self.mean_member_brier,
150
+ "marginal_agent_value": dict(self.marginal_agent_value),
151
+ "diversity_payoff_curve": list(self.diversity_payoff_curve),
152
+ "time_to_truth_ticks": self.time_to_truth_ticks,
153
+ "adversarial_robustness_score": self.adversarial_robustness_score,
154
+ "metadata": dict(self.metadata),
155
+ }
156
+
157
+ def beats_best_member(self) -> bool:
158
+ return self.wisdom_of_crowds_delta < 0
159
+
160
+
161
+ def wisdom_of_crowds_delta(eval_set: EvalSet) -> tuple[float, float, float]:
162
+ """Returns (delta, consensus_brier, best_member_brier)."""
163
+ consensus = mean_brier_for_consensus(eval_set)
164
+ members = [mean_brier_for_agent(eval_set, kid) for kid in eval_set.per_agent()]
165
+ members = [m for m in members if m is not None]
166
+ if not members:
167
+ return float("nan"), consensus, float("nan")
168
+ best = min(members)
169
+ return (consensus - best), consensus, best
170
+
171
+
172
+ def marginal_agent_value(eval_set: EvalSet) -> dict[bytes, float]:
173
+ """Per-agent: change in consensus Brier when this agent is dropped.
174
+
175
+ Positive value ⇒ dropping this agent makes consensus *worse*
176
+ (i.e. the agent contributes positive value).
177
+ """
178
+ base = mean_brier_for_consensus(eval_set)
179
+ out: dict[bytes, float] = {}
180
+ for kid in eval_set.per_agent():
181
+ held_out = EvalSet(
182
+ predictions=[p for p in eval_set.predictions if bytes(p.agent_kid) != bytes(kid)],
183
+ truths=eval_set.truths,
184
+ consensus_kind=eval_set.consensus_kind,
185
+ )
186
+ if not held_out.predictions:
187
+ continue
188
+ delta = mean_brier_for_consensus(held_out) - base
189
+ out[bytes(kid)] = delta
190
+ return out
191
+
192
+
193
+ def diversity_payoff_curve(
194
+ eval_set: EvalSet,
195
+ bin_count: int = 10,
196
+ ) -> list[tuple[float, float]]:
197
+ """Per-question (mean panel Jaccard, consensus Brier).
198
+
199
+ Bin questions by their panel's mean pairwise Jaccard overlap;
200
+ return one (jaccard_bin_centre, mean_consensus_brier) per non-empty bin.
201
+ """
202
+ truths = eval_set.truth_map()
203
+ samples: list[tuple[float, float]] = []
204
+ for qid, preds in eval_set.per_question().items():
205
+ if qid not in truths:
206
+ continue
207
+ c = consensus_confidence(preds)
208
+ b = brier_score(c, truths[qid])
209
+ j = _mean_pairwise_jaccard([set(p.citations) for p in preds])
210
+ samples.append((j, b))
211
+ if not samples:
212
+ return []
213
+ bins: list[list[float]] = [[] for _ in range(bin_count)]
214
+ for j, b in samples:
215
+ idx = min(bin_count - 1, max(0, int(j * bin_count)))
216
+ bins[idx].append(b)
217
+ out: list[tuple[float, float]] = []
218
+ for i, bucket in enumerate(bins):
219
+ if not bucket:
220
+ continue
221
+ centre = (i + 0.5) / bin_count
222
+ out.append((centre, sum(bucket) / len(bucket)))
223
+ return out
224
+
225
+
226
+ def adversarial_robustness_score(
227
+ eval_set: EvalSet,
228
+ *,
229
+ byzantine_fraction: float = 0.2,
230
+ seed: int = 42,
231
+ ) -> float:
232
+ """Consensus Brier degradation when a fraction of agents flip predictions.
233
+
234
+ Returns ``Brier(byzantine) − Brier(honest)``. Lower (closer to 0) is
235
+ more robust. Pure deterministic — picks byzantine agents by seeded
236
+ sampling from the sorted-kid list. Identical seed ⇒ identical
237
+ byzantine set.
238
+ """
239
+ import random as _random
240
+
241
+ rng = _random.Random(seed)
242
+ base = mean_brier_for_consensus(eval_set)
243
+ agents = sorted(eval_set.per_agent(), key=bytes)
244
+ if not agents:
245
+ return 0.0
246
+ n_byz = max(1, round(byzantine_fraction * len(agents)))
247
+ byzantine = set(rng.sample(agents, n_byz))
248
+
249
+ poisoned: list[Prediction] = []
250
+ for p in eval_set.predictions:
251
+ if bytes(p.agent_kid) in byzantine:
252
+ poisoned.append(
253
+ Prediction(
254
+ agent_kid=p.agent_kid,
255
+ question_id=p.question_id,
256
+ confidence=1.0 - p.confidence,
257
+ citations=p.citations,
258
+ )
259
+ )
260
+ else:
261
+ poisoned.append(p)
262
+ poisoned_set = EvalSet(
263
+ predictions=poisoned,
264
+ truths=eval_set.truths,
265
+ consensus_kind=eval_set.consensus_kind,
266
+ )
267
+ return mean_brier_for_consensus(poisoned_set) - base
268
+
269
+
270
+ def time_to_truth_ticks(claim_to_resolution: Iterable[tuple[int, int]]) -> float | None:
271
+ """Average HLC-tick distance from first claim to oracle resolution.
272
+
273
+ Callers must supply (first_claim_hlc_ms, oracle_resolution_hlc_ms) pairs.
274
+ The simlab trace currently does not carry HLC timestamps inside
275
+ deliberation_records or oracle_resolutions — wiring that through (e.g.
276
+ `hlc_ms` on each round + on each oracle resolution) is a prerequisite
277
+ for trace-driven population. Returns None on empty input.
278
+ """
279
+ deltas = [r - c for c, r in claim_to_resolution if r >= c]
280
+ if not deltas:
281
+ return None
282
+ return sum(deltas) / len(deltas)
283
+
284
+
285
+ # ---------------------------------------------------------------------------
286
+ # Top-level scorer
287
+ # ---------------------------------------------------------------------------
288
+
289
+
290
+ def score(
291
+ eval_set: EvalSet,
292
+ *,
293
+ namespace: str = "default",
294
+ claim_to_resolution_ticks: Iterable[tuple[int, int]] = (),
295
+ byzantine_fraction: float = 0.2,
296
+ ) -> SwarmIQReport:
297
+ """Compute the full SwarmIQ report on an EvalSet."""
298
+ delta, consensus, best = wisdom_of_crowds_delta(eval_set)
299
+ members = [
300
+ m
301
+ for m in (mean_brier_for_agent(eval_set, kid) for kid in eval_set.per_agent())
302
+ if m is not None
303
+ ]
304
+ worst = max(members) if members else float("nan")
305
+ mean = sum(members) / len(members) if members else float("nan")
306
+ mav = marginal_agent_value(eval_set)
307
+ return SwarmIQReport(
308
+ namespace=namespace,
309
+ n_agents=len(eval_set.per_agent()),
310
+ n_questions=len(eval_set.per_question()),
311
+ wisdom_of_crowds_delta=delta,
312
+ consensus_brier=consensus,
313
+ best_member_brier=best,
314
+ worst_member_brier=worst,
315
+ mean_member_brier=mean,
316
+ marginal_agent_value={k.hex(): v for k, v in mav.items()},
317
+ diversity_payoff_curve=diversity_payoff_curve(eval_set),
318
+ time_to_truth_ticks=time_to_truth_ticks(claim_to_resolution_ticks),
319
+ adversarial_robustness_score=adversarial_robustness_score(
320
+ eval_set, byzantine_fraction=byzantine_fraction
321
+ ),
322
+ )
323
+
324
+
325
+ # ---------------------------------------------------------------------------
326
+ # Helpers
327
+ # ---------------------------------------------------------------------------
328
+
329
+
330
+ # ---------------------------------------------------------------------------
331
+ # Effective Information Gain per Question (EIG/Q) — v0.10
332
+ # ---------------------------------------------------------------------------
333
+ #
334
+ # Replaces the legacy ``CollectiveIQ.score`` (a weighted hodgepodge with
335
+ # no common units). EIG/Q measures realised information per question in
336
+ # bits, derived purely from fields already in ``trace.json``:
337
+ # - panel_records[q].mean_confidence (final consensus)
338
+ # - panel_records[q].ground_truth (oracle outcome, when present)
339
+ # - deliberation_records[q].rounds[0].mean (round-0 starting confidence)
340
+ #
341
+ # Formula (Pinsker upper-bound on residual entropy):
342
+ #
343
+ # EIG/Q = (1/Q) · Σ_q [ H_b(p̄_q^{round 0}) − 4·Brier(p̄_q^{final}, y_q) ]
344
+ #
345
+ # where H_b(p) = -p log2(p) - (1-p) log2(1-p) is the binary entropy.
346
+ #
347
+ # Properties:
348
+ # - Goes UP when the swarm started uncertain (high H_0) and ended close
349
+ # to truth (low Brier) — the only state that should count as "smarter".
350
+ # - Goes NEGATIVE when the swarm ended confidently wrong (4·Brier > H_0).
351
+ # This is the failure mode the legacy CIQ score *rewards* and EIG/Q
352
+ # correctly penalises.
353
+ # - Strictly proper, units in bits, no arbitrary weights.
354
+
355
+
356
+ def _bernoulli_entropy_bits(p: float) -> float:
357
+ """Binary entropy in bits, clamped to [0, 1] for numerical stability."""
358
+ import math
359
+
360
+ p = max(0.0, min(1.0, float(p)))
361
+ if p <= 0.0 or p >= 1.0:
362
+ return 0.0
363
+ return -(p * math.log2(p) + (1.0 - p) * math.log2(1.0 - p))
364
+
365
+
366
+ def effective_information_gain_per_question(
367
+ panel_records: Sequence[dict[str, Any]],
368
+ deliberation_records: Sequence[dict[str, Any]] | None = None,
369
+ ) -> dict[str, Any]:
370
+ """Compute EIG/Q from trace-shaped panel + deliberation records.
371
+
372
+ Each ``panel_records[i]`` should carry ``mean_confidence`` (final) and
373
+ optionally ``ground_truth`` (binary 0.0/1.0). When deliberation rounds
374
+ are available, ``deliberation_records[i].rounds[0].mean`` provides the
375
+ round-0 starting confidence; otherwise we fall back to a 0.5 prior
376
+ (which gives H_0 = 1.0 bit — the maximum-entropy assumption).
377
+
378
+ Returns a dict with::
379
+
380
+ {
381
+ "eig_per_q": [<bits>, ...], # one float per panel_record (NaN when no ground_truth)
382
+ "eig_mean": <float>, # mean over questions with ground_truth, NaN if none
383
+ "baseline_round0_h": [<bits>, ...], # the H_0 we used per q
384
+ "n_scored": <int>, # questions that had ground_truth + were scored
385
+ }
386
+ """
387
+ deliberation_records = list(deliberation_records or [])
388
+ # Index deliberation by query string (the join key panel_records uses).
389
+ delib_by_query: dict[str, dict[str, Any]] = {}
390
+ for d in deliberation_records:
391
+ q = d.get("query")
392
+ if isinstance(q, str):
393
+ delib_by_query[q] = d
394
+
395
+ eig_per_q: list[float] = []
396
+ h0_per_q: list[float] = []
397
+ scored: list[float] = []
398
+
399
+ for rec in panel_records:
400
+ final = rec.get("mean_confidence")
401
+ gt = rec.get("ground_truth")
402
+ query = rec.get("query")
403
+
404
+ # Round-0 mean: prefer deliberation R0; fall back to first round
405
+ # mean on this rec if present; else 0.5 (max entropy).
406
+ h0_p: float = 0.5
407
+ delib = delib_by_query.get(query) if isinstance(query, str) else None
408
+ if delib:
409
+ rounds = delib.get("rounds") or []
410
+ if rounds:
411
+ first = rounds[0]
412
+ m = first.get("mean")
413
+ if isinstance(m, (int, float)):
414
+ h0_p = float(m)
415
+ h0 = _bernoulli_entropy_bits(h0_p)
416
+ h0_per_q.append(h0)
417
+
418
+ # Need final confidence + ground truth to compute realised gain.
419
+ if final is None or gt is None:
420
+ eig_per_q.append(float("nan"))
421
+ continue
422
+ try:
423
+ f = float(final)
424
+ y = float(gt)
425
+ except (TypeError, ValueError):
426
+ eig_per_q.append(float("nan"))
427
+ continue
428
+ # Pinsker upper bound: 4·Brier ≥ H(post|truth) in bits (within a
429
+ # constant factor that we absorb into the bound).
430
+ residual_h_bound = 4.0 * brier_score(f, y)
431
+ eig = h0 - residual_h_bound
432
+ eig_per_q.append(eig)
433
+ scored.append(eig)
434
+
435
+ eig_mean = (sum(scored) / len(scored)) if scored else float("nan")
436
+ return {
437
+ "eig_per_q": eig_per_q,
438
+ "eig_mean": eig_mean,
439
+ "baseline_round0_h": h0_per_q,
440
+ "n_scored": len(scored),
441
+ }
442
+
443
+
444
+ # ---------------------------------------------------------------------------
445
+ # Role recommendations
446
+ # ---------------------------------------------------------------------------
447
+
448
+
449
+ @dataclass
450
+ class RoleAdjustment:
451
+ """A recommended role change for one agent, with rationale."""
452
+
453
+ agent_kid: str
454
+ current_role: str | None
455
+ recommended_role: str
456
+ confidence: float
457
+ trigger: str
458
+ reason: str
459
+
460
+
461
+ class SwarmIQ:
462
+ """Stateful wrapper around an EvalSet that adds role-recommendation logic.
463
+
464
+ Usage::
465
+
466
+ siq = SwarmIQ(eval_set, roles={"<kid_hex>": "Scout", ...})
467
+ adjustments = siq.recommend()
468
+
469
+ ``roles`` maps agent_kid hex-string → current role string (or None).
470
+ If omitted, all agents are treated as role-less.
471
+
472
+ ``recommend()`` returns an empty list when there is insufficient data
473
+ (< 3 agents with scored predictions, or < 3 oracle resolutions).
474
+ """
475
+
476
+ # Minimum data thresholds below which the analysis is unreliable.
477
+ _MIN_AGENTS = 3
478
+ _MIN_RESOLUTIONS = 3
479
+
480
+ def __init__(
481
+ self,
482
+ eval_set: EvalSet,
483
+ *,
484
+ roles: dict[str, str | None] | None = None,
485
+ ) -> None:
486
+ self._eval_set = eval_set
487
+ self._roles: dict[str, str | None] = dict(roles or {})
488
+
489
+ def _agent_brier(self) -> dict[bytes, float]:
490
+ out: dict[bytes, float] = {}
491
+ for kid in self._eval_set.per_agent():
492
+ b = mean_brier_for_agent(self._eval_set, kid)
493
+ if b is not None:
494
+ out[kid] = b
495
+ return out
496
+
497
+ def _mav(self) -> dict[bytes, float]:
498
+ return marginal_agent_value(self._eval_set)
499
+
500
+ def recommend(self) -> list[RoleAdjustment]:
501
+ """Return role-change recommendations based on swarm performance metrics.
502
+
503
+ Trigger checks applied in order (first match wins per agent):
504
+
505
+ 1. ``HIGH_BRIER_GENERALIST``: agent Brier ≥ median + 0.10 and no
506
+ current role → recommend ``Sceptic``.
507
+ 2. ``POSITIVE_MAV_UNDERUSED``: marginal agent value ≥ 0.05 and no
508
+ current role → recommend ``Scout``.
509
+ 3. ``NEGATIVE_MAV_DRAG``: marginal agent value ≤ -0.03 and current
510
+ role is ``Scout`` → recommend ``Sceptic``.
511
+
512
+ Confidence = 0.6 + min(0.3, |metric_delta| * 2). Only adjustments
513
+ with confidence ≥ 0.3 are emitted.
514
+ """
515
+ agent_briers = self._agent_brier()
516
+ scored_agents = list(agent_briers.keys())
517
+
518
+ # Insufficient data guard.
519
+ if len(scored_agents) < self._MIN_AGENTS:
520
+ return []
521
+ n_resolved = len(self._eval_set.truths)
522
+ if n_resolved < self._MIN_RESOLUTIONS:
523
+ return []
524
+
525
+ brier_values = list(agent_briers.values())
526
+ sorted_briers = sorted(brier_values)
527
+ mid = len(sorted_briers) // 2
528
+ if len(sorted_briers) % 2 == 1:
529
+ median_brier = sorted_briers[mid]
530
+ else:
531
+ median_brier = (sorted_briers[mid - 1] + sorted_briers[mid]) / 2.0
532
+
533
+ mav_map = self._mav()
534
+ adjustments: list[RoleAdjustment] = []
535
+
536
+ for kid in scored_agents:
537
+ kid_hex = kid.hex()
538
+ current_role = self._roles.get(kid_hex)
539
+
540
+ brier = agent_briers[kid]
541
+ mav_val = mav_map.get(kid, 0.0)
542
+
543
+ # --- Trigger 1: HIGH_BRIER_GENERALIST ---
544
+ brier_excess = brier - (median_brier + 0.10)
545
+ if brier_excess >= 0 and current_role is None:
546
+ delta = abs(brier_excess)
547
+ confidence = 0.6 + min(0.3, delta * 2)
548
+ if confidence >= 0.3:
549
+ adjustments.append(
550
+ RoleAdjustment(
551
+ agent_kid=kid_hex,
552
+ current_role=current_role,
553
+ recommended_role="Sceptic",
554
+ confidence=round(confidence, 6),
555
+ trigger="HIGH_BRIER_GENERALIST",
556
+ reason=(
557
+ f"Brier {brier:.4f} ≥ swarm median "
558
+ f"{median_brier:.4f} + 0.10; agent may "
559
+ "benefit from a sceptic framing to reduce overconfidence."
560
+ ),
561
+ )
562
+ )
563
+ continue
564
+
565
+ # --- Trigger 2: POSITIVE_MAV_UNDERUSED ---
566
+ if mav_val >= 0.05 and current_role is None:
567
+ delta = abs(mav_val)
568
+ confidence = 0.6 + min(0.3, delta * 2)
569
+ if confidence >= 0.3:
570
+ adjustments.append(
571
+ RoleAdjustment(
572
+ agent_kid=kid_hex,
573
+ current_role=current_role,
574
+ recommended_role="Scout",
575
+ confidence=round(confidence, 6),
576
+ trigger="POSITIVE_MAV_UNDERUSED",
577
+ reason=(
578
+ f"Marginal agent value {mav_val:.4f} ≥ 0.05; "
579
+ "agent contributes positively to consensus and "
580
+ "should be formalised as a Scout."
581
+ ),
582
+ )
583
+ )
584
+ continue
585
+
586
+ # --- Trigger 3: NEGATIVE_MAV_DRAG ---
587
+ if mav_val <= -0.03 and current_role == "Scout":
588
+ delta = abs(mav_val)
589
+ confidence = 0.6 + min(0.3, delta * 2)
590
+ if confidence >= 0.3:
591
+ adjustments.append(
592
+ RoleAdjustment(
593
+ agent_kid=kid_hex,
594
+ current_role=current_role,
595
+ recommended_role="Sceptic",
596
+ confidence=round(confidence, 6),
597
+ trigger="NEGATIVE_MAV_DRAG",
598
+ reason=(
599
+ f"Marginal agent value {mav_val:.4f} ≤ -0.03 "
600
+ "despite Scout role; agent is degrading swarm "
601
+ "consensus and should switch to Sceptic."
602
+ ),
603
+ )
604
+ )
605
+
606
+ return adjustments
607
+
608
+
609
+ @dataclass(frozen=True)
610
+ class ClusterScorecard:
611
+ """Cluster-level SwarmIQ scorecard (T6-07).
612
+
613
+ Aggregates diversity, coverage and calibration across a list of
614
+ agents — used by operators running multi-host federations who need a
615
+ single number per cluster (rather than per-agent) to chart over time.
616
+
617
+ * ``diversity`` — 1 − mean pairwise Jaccard over per-agent answered-
618
+ question sets. 1.0 means agents probe disjoint slices of the
619
+ question space; 0.0 means every agent answers the same questions.
620
+ * ``coverage`` — fraction of resolved questions for which at least
621
+ one agent in the cluster published a prediction.
622
+ * ``calibration`` — 1 − mean Brier across all (agent, question)
623
+ pairs that have an oracle truth. Higher = better calibrated.
624
+ * ``n_agents`` / ``n_questions_resolved`` — sample-size context so
625
+ operators can spot under-powered scorecards.
626
+ """
627
+
628
+ n_agents: int
629
+ n_questions_resolved: int
630
+ diversity: float
631
+ coverage: float
632
+ calibration: float
633
+
634
+ def to_dict(self) -> dict[str, Any]:
635
+ return {
636
+ "n_agents": self.n_agents,
637
+ "n_questions_resolved": self.n_questions_resolved,
638
+ "diversity": self.diversity,
639
+ "coverage": self.coverage,
640
+ "calibration": self.calibration,
641
+ }
642
+
643
+
644
+ def _cluster_score_impl(eval_set: EvalSet, agent_kids: Sequence[bytes]) -> ClusterScorecard:
645
+ kid_set = {bytes(k) for k in agent_kids}
646
+ if not kid_set:
647
+ return ClusterScorecard(0, 0, 0.0, 0.0, 0.0)
648
+
649
+ truth_map = eval_set.truth_map() # str question_id → 0/1 outcome
650
+ n_resolved = len(truth_map)
651
+
652
+ # Per-agent answered-question sets, restricted to the cluster.
653
+ per_agent = eval_set.per_agent() # mapping kid → list[Prediction]
654
+ answered_sets: list[set[str]] = []
655
+ answered_any: set[str] = set()
656
+ brier_sum = 0.0
657
+ brier_n = 0
658
+ for kid in kid_set:
659
+ preds = per_agent.get(kid, [])
660
+ qids = {p.question_id for p in preds}
661
+ answered_sets.append(qids)
662
+ answered_any |= qids
663
+ for p in preds:
664
+ truth = truth_map.get(p.question_id)
665
+ if truth is None:
666
+ continue
667
+ outcome = float(truth)
668
+ brier_sum += (p.confidence - outcome) ** 2
669
+ brier_n += 1
670
+
671
+ # Reuse the helper defined further down in this module via name lookup.
672
+ diversity = 1.0 - _mean_pairwise_jaccard(answered_sets) # type: ignore[arg-type]
673
+ coverage = len(answered_any & set(truth_map.keys())) / n_resolved if n_resolved else 0.0
674
+ calibration = 1.0 - (brier_sum / brier_n) if brier_n else 0.0
675
+ return ClusterScorecard(
676
+ n_agents=len(kid_set),
677
+ n_questions_resolved=n_resolved,
678
+ diversity=round(diversity, 6),
679
+ coverage=round(coverage, 6),
680
+ calibration=round(calibration, 6),
681
+ )
682
+
683
+
684
+ def _swarmiq_cluster_score(self: SwarmIQ, agents: Sequence[Any]) -> ClusterScorecard:
685
+ """Return a :class:`ClusterScorecard` aggregating ``agents`` (T6-07).
686
+
687
+ ``agents`` accepts either raw kid bytes or any object with a ``.kid``
688
+ attribute (e.g. :class:`HyperMindAgent`).
689
+ """
690
+ kids: list[bytes] = []
691
+ for a in agents:
692
+ if isinstance(a, (bytes, bytearray)):
693
+ kids.append(bytes(a))
694
+ elif hasattr(a, "kid"):
695
+ kids.append(bytes(a.kid))
696
+ else:
697
+ raise TypeError(f"cluster_score: cannot extract kid from {type(a)!r}")
698
+ return _cluster_score_impl(self._eval_set, kids)
699
+
700
+
701
+ SwarmIQ.cluster_score = _swarmiq_cluster_score # type: ignore[attr-defined]
702
+
703
+
704
+ def _mean_pairwise_jaccard(sets: Sequence[set[bytes]]) -> float:
705
+ sets = [s for s in sets if s]
706
+ if len(sets) < 2:
707
+ return 0.0
708
+ total = 0.0
709
+ n = 0
710
+ for i in range(len(sets)):
711
+ for j in range(i + 1, len(sets)):
712
+ a, b = sets[i], sets[j]
713
+ union = a | b
714
+ if not union:
715
+ continue
716
+ total += len(a & b) / len(union)
717
+ n += 1
718
+ return total / n if n else 0.0