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
hypermind/consult.py ADDED
@@ -0,0 +1,493 @@
1
+ """ConsultResult and routing strategies.
2
+
3
+ Routing strategies:
4
+ - "disagreement": route to the panel whose pairwise posterior
5
+ disagreement is maximal — well-defined without external prior, has
6
+ an information-theoretic interpretation (BALD-style).
7
+ - "calibration": route to historically best-calibrated agents.
8
+ - "random": uniform sample (sandbox/baseline).
9
+ - "stratified": stratify by reputation quantile to avoid mode collapse.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import math
15
+ import random
16
+ from collections.abc import Callable
17
+ from dataclasses import dataclass, field
18
+ from typing import Any, Literal
19
+
20
+ from hypermind.uncertainty import Uncertainty
21
+ from hypermind.wire import Kid
22
+
23
+ if False: # TYPE_CHECKING
24
+ from hypermind.responders.structured import StructuredResponse
25
+
26
+ RouteStrategyName = Literal["disagreement", "calibration", "random", "stratified"]
27
+ RouteStrategy = RouteStrategyName | Callable[..., list[bytes]]
28
+
29
+
30
+ @dataclass
31
+ class PanelMember:
32
+ agent_id: bytes
33
+ weight: float
34
+ posterior: Uncertainty
35
+ route_reason: str
36
+ #: Optional structured reasoning captured by LLM responders (v0.6+).
37
+ #: Bare ``Uncertainty``-only responders leave this as ``None``.
38
+ structured: StructuredResponse | None = None
39
+ #: Namespace id (hex string) of the panel member's home namespace.
40
+ #: ``None`` for same-namespace members; set for cross-namespace members.
41
+ namespace_id: str | None = None
42
+ #: Reputation score read from the member's home namespace at consult
43
+ #: time. Displayed in the synthesis payload for transparency; never
44
+ #: silently merged into the calling namespace's reputation kernel.
45
+ foreign_reputation_snapshot: float | None = None
46
+
47
+
48
+ @dataclass
49
+ class CalibTruthSnapshot:
50
+ """Selection-bias trap detector (Wei-Chen Cycle 2 §4).
51
+
52
+ Under a healthy roadmap, calibration improves and truth-coverage
53
+ rises. The dangerous regime is "calibration appears to improve
54
+ while truth-coverage shrinks" — the system gets better only on
55
+ easy claims that resolve.
56
+ """
57
+
58
+ calibration_brier: float
59
+ truth_coverage_ratio: float
60
+ warning: Literal["healthy", "selection_bias", "insufficient_resolution"] | None = None
61
+
62
+ @classmethod
63
+ def from_metrics(
64
+ cls,
65
+ calibration_brier: float,
66
+ truth_coverage_ratio: float,
67
+ prev_calibration: float | None = None,
68
+ prev_coverage: float | None = None,
69
+ ) -> CalibTruthSnapshot:
70
+ warning: Literal["healthy", "selection_bias", "insufficient_resolution"] | None = None
71
+ if truth_coverage_ratio < 0.4:
72
+ warning = "insufficient_resolution"
73
+ elif (
74
+ prev_calibration is not None
75
+ and prev_coverage is not None
76
+ and calibration_brier < prev_calibration # calibration improved
77
+ and truth_coverage_ratio < prev_coverage - 0.1 # but coverage shrank
78
+ ):
79
+ warning = "selection_bias"
80
+ else:
81
+ warning = "healthy"
82
+ return cls(
83
+ calibration_brier=calibration_brier,
84
+ truth_coverage_ratio=truth_coverage_ratio,
85
+ warning=warning,
86
+ )
87
+
88
+
89
+ ARGUMENT_EDGE_KINDS = ("supports", "rebuts", "undercuts", "qualifies")
90
+ """Toulmin / IBIS argument edge kinds (ROADMAP §0.3.5).
91
+
92
+ * ``supports`` — adds positive evidence to the dst claim.
93
+ * ``rebuts`` — disputes the dst claim's truth.
94
+ * ``undercuts`` — disputes the *warrant* (chain of inference) without
95
+ contradicting the dst directly.
96
+ * ``qualifies`` — narrows the dst's scope without rejecting it.
97
+ """
98
+
99
+ ArgumentEdgeKind = Literal["supports", "rebuts", "undercuts", "qualifies"]
100
+
101
+
102
+ @dataclass
103
+ class ArgumentEdge:
104
+ src_kid: Kid
105
+ dst_kid: Kid
106
+ edge_type: Literal["elicits", "asserts", "rebuts", "contradicts", "aggregates", "settles"]
107
+ weight: float = 1.0
108
+ #: Toulmin/IBIS-shaped kind. Optional for back-compat; the closed
109
+ #: set is :data:`ARGUMENT_EDGE_KINDS`. ``None`` means "not yet
110
+ #: classified" — older receipts that pre-date v0.3.5 round-trip
111
+ #: without modification.
112
+ kind: ArgumentEdgeKind | None = None
113
+
114
+ def __post_init__(self) -> None:
115
+ if self.kind is not None and self.kind not in ARGUMENT_EDGE_KINDS:
116
+ raise ValueError(
117
+ f"unknown argument edge kind {self.kind!r}; valid kinds: {ARGUMENT_EDGE_KINDS}"
118
+ )
119
+
120
+
121
+ @dataclass
122
+ class ArgumentDAG:
123
+ edges: list[ArgumentEdge] = field(default_factory=list)
124
+
125
+ def summary(self) -> dict[str, int]:
126
+ """Return a ``{kind: count}`` summary of edge kinds.
127
+
128
+ Surfaces support+rebut conflict so callers can see at a glance
129
+ whether the DAG has internal tension.
130
+ """
131
+ out: dict[str, int] = {k: 0 for k in ARGUMENT_EDGE_KINDS}
132
+ for e in self.edges:
133
+ if e.kind is not None:
134
+ out[e.kind] = out.get(e.kind, 0) + 1
135
+ return out
136
+
137
+ def has_conflict(self) -> bool:
138
+ """True iff the DAG carries both supports and rebuts edges."""
139
+ s = self.summary()
140
+ return s.get("supports", 0) > 0 and s.get("rebuts", 0) > 0
141
+
142
+
143
+ @dataclass
144
+ class ConsultResult:
145
+ query: str
146
+ posterior: Uncertainty
147
+ panel: list[PanelMember]
148
+ argument_dag: ArgumentDAG
149
+ disagreement: float # JS-divergence across panel posteriors
150
+ calibration_truth_axis: CalibTruthSnapshot
151
+ receipt_kid: Kid
152
+ panel_composition_hash: str
153
+ #: v0.6+ — list of structured reasoning records, one per panel member.
154
+ #: Empty when no LLM responder produced structured output.
155
+ structured_panel: list[StructuredResponse] = field(default_factory=list)
156
+ #: v0.6+ — full mixture of per-member posteriors (preserves dissent).
157
+ #: ``None`` when no panel members survived (shouldn't normally happen).
158
+ mixture: PosteriorMixture | None = None
159
+
160
+ @property
161
+ def is_high_disagreement(self) -> bool:
162
+ return self.disagreement > 0.5
163
+
164
+ def synthesize(
165
+ self,
166
+ *,
167
+ diversity_guard: Any | None = None,
168
+ ) -> dict[str, Any]:
169
+ """Return a synthesis payload, with T4-05 first-class diversity quorum.
170
+
171
+ When ``diversity_guard`` is supplied (a ``DiversityGuard``-shaped
172
+ object exposing ``check_quorum``), the payload carries
173
+ ``diversity_warning: bool`` indicating the quorum verdict and a
174
+ ``diversity_quorum`` block with the full result. Without a guard,
175
+ the payload omits these fields and behaviour is unchanged.
176
+ """
177
+ out: dict[str, Any] = {
178
+ "query": self.query,
179
+ "posterior_mean": self.posterior.mean(),
180
+ "panel_size": len(self.panel),
181
+ "disagreement": self.disagreement,
182
+ "panel_composition_hash": self.panel_composition_hash,
183
+ }
184
+ if diversity_guard is not None:
185
+ citations: list[list[bytes]] = []
186
+ for m in self.panel:
187
+ cs = getattr(m, "citations", None) or getattr(m, "parent_kids", None) or []
188
+ citations.append([bytes(c) for c in cs])
189
+
190
+ # Wrap citations in a member-shaped record.
191
+
192
+ class _M:
193
+ __slots__ = ("citations",)
194
+
195
+ def __init__(self, c: list[bytes]) -> None:
196
+ self.citations = c
197
+
198
+ quorum = diversity_guard.check_quorum([_M(c) for c in citations])
199
+ out["diversity_warning"] = not quorum.passed
200
+ out["diversity_quorum"] = quorum.as_dict()
201
+ return out
202
+
203
+
204
+ # ---- routing -----------------------------------------------------------
205
+
206
+
207
+ def select_panel(
208
+ candidates: list[bytes],
209
+ panel_size: int,
210
+ *,
211
+ strategy: RouteStrategy,
212
+ posterior_estimate: Callable[[bytes], Uncertainty] | None = None,
213
+ reputation: Callable[[bytes], float] | None = None,
214
+ rng: random.Random | None = None,
215
+ ) -> list[bytes]:
216
+ """Choose a panel from candidates per the requested strategy."""
217
+ rng = rng or random.Random()
218
+ if not candidates:
219
+ return []
220
+ panel_size = min(panel_size, len(candidates))
221
+
222
+ if callable(strategy):
223
+ return list(strategy(candidates, panel_size))
224
+
225
+ if strategy == "random":
226
+ return rng.sample(candidates, panel_size)
227
+
228
+ if strategy == "calibration":
229
+ if reputation is None:
230
+ return rng.sample(candidates, panel_size)
231
+ return sorted(candidates, key=reputation, reverse=True)[:panel_size]
232
+
233
+ if strategy == "stratified":
234
+ if reputation is None:
235
+ return rng.sample(candidates, panel_size)
236
+ # Bucket by quantile, sample one from each bucket.
237
+ sorted_by_rep = sorted(candidates, key=reputation, reverse=True)
238
+ bucket_size = max(1, len(sorted_by_rep) // panel_size)
239
+ return [
240
+ sorted_by_rep[i * bucket_size]
241
+ for i in range(panel_size)
242
+ if i * bucket_size < len(sorted_by_rep)
243
+ ]
244
+
245
+ if strategy == "disagreement":
246
+ if posterior_estimate is None:
247
+ return rng.sample(candidates, panel_size)
248
+ # Greedy: pick first by reputation (or random); then add the
249
+ # candidate that maximises mean pairwise JS-divergence with the
250
+ # current panel.
251
+ chosen: list[bytes] = []
252
+ if reputation is not None:
253
+ seed = max(candidates, key=reputation)
254
+ else:
255
+ seed = rng.choice(candidates)
256
+ chosen.append(seed)
257
+ remaining = [c for c in candidates if c != seed]
258
+ while len(chosen) < panel_size and remaining:
259
+ best = max(
260
+ remaining,
261
+ key=lambda c: sum(
262
+ _js_divergence(posterior_estimate(c), posterior_estimate(p)) for p in chosen
263
+ ),
264
+ )
265
+ chosen.append(best)
266
+ remaining.remove(best)
267
+ return chosen
268
+
269
+ raise ValueError(f"unknown route strategy {strategy!r}")
270
+
271
+
272
+ # ---- aggregation -------------------------------------------------------
273
+
274
+
275
+ def aggregate_panel(panel: list[PanelMember]) -> tuple[Uncertainty, float]:
276
+ """Reputation-weighted Beta aggregation + JS-divergence disagreement."""
277
+ if not panel:
278
+ raise ValueError("cannot aggregate empty panel")
279
+
280
+ # Weighted mean of Beta posteriors → Beta(weighted α, weighted β).
281
+ total_w = sum(p.weight for p in panel) or 1.0
282
+ alpha_sum, beta_sum = 0.0, 0.0
283
+ for p in panel:
284
+ if p.posterior.kind != "beta":
285
+ mean = p.posterior.mean()
286
+ alpha_sum += mean * p.weight
287
+ beta_sum += (1 - mean) * p.weight
288
+ else:
289
+ a, b = p.posterior.params
290
+ alpha_sum += a * (p.weight / total_w)
291
+ beta_sum += b * (p.weight / total_w)
292
+ aggregated = Uncertainty.beta(max(alpha_sum, 0.5), max(beta_sum, 0.5))
293
+
294
+ # Mean pairwise JS-divergence.
295
+ n = len(panel)
296
+ if n < 2:
297
+ return aggregated, 0.0
298
+ total_js = 0.0
299
+ pairs = 0
300
+ for i in range(n):
301
+ for j in range(i + 1, n):
302
+ total_js += _js_divergence(panel[i].posterior, panel[j].posterior)
303
+ pairs += 1
304
+ disagreement = total_js / max(pairs, 1)
305
+ return aggregated, max(0.0, min(disagreement, 1.0))
306
+
307
+
308
+ # ---- v0.6 dissent preservation -----------------------------------------
309
+
310
+
311
+ @dataclass(frozen=True)
312
+ class PosteriorMixture:
313
+ """Weighted mixture of per-member Beta posteriors.
314
+
315
+ The aggregator collapses bimodal panels into a single Beta. The
316
+ mixture preserves the full panel distribution so callers can
317
+ surface minority views, escalate on high-disagreement, or compute
318
+ arbitrary functionals (e.g., 10th-percentile scenario).
319
+
320
+ ``components`` is sorted descending by weight; weights are normalised
321
+ to sum to 1.0.
322
+ """
323
+
324
+ components: tuple[tuple[float, Uncertainty], ...]
325
+ majority_threshold: float = 0.5
326
+
327
+ @property
328
+ def n_components(self) -> int:
329
+ return len(self.components)
330
+
331
+ def majority_view(self) -> Uncertainty | None:
332
+ """Return the highest-weight component's Uncertainty."""
333
+ if not self.components:
334
+ return None
335
+ return self.components[0][1]
336
+
337
+ def minority_views(self) -> list[tuple[float, Uncertainty]]:
338
+ """Components whose cumulative weight is below ``majority_threshold``.
339
+
340
+ Iterates in weight-descending order; once cumulative weight
341
+ exceeds the threshold, all *remaining* components are minority.
342
+ """
343
+ if not self.components:
344
+ return []
345
+ cumulative = 0.0
346
+ out: list[tuple[float, Uncertainty]] = []
347
+ majority_passed = False
348
+ for w, u in self.components:
349
+ cumulative += w
350
+ if not majority_passed:
351
+ if cumulative >= self.majority_threshold:
352
+ majority_passed = True
353
+ continue
354
+ out.append((w, u))
355
+ return out
356
+
357
+ def to_dict(self) -> dict:
358
+ components_out = []
359
+ for w, u in self.components:
360
+ entry = {"weight": w, "posterior_mean": u.mean()}
361
+ if hasattr(u, "to_cbor"):
362
+ cbor = u.to_cbor()
363
+ # to_cbor returns a dict in this SDK; serialise as-is
364
+ entry["uncertainty"] = cbor if isinstance(cbor, dict) else None
365
+ components_out.append(entry)
366
+ return {
367
+ "n_components": self.n_components,
368
+ "components": components_out,
369
+ "majority_threshold": self.majority_threshold,
370
+ }
371
+
372
+
373
+ def build_posterior_mixture(
374
+ panel: list[PanelMember],
375
+ *,
376
+ majority_threshold: float = 0.5,
377
+ ) -> PosteriorMixture:
378
+ """Build a :class:`PosteriorMixture` from panel members.
379
+
380
+ Weights are normalised to sum to 1.0; components sorted by weight
381
+ descending. Members with non-positive weight (rare, but possible
382
+ when reputation × calibration is exactly 0) get a tiny floor so
383
+ they're surfaced rather than silently dropped.
384
+ """
385
+ if not panel:
386
+ return PosteriorMixture(components=(), majority_threshold=majority_threshold)
387
+ raw = [(max(m.weight, 1e-9), m.posterior) for m in panel]
388
+ total = sum(w for w, _ in raw)
389
+ normalised = [(w / total, u) for w, u in raw]
390
+ normalised.sort(key=lambda x: x[0], reverse=True)
391
+ return PosteriorMixture(
392
+ components=tuple(normalised),
393
+ majority_threshold=majority_threshold,
394
+ )
395
+
396
+
397
+ # ---- info-gain panel routing (ROADMAP §0.3.3) -------------------------
398
+
399
+
400
+ def _bernoulli_entropy(p: float) -> float:
401
+ """Binary entropy in nats. Bounded; numerically stable at the edges."""
402
+ p = max(min(p, 1 - 1e-12), 1e-12)
403
+ return -p * math.log(p) - (1 - p) * math.log(1 - p)
404
+
405
+
406
+ def _aggregate_mean(posteriors: list[Uncertainty], weights: list[float]) -> float:
407
+ if not posteriors:
408
+ return 0.5
409
+ total = sum(weights) or 1.0
410
+ return sum(p.mean() * w for p, w in zip(posteriors, weights, strict=False)) / total
411
+
412
+
413
+ class InfoGainRouter:
414
+ """Greedy expected-info-gain panel selector.
415
+
416
+ Selects panel members maximising the *expected predictive entropy
417
+ reduction* about the claim's truth. Concretely, the router picks
418
+ the candidate whose addition to the running panel most reduces the
419
+ aggregated posterior's binary entropy — agents with more peaked
420
+ estimates (high reputation, sharp posteriors) drive entropy down
421
+ fast, while uncertain candidates expand the predictive support.
422
+
423
+ The router is pure-Python and stateless; tests can drive it with
424
+ arbitrary posterior estimators.
425
+ """
426
+
427
+ def __init__(
428
+ self,
429
+ posterior_estimate: Callable[[bytes], Uncertainty],
430
+ weight: Callable[[bytes], float] | None = None,
431
+ ) -> None:
432
+ self._posterior = posterior_estimate
433
+ self._weight = weight or (lambda _kid: 1.0)
434
+
435
+ def select(self, candidates: list[bytes], k: int) -> list[bytes]:
436
+ """Return the top-k candidates maximising expected info gain."""
437
+ if k <= 0 or not candidates:
438
+ return []
439
+ if k == 1:
440
+ # Single-pick: choose the candidate with the lowest
441
+ # individual posterior entropy (sharpest belief).
442
+ return [
443
+ min(
444
+ candidates,
445
+ key=lambda c: _bernoulli_entropy(self._posterior(c).mean()),
446
+ )
447
+ ]
448
+
449
+ chosen: list[bytes] = []
450
+ remaining = list(candidates)
451
+ while len(chosen) < k and remaining:
452
+ current_post = [self._posterior(c) for c in chosen]
453
+ current_w = [self._weight(c) for c in chosen]
454
+ current_mean = _aggregate_mean(current_post, current_w) if chosen else 0.5
455
+ current_entropy = _bernoulli_entropy(current_mean)
456
+
457
+ def _gain(
458
+ c: bytes,
459
+ _post: list = current_post, # bind loop variable (B023)
460
+ _w: list = current_w, # bind loop variable (B023)
461
+ _entropy: float = current_entropy, # bind loop variable (B023)
462
+ ) -> float:
463
+ p = [*_post, self._posterior(c)]
464
+ w = [*_w, self._weight(c)]
465
+ new_mean = _aggregate_mean(p, w)
466
+ new_entropy = _bernoulli_entropy(new_mean)
467
+ # Expected info gain ≈ (_entropy - new_entropy) +
468
+ # epsilon * uncertainty_of_candidate so ties favour
469
+ # higher-uncertainty members (panel diversity).
470
+ cand_uncertainty = _bernoulli_entropy(self._posterior(c).mean())
471
+ return (_entropy - new_entropy) + 1e-3 * cand_uncertainty
472
+
473
+ best = max(remaining, key=_gain)
474
+ chosen.append(best)
475
+ remaining.remove(best)
476
+ return chosen
477
+
478
+
479
+ def _js_divergence(a: Uncertainty, b: Uncertainty) -> float:
480
+ """Jensen-Shannon divergence between two Bernoulli means.
481
+
482
+ Bounded in [0, 1] when using log base 2; we use natural log and
483
+ rescale to [0, 1] dividing by ln(2).
484
+ """
485
+ p = max(min(a.mean(), 1 - 1e-9), 1e-9)
486
+ q = max(min(b.mean(), 1 - 1e-9), 1e-9)
487
+ m = 0.5 * (p + q)
488
+
489
+ def _kl(x: float, y: float) -> float:
490
+ return x * math.log(x / y) + (1 - x) * math.log((1 - x) / (1 - y))
491
+
492
+ js = 0.5 * _kl(p, m) + 0.5 * _kl(q, m)
493
+ return max(0.0, min(js / math.log(2), 1.0))
@@ -0,0 +1,112 @@
1
+ """Selection-bias trap detector for consult panels (ROADMAP §0.3.6).
2
+
3
+ Compares an actual panel's composition against the registered
4
+ population distribution along one or more dimensions (e.g. ``org``,
5
+ ``topic_history``). KL-divergence above a configurable threshold
6
+ flags the panel as biased.
7
+
8
+ The detector is dimension-agnostic: callers pass a dict per panel
9
+ member describing their attributes, plus an expected distribution per
10
+ dimension. Any dimension that drifts beyond ``threshold`` produces a
11
+ :class:`BiasFinding`.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ from collections.abc import Mapping
18
+ from dataclasses import dataclass
19
+
20
+ EPS = 1e-9
21
+ DEFAULT_THRESHOLD = 0.5
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class BiasFinding:
26
+ dimension: str
27
+ kl_divergence: float
28
+ expected: dict[str, float]
29
+ observed: dict[str, float]
30
+ severity: str # "warning" | "critical"
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class PanelMemberAttrs:
35
+ """Minimal panel-member descriptor for the bias detector.
36
+
37
+ Real panel members carry more state; this dataclass extracts only
38
+ the dimensional attributes the detector needs.
39
+ """
40
+
41
+ agent_id: bytes
42
+ attributes: Mapping[str, str]
43
+
44
+
45
+ def _kl(p: Mapping[str, float], q: Mapping[str, float]) -> float:
46
+ """KL(P || Q) over the union of categories."""
47
+ keys = set(p) | set(q)
48
+ out = 0.0
49
+ for k in keys:
50
+ pk = max(p.get(k, 0.0), EPS)
51
+ qk = max(q.get(k, 0.0), EPS)
52
+ out += pk * math.log(pk / qk)
53
+ return out
54
+
55
+
56
+ def _normalise(d: Mapping[str, float]) -> dict[str, float]:
57
+ total = sum(max(0.0, v) for v in d.values())
58
+ if total <= 0:
59
+ return {k: 0.0 for k in d}
60
+ return {k: max(0.0, v) / total for k, v in d.items()}
61
+
62
+
63
+ def detect_selection_bias(
64
+ panel: list[PanelMemberAttrs],
65
+ population_stats: Mapping[str, Mapping[str, float]],
66
+ *,
67
+ threshold: float = DEFAULT_THRESHOLD,
68
+ critical_threshold: float = 1.5,
69
+ ) -> list[BiasFinding]:
70
+ """Compute per-dimension KL-divergence and emit findings above threshold.
71
+
72
+ Args:
73
+ panel: Actual panel members.
74
+ population_stats: ``{dimension: {category: expected_freq}}``.
75
+ threshold: KL-divergence above this emits a warning finding.
76
+ critical_threshold: KL-divergence above this escalates to
77
+ critical.
78
+
79
+ Returns:
80
+ Sorted list of :class:`BiasFinding`. Empty if the panel
81
+ composition is statistically indistinguishable from the
82
+ registered population on every dimension.
83
+ """
84
+ if not panel:
85
+ # Tiny / empty panel: no meaningful distribution comparison.
86
+ return []
87
+ findings: list[BiasFinding] = []
88
+ for dim, expected_raw in population_stats.items():
89
+ expected = _normalise(expected_raw)
90
+ observed_counts: dict[str, float] = {}
91
+ for m in panel:
92
+ v = m.attributes.get(dim)
93
+ if v is None:
94
+ continue
95
+ observed_counts[v] = observed_counts.get(v, 0.0) + 1.0
96
+ if not observed_counts:
97
+ continue
98
+ observed = _normalise(observed_counts)
99
+ kl = _kl(observed, expected)
100
+ if kl >= threshold:
101
+ severity = "critical" if kl >= critical_threshold else "warning"
102
+ findings.append(
103
+ BiasFinding(
104
+ dimension=dim,
105
+ kl_divergence=kl,
106
+ expected=dict(expected),
107
+ observed=observed,
108
+ severity=severity,
109
+ )
110
+ )
111
+ findings.sort(key=lambda f: -f.kl_divergence)
112
+ return findings