hypermind 0.11.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hmctl/__init__.py +7 -0
- hmctl/cli.py +947 -0
- hypermind/__init__.py +208 -0
- hypermind/_deprecations.py +108 -0
- hypermind/agent.py +2896 -0
- hypermind/agent_card.py +127 -0
- hypermind/api/__init__.py +32 -0
- hypermind/api/app.py +686 -0
- hypermind/capability.py +923 -0
- hypermind/consult.py +493 -0
- hypermind/consult_bias.py +112 -0
- hypermind/contract_net.py +219 -0
- hypermind/crypto/__init__.py +21 -0
- hypermind/crypto/cose_encrypt.py +126 -0
- hypermind/crypto/dkg.py +246 -0
- hypermind/crypto/domain.py +59 -0
- hypermind/crypto/frost.py +771 -0
- hypermind/crypto/hashing.py +38 -0
- hypermind/crypto/hlc.py +163 -0
- hypermind/crypto/hpke.py +277 -0
- hypermind/crypto/kms.py +196 -0
- hypermind/crypto/kms_backends/__init__.py +56 -0
- hypermind/crypto/kms_backends/aws.py +128 -0
- hypermind/crypto/kms_backends/azure.py +114 -0
- hypermind/crypto/kms_backends/gcp.py +97 -0
- hypermind/crypto/kms_backends/vault.py +120 -0
- hypermind/crypto/namespace_root.py +204 -0
- hypermind/crypto/pq.py +152 -0
- hypermind/crypto/room_epoch.py +97 -0
- hypermind/crypto/signing.py +245 -0
- hypermind/deliberation.py +355 -0
- hypermind/did_web.py +256 -0
- hypermind/dispute.py +858 -0
- hypermind/errors.py +218 -0
- hypermind/eval/__init__.py +49 -0
- hypermind/eval/calibration.py +307 -0
- hypermind/eval/comparison.py +251 -0
- hypermind/eval/replay.py +202 -0
- hypermind/eval/swarm_iq.py +718 -0
- hypermind/knowledge/__init__.py +17 -0
- hypermind/knowledge/citation_graph.py +141 -0
- hypermind/knowledge/correlated_agreement.py +167 -0
- hypermind/knowledge/embedding_registry.py +115 -0
- hypermind/knowledge/kernel_store.py +197 -0
- hypermind/knowledge/rekor.py +173 -0
- hypermind/knowledge/reputation.py +364 -0
- hypermind/knowledge/swarm_memory.py +523 -0
- hypermind/knowledge/transparency.py +409 -0
- hypermind/mcp/__init__.py +60 -0
- hypermind/mcp/bridge.py +151 -0
- hypermind/mcp/client.py +142 -0
- hypermind/mcp/provenance.py +214 -0
- hypermind/mcp/relata_tools.py +152 -0
- hypermind/mcp/server.py +248 -0
- hypermind/mcp/transport.py +206 -0
- hypermind/mind/__init__.py +150 -0
- hypermind/mind/active.py +234 -0
- hypermind/mind/belief.py +369 -0
- hypermind/mind/deliberator.py +625 -0
- hypermind/mind/diversity.py +144 -0
- hypermind/mind/evolve.py +184 -0
- hypermind/mind/federation.py +154 -0
- hypermind/mind/goals.py +117 -0
- hypermind/mind/integration.py +190 -0
- hypermind/mind/mediator.py +74 -0
- hypermind/mind/memory_store.py +129 -0
- hypermind/mind/policy.py +307 -0
- hypermind/mind/records.py +260 -0
- hypermind/mind/roles.py +190 -0
- hypermind/mind/sybil_guard.py +41 -0
- hypermind/mind/world_model.py +166 -0
- hypermind/namespace.py +515 -0
- hypermind/namespace_policy.py +254 -0
- hypermind/observability/__init__.py +25 -0
- hypermind/observability/asgi.py +214 -0
- hypermind/observability/cost.py +277 -0
- hypermind/observability/otel_export.py +200 -0
- hypermind/observability/recorder.py +344 -0
- hypermind/observability/swarm_trace.py +438 -0
- hypermind/pin_policy.py +116 -0
- hypermind/py.typed +0 -0
- hypermind/responders/__init__.py +87 -0
- hypermind/responders/anthropic.py +159 -0
- hypermind/responders/base.py +162 -0
- hypermind/responders/openai.py +199 -0
- hypermind/responders/router.py +254 -0
- hypermind/responders/structured.py +287 -0
- hypermind/responders/tools.py +444 -0
- hypermind/revocation.py +270 -0
- hypermind/rule_ids.py +135 -0
- hypermind/schemas/encrypted_statement.cddl +28 -0
- hypermind/schemas/namespace_accept.cddl +20 -0
- hypermind/schemas/namespace_create.cddl +25 -0
- hypermind/schemas/namespace_founding_attest.cddl +30 -0
- hypermind/schemas/namespace_invite.cddl +22 -0
- hypermind/schemas/wire-v0.1.cddl +73 -0
- hypermind/simlab/__init__.py +15 -0
- hypermind/simlab/_persona_registry.py +93 -0
- hypermind/simlab/_scenario_impl.py +2778 -0
- hypermind/simlab/app.py +2538 -0
- hypermind/simlab/backends/__init__.py +27 -0
- hypermind/simlab/backends/anchor.py +88 -0
- hypermind/simlab/backends/local.py +32 -0
- hypermind/simlab/backends/server.py +79 -0
- hypermind/simlab/cli_command.py +108 -0
- hypermind/simlab/config.py +106 -0
- hypermind/simlab/deployment.py +26 -0
- hypermind/simlab/knowledge.py +716 -0
- hypermind/simlab/learning.py +295 -0
- hypermind/simlab/modes/__init__.py +115 -0
- hypermind/simlab/modes/_eval_real_llm.py +373 -0
- hypermind/simlab/modes/aar.py +611 -0
- hypermind/simlab/modes/backtest.py +610 -0
- hypermind/simlab/modes/binary_forecast.py +69 -0
- hypermind/simlab/modes/conformance.py +1869 -0
- hypermind/simlab/modes/evaluation.py +2271 -0
- hypermind/simlab/modes/governance.py +648 -0
- hypermind/simlab/modes/redteam.py +534 -0
- hypermind/simlab/modes/tabletop.py +797 -0
- hypermind/simlab/modes/tournament.py +448 -0
- hypermind/simlab/modes/whatif.py +523 -0
- hypermind/simlab/namespace.py +125 -0
- hypermind/simlab/personas.py +477 -0
- hypermind/simlab/prompt_generator.py +172 -0
- hypermind/simlab/question_sets/geopolitics_q20.json +122 -0
- hypermind/simlab/question_sets/governance_q5.json +67 -0
- hypermind/simlab/question_sets/msft_outlook_q10.json +62 -0
- hypermind/simlab/question_sets/whatif_q3.json +38 -0
- hypermind/simlab/registry.py +325 -0
- hypermind/simlab/runner.py +239 -0
- hypermind/simlab/scenario.py +147 -0
- hypermind/simlab/swarms.py +180 -0
- hypermind/simlab/tool_handlers/__init__.py +4 -0
- hypermind/simlab/tool_handlers/alpha_vantage.py +39 -0
- hypermind/simlab/tool_handlers/brave.py +46 -0
- hypermind/simlab/tool_handlers/newsapi.py +50 -0
- hypermind/simlab/tool_handlers/openweather.py +46 -0
- hypermind/simlab/tool_handlers/pinecone.py +52 -0
- hypermind/simlab/tool_handlers/qdrant.py +57 -0
- hypermind/simlab/tool_handlers/query_knowledge_base.py +21 -0
- hypermind/simlab/tool_handlers/relata_recall.py +61 -0
- hypermind/simlab/tool_handlers/retrieve_document.py +21 -0
- hypermind/simlab/tool_handlers/serper.py +46 -0
- hypermind/simlab/tool_handlers/tavily.py +53 -0
- hypermind/simlab/tool_handlers/weaviate.py +65 -0
- hypermind/simlab/tool_handlers/wikipedia.py +64 -0
- hypermind/simlab/tool_handlers/wolfram.py +48 -0
- hypermind/simlab/tool_handlers/yahoo_finance.py +49 -0
- hypermind/simlab/tool_registry.py +568 -0
- hypermind/simlab/topic_adapter.py +338 -0
- hypermind/simlab/topic_questions.py +259 -0
- hypermind/storage/__init__.py +69 -0
- hypermind/storage/backend.py +71 -0
- hypermind/storage/relata.py +245 -0
- hypermind/storage/sqlite.py +258 -0
- hypermind/sync.py +191 -0
- hypermind/tal.py +175 -0
- hypermind/tasks.py +334 -0
- hypermind/testing.py +16 -0
- hypermind/tools/__init__.py +32 -0
- hypermind/tools/registry.py +61 -0
- hypermind/transport/__init__.py +22 -0
- hypermind/transport/anti_entropy.py +708 -0
- hypermind/transport/bus.py +206 -0
- hypermind/transport/knows_delta.py +204 -0
- hypermind/transport/libp2p.py +298 -0
- hypermind/transport/placement.py +58 -0
- hypermind/transport/tcp.py +797 -0
- hypermind/transport/tls_profile.py +417 -0
- hypermind/types.py +59 -0
- hypermind/uncertainty.py +222 -0
- hypermind/wire.py +897 -0
- hypermind/workflow/__init__.py +72 -0
- hypermind/workflow/engine.py +655 -0
- hypermind/workflow/plan.py +182 -0
- hypermind/workflow/repair.py +203 -0
- hypermind-0.11.0.dist-info/METADATA +524 -0
- hypermind-0.11.0.dist-info/RECORD +181 -0
- hypermind-0.11.0.dist-info/WHEEL +4 -0
- hypermind-0.11.0.dist-info/entry_points.txt +2 -0
- hypermind-0.11.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,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
|