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