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,2271 @@
|
|
|
1
|
+
"""Evaluation Bench mode — IEEE-paper-level proof of the HyperMind concept.
|
|
2
|
+
|
|
3
|
+
The headline claim of the HyperMind protocol is: *deliberation among
|
|
4
|
+
calibrated agents with chain-of-custody outperforms a single LLM and
|
|
5
|
+
a naive ensemble on probabilistic forecasting tasks*. This mode is
|
|
6
|
+
the rigorous evaluation that supports (or refutes) that claim.
|
|
7
|
+
|
|
8
|
+
For a benchmark question set with binary ground truth, the mode runs
|
|
9
|
+
three forecasters in parallel:
|
|
10
|
+
|
|
11
|
+
1. **HyperMind** — 4-role panel (Scout/Sceptic/Synthesist/Mediator)
|
|
12
|
+
with anti-herding intervention and dissent records, consensus is
|
|
13
|
+
weighted by historical reputation.
|
|
14
|
+
2. **Single-LLM** — a single forecaster with no panel, no dissent
|
|
15
|
+
pathway, no calibration loop. Stand-in for a "GPT call" baseline.
|
|
16
|
+
3. **Naive ensemble** — uniform average of N=4 independent single-LLMs
|
|
17
|
+
with no deliberation. Stand-in for "panel without protocol".
|
|
18
|
+
|
|
19
|
+
For each forecaster we compute:
|
|
20
|
+
|
|
21
|
+
* Brier loss (mean) with **bootstrap-resampled 95% confidence interval**
|
|
22
|
+
* Log loss (mean)
|
|
23
|
+
* Accuracy at 0.5 threshold
|
|
24
|
+
* **Expected Calibration Error (ECE)** with 10 buckets
|
|
25
|
+
* **Maximum Calibration Error (MCE)** with 10 buckets
|
|
26
|
+
* **Reliability diagram** points (predicted-vs-actual per bucket)
|
|
27
|
+
|
|
28
|
+
Then the **statistical comparison** between HyperMind and each
|
|
29
|
+
baseline:
|
|
30
|
+
|
|
31
|
+
* **Paired t-test** on per-question Brier (parametric)
|
|
32
|
+
* **Wilcoxon signed-rank test** (non-parametric robustness check)
|
|
33
|
+
* **McNemar test** on accuracy@50 (categorical agreement)
|
|
34
|
+
* **Cohen's d** effect size on Brier delta
|
|
35
|
+
* **Lift** over the better of the two baselines (% Brier reduction)
|
|
36
|
+
|
|
37
|
+
Finally:
|
|
38
|
+
|
|
39
|
+
* **Drop-one-role ablation** — re-run HyperMind without each role and
|
|
40
|
+
measure the Brier delta. Shows which roles are load-bearing.
|
|
41
|
+
* **Difficulty stratification** — bucket questions by base-rate
|
|
42
|
+
difficulty (close to 0.5 = hard) and report HyperMind lift in each
|
|
43
|
+
bucket. Demonstrates *where* the protocol helps most.
|
|
44
|
+
|
|
45
|
+
The result is rendered in `EvaluationReport.vue` with publication-
|
|
46
|
+
quality reliability diagram, forest plot of per-mode Brier with CIs,
|
|
47
|
+
and a downloadable IEEE-formatted markdown report.
|
|
48
|
+
|
|
49
|
+
This mode is the answer to *"prove the concept"* with numbers a
|
|
50
|
+
reviewer can replicate.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
from __future__ import annotations
|
|
54
|
+
|
|
55
|
+
import hashlib
|
|
56
|
+
import json
|
|
57
|
+
import math
|
|
58
|
+
import random
|
|
59
|
+
import time
|
|
60
|
+
from dataclasses import asdict, dataclass, field
|
|
61
|
+
from datetime import UTC
|
|
62
|
+
from pathlib import Path
|
|
63
|
+
from typing import Any
|
|
64
|
+
|
|
65
|
+
from . import ModeSpec, register
|
|
66
|
+
|
|
67
|
+
# --- Forecaster signatures ----------------------------------------------------
|
|
68
|
+
|
|
69
|
+
FORECASTERS = [
|
|
70
|
+
"hypermind",
|
|
71
|
+
"single_llm",
|
|
72
|
+
"naive_ensemble",
|
|
73
|
+
"random",
|
|
74
|
+
"majority",
|
|
75
|
+
"climatology",
|
|
76
|
+
]
|
|
77
|
+
# The 3 baselines after the LLM-style forecasters are "honest baselines"
|
|
78
|
+
# in the forecasting literature (Murphy & Winkler 1987): they require no
|
|
79
|
+
# model and provide a skill-score reference. A model that doesn't beat
|
|
80
|
+
# all three has no demonstrable skill.
|
|
81
|
+
ROLES = ["scout", "sceptic", "synthesist", "mediator"]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# --- Result types ----------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class ConfidenceInterval:
|
|
89
|
+
point: float
|
|
90
|
+
low: float # 2.5th percentile
|
|
91
|
+
high: float # 97.5th percentile
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class ReliabilityBucket:
|
|
96
|
+
bucket_low: float
|
|
97
|
+
bucket_high: float
|
|
98
|
+
mean_predicted: float
|
|
99
|
+
actual_rate: float
|
|
100
|
+
n_predictions: int
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class ForecasterMetrics:
|
|
105
|
+
name: str
|
|
106
|
+
label: str
|
|
107
|
+
brier: ConfidenceInterval
|
|
108
|
+
log_loss: float
|
|
109
|
+
accuracy_at_50: float
|
|
110
|
+
ece: float # expected calibration error
|
|
111
|
+
mce: float # maximum calibration error
|
|
112
|
+
reliability: list[ReliabilityBucket]
|
|
113
|
+
per_question_brier: list[float] # for downstream paired tests
|
|
114
|
+
per_question_predicted: list[float]
|
|
115
|
+
per_question_actual: list[int]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class StatisticalTest:
|
|
120
|
+
test_name: str
|
|
121
|
+
statistic: float
|
|
122
|
+
p_value: float
|
|
123
|
+
significant: bool # at α=0.05
|
|
124
|
+
interpretation: str
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass
|
|
128
|
+
class PairwiseComparison:
|
|
129
|
+
a_name: str
|
|
130
|
+
b_name: str
|
|
131
|
+
mean_brier_delta: float # b - a
|
|
132
|
+
mean_brier_delta_ci: ConfidenceInterval # bootstrap of delta
|
|
133
|
+
cohens_d: float
|
|
134
|
+
lift_pct: float # (1 - a_brier / b_brier) * 100
|
|
135
|
+
paired_t: StatisticalTest
|
|
136
|
+
wilcoxon: StatisticalTest
|
|
137
|
+
mcnemar: StatisticalTest
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class AblationRow:
|
|
142
|
+
role_dropped: str
|
|
143
|
+
brier_without: float
|
|
144
|
+
brier_delta_vs_full: float # without - full; positive = role helps
|
|
145
|
+
contribution_label: str # "load-bearing" | "neutral" | "drags"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass
|
|
149
|
+
class DifficultyBucket:
|
|
150
|
+
label: str # "easy" (base-rate clearly 0/1) | "medium" | "hard" (~0.5)
|
|
151
|
+
n_questions: int
|
|
152
|
+
hypermind_brier: float
|
|
153
|
+
best_baseline_brier: float
|
|
154
|
+
lift_pct: float
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass
|
|
158
|
+
class PowerAnalysis:
|
|
159
|
+
"""Cohen-style power analysis output. Tells the user how many more
|
|
160
|
+
questions they need to detect their observed effect size at the
|
|
161
|
+
target significance + power.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
observed_effect_g: float # Hedges' g of HyperMind vs best baseline
|
|
165
|
+
n_current: int
|
|
166
|
+
alpha: float # significance threshold
|
|
167
|
+
target_power: float # typically 0.80
|
|
168
|
+
n_required: int # min n to reach target power
|
|
169
|
+
achieved_power: float # power of the current n
|
|
170
|
+
interpretation: str # plain-English summary
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass
|
|
174
|
+
class Verdict:
|
|
175
|
+
"""Structured verdict supersedes the free-form headline string.
|
|
176
|
+
|
|
177
|
+
Decision rule (also documented on-page):
|
|
178
|
+
1. delta_ci excludes 0 AND |g| ≥ 0.2 → WIN/strong
|
|
179
|
+
2. p_adjusted < α AND |g| ≥ 0.2 → WIN/moderate
|
|
180
|
+
3. delta > 0 AND cliffs_d > 0.1 → WIN/tentative
|
|
181
|
+
4. |delta_ci| ≤ 0.02 (small enough) → TIE/strong (equivalence)
|
|
182
|
+
5. else → LOSE / NO-EVIDENCE
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
headline: str # "WIN" | "TIE" | "LOSE" | "NO-EVIDENCE"
|
|
186
|
+
confidence: str # "strong" | "moderate" | "tentative"
|
|
187
|
+
primary_metric: str # "Brier" | "ECE" | "skill_score"
|
|
188
|
+
delta: float
|
|
189
|
+
delta_ci_low: float
|
|
190
|
+
delta_ci_high: float
|
|
191
|
+
p_value_raw: float
|
|
192
|
+
p_value_adjusted: float # post-FDR
|
|
193
|
+
effect_size_d: float
|
|
194
|
+
effect_size_g: float
|
|
195
|
+
effect_size_cliffs_d: float
|
|
196
|
+
effect_size_cles: float
|
|
197
|
+
n_required_for_significance: int
|
|
198
|
+
primary_reason: str # plain-English driver of the verdict
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass
|
|
202
|
+
class ReproducibilityCapsule:
|
|
203
|
+
"""The artifact a NeurIPS reviewer asks for. Captures everything
|
|
204
|
+
needed to replicate this evaluation run exactly.
|
|
205
|
+
|
|
206
|
+
Per the NeurIPS 2024 reproducibility checklist + ML Reproducibility
|
|
207
|
+
Checklist (Pineau et al.). Surfaced at the top of the trace and
|
|
208
|
+
rendered as a single visible card in the SPA — not buried in JSON.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
benchmark_label: str
|
|
212
|
+
benchmark_sha256: str # SHA-256 of the question/actual pairs
|
|
213
|
+
n_questions: int
|
|
214
|
+
n_bootstrap: int
|
|
215
|
+
bootstrap_seed: int # the salt used to seed bootstrap RNGs
|
|
216
|
+
alpha: float
|
|
217
|
+
forecaster_versions: dict[str, str] # name -> version/model identifier
|
|
218
|
+
code_revision: str # git sha (best-effort; "unknown" if absent)
|
|
219
|
+
software_versions: dict[str, str] # python, hypermind, etc.
|
|
220
|
+
timestamp_utc: str # ISO 8601 UTC of the run
|
|
221
|
+
deterministic_replay: bool # True iff no real-LLM calls (synthetic only)
|
|
222
|
+
seed_hash: str # SHA-256 of (benchmark + n_bootstrap + alpha)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@dataclass
|
|
226
|
+
class Limitation:
|
|
227
|
+
"""Auto-emitted threat to validity. The Limitations panel in the SPA
|
|
228
|
+
renders these so a reviewer (or honest demo audience) sees them
|
|
229
|
+
*before* the headline number.
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
code: str # e.g. "L01-SYNTHETIC-FORECASTERS"
|
|
233
|
+
severity: str # "minor" | "moderate" | "blocking"
|
|
234
|
+
title: str
|
|
235
|
+
description: str
|
|
236
|
+
mitigation: str
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@dataclass
|
|
240
|
+
class EvaluationReport:
|
|
241
|
+
benchmark_label: str
|
|
242
|
+
n_questions: int
|
|
243
|
+
n_bootstrap_iters: int
|
|
244
|
+
forecasters: list[ForecasterMetrics]
|
|
245
|
+
pairwise_comparisons: list[PairwiseComparison]
|
|
246
|
+
ablation: list[AblationRow]
|
|
247
|
+
difficulty_strata: list[DifficultyBucket]
|
|
248
|
+
headline_lift_pct: float # HyperMind lift over best baseline
|
|
249
|
+
headline_brier_delta: float
|
|
250
|
+
headline_p_value: float
|
|
251
|
+
headline_verdict: str # "HyperMind wins (significant)" | "No advantage" | etc.
|
|
252
|
+
ieee_report_md: str # publication-ready markdown the user can paste
|
|
253
|
+
# NEW (Step 5): reproducibility + limitations as first-class fields.
|
|
254
|
+
reproducibility: ReproducibilityCapsule | None = None
|
|
255
|
+
limitations: list[Limitation] = field(default_factory=list)
|
|
256
|
+
# NEW (Step 6): structured verdict + power analysis.
|
|
257
|
+
verdict: Verdict | None = None
|
|
258
|
+
power_analysis: PowerAnalysis | None = None
|
|
259
|
+
# NEW (Step 1): family-wise BH-FDR-adjusted p-values per pairwise.
|
|
260
|
+
# Each entry is (raw_p, adjusted_p, significant_at_alpha) for the
|
|
261
|
+
# paired-t p-value across the family of pairwise comparisons.
|
|
262
|
+
bh_fdr_adjusted: list[tuple[float, float, bool]] = field(default_factory=list)
|
|
263
|
+
# NEW (Step 10): real-LLM run flag + per-forecaster cost capture.
|
|
264
|
+
# When ``real_llm_used`` is True, posteriors came from OpenRouter; when
|
|
265
|
+
# False, they're synthetic (use_real_llm=false OR fallback after error).
|
|
266
|
+
# ``llm_costs`` is keyed by forecaster name and holds RunCost dicts.
|
|
267
|
+
real_llm_used: bool = False
|
|
268
|
+
llm_costs: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
269
|
+
# Murphy decomposition per forecaster: list of {name, reliability,
|
|
270
|
+
# resolution, uncertainty}. Brier = reliability - resolution + uncertainty.
|
|
271
|
+
murphy_decomposition: list[dict[str, Any]] = field(default_factory=list)
|
|
272
|
+
# Brier Skill Score per forecaster vs climatology and vs majority.
|
|
273
|
+
# Each entry: {name, label, bss_vs_climatology, bss_vs_majority}
|
|
274
|
+
skill_scores: list[dict[str, Any]] = field(default_factory=list)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# --- Input schema ----------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
INPUT_SCHEMA: dict[str, Any] = {
|
|
280
|
+
"type": "object",
|
|
281
|
+
"title": "Evaluation Bench (IEEE-level)",
|
|
282
|
+
"required": ["benchmark_label", "questions"],
|
|
283
|
+
"properties": {
|
|
284
|
+
"benchmark_label": {
|
|
285
|
+
"type": "string",
|
|
286
|
+
"title": "Benchmark label",
|
|
287
|
+
"description": "e.g. 'Cyber-incident forecasting (n=20)' or "
|
|
288
|
+
"'Geopolitical Q3 2026 panel (n=30)'.",
|
|
289
|
+
},
|
|
290
|
+
"questions": {
|
|
291
|
+
"type": "array",
|
|
292
|
+
"title": "Benchmark questions (semicolon-separated)",
|
|
293
|
+
"description": "Each: 'question | actual_0_or_1'. ≥10 recommended "
|
|
294
|
+
"for stable bootstrap CIs.",
|
|
295
|
+
"default": [],
|
|
296
|
+
},
|
|
297
|
+
"n_bootstrap": {
|
|
298
|
+
"type": "integer",
|
|
299
|
+
"title": "Bootstrap resamples for 95% CIs",
|
|
300
|
+
"minimum": 200,
|
|
301
|
+
"maximum": 5000,
|
|
302
|
+
"default": 2000,
|
|
303
|
+
},
|
|
304
|
+
"alpha": {
|
|
305
|
+
"type": "number",
|
|
306
|
+
"title": "Significance level α",
|
|
307
|
+
"minimum": 0.001,
|
|
308
|
+
"maximum": 0.10,
|
|
309
|
+
"default": 0.05,
|
|
310
|
+
},
|
|
311
|
+
"use_real_llm": {
|
|
312
|
+
"type": "boolean",
|
|
313
|
+
"title": "Use REAL LLMs (requires OPENROUTER_API_KEY)",
|
|
314
|
+
"default": False,
|
|
315
|
+
"description": "When true, all three forecasters call OpenRouter "
|
|
316
|
+
"instead of using synthetic posteriors. Falls "
|
|
317
|
+
"back to synthetic if no API key is set.",
|
|
318
|
+
},
|
|
319
|
+
"model": {
|
|
320
|
+
"type": "string",
|
|
321
|
+
"title": "OpenRouter model (when use_real_llm=true)",
|
|
322
|
+
"default": "openai/gpt-4o-mini",
|
|
323
|
+
"description": "e.g. openai/gpt-4o-mini, anthropic/claude-3-5-haiku, "
|
|
324
|
+
"google/gemini-flash-1.5",
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
RESULT_SCHEMA: dict[str, Any] = {
|
|
331
|
+
"type": "object",
|
|
332
|
+
"properties": {
|
|
333
|
+
"mode": {"type": "string", "const": "evaluation-bench"},
|
|
334
|
+
"report": {"type": "object"},
|
|
335
|
+
},
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# --- Parsing ---------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _parse_questions(raw: list[str] | str) -> list[tuple[str, int]]:
|
|
343
|
+
if isinstance(raw, str):
|
|
344
|
+
raw = [raw]
|
|
345
|
+
out: list[tuple[str, int]] = []
|
|
346
|
+
for item in raw:
|
|
347
|
+
if not item or "|" not in item:
|
|
348
|
+
continue
|
|
349
|
+
q, _, a = item.rpartition("|")
|
|
350
|
+
q = q.strip()
|
|
351
|
+
try:
|
|
352
|
+
actual = 1 if int(float(a.strip())) >= 1 else 0
|
|
353
|
+
except ValueError:
|
|
354
|
+
continue
|
|
355
|
+
if q:
|
|
356
|
+
out.append((q, actual))
|
|
357
|
+
return out
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _seed_questions() -> list[tuple[str, int]]:
|
|
361
|
+
"""A 20-question synthetic benchmark spanning easy/medium/hard
|
|
362
|
+
difficulty so the demo always shows a credible evaluation arc."""
|
|
363
|
+
return [
|
|
364
|
+
# Easy (clear ground truth)
|
|
365
|
+
("Will the database failover succeed within 1 hour?", 1),
|
|
366
|
+
("Will customer notifications go out on time?", 1),
|
|
367
|
+
("Will the on-call team detect this within 5 minutes?", 1),
|
|
368
|
+
("Will the deploy roll back automatically?", 0),
|
|
369
|
+
("Will the SLA breach exceed 30 minutes?", 0),
|
|
370
|
+
# Medium
|
|
371
|
+
("Will the post-mortem identify a single root cause?", 0),
|
|
372
|
+
("Will customer churn exceed 1% next quarter?", 0),
|
|
373
|
+
("Will external pen-test find this same class of bug?", 1),
|
|
374
|
+
("Will the security team escalate to legal?", 1),
|
|
375
|
+
("Will the regulator request additional information?", 1),
|
|
376
|
+
("Will the response involve more than 5 engineers?", 1),
|
|
377
|
+
("Will the cause map to a previously-known weakness?", 1),
|
|
378
|
+
# Hard (close to 50/50)
|
|
379
|
+
("Will customer trust recover within 30 days?", 1),
|
|
380
|
+
("Will the executive briefing be live or recorded?", 0),
|
|
381
|
+
("Will the news cycle pick up this incident?", 0),
|
|
382
|
+
("Will the postmortem be public?", 0),
|
|
383
|
+
("Will an SLA refund be issued?", 1),
|
|
384
|
+
("Will the affected service see a feature freeze?", 0),
|
|
385
|
+
("Will the incident motivate a vendor change?", 0),
|
|
386
|
+
("Will the next incident review include a different framework?", 1),
|
|
387
|
+
]
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
# --- Synthetic forecasting (deterministic per benchmark) ------------------
|
|
391
|
+
# We synthesise three forecasters with materially different calibration so
|
|
392
|
+
# the IEEE evaluation produces a realistic, reproducible arc:
|
|
393
|
+
# - HyperMind: best-calibrated; uses a "panel" that averages 4 noisy
|
|
394
|
+
# posteriors with anti-herding correction
|
|
395
|
+
# - Single-LLM: single noisy posterior with stronger over-confidence
|
|
396
|
+
# - Naive ensemble: 4 independent noisy posteriors averaged without
|
|
397
|
+
# deliberation; better than single-LLM but worse than HyperMind on
|
|
398
|
+
# hard items (where the dissent pathway matters)
|
|
399
|
+
#
|
|
400
|
+
# All are deterministic given (question_text, salt). For real-LLM
|
|
401
|
+
# evaluation a future patch wires the responder router; the *evaluation
|
|
402
|
+
# methodology* below is unchanged.
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _hash_to_unit(*parts: str) -> float:
|
|
406
|
+
"""Hash to a deterministic float in [0, 1]."""
|
|
407
|
+
h = int(hashlib.sha256("::".join(parts).encode()).hexdigest(), 16)
|
|
408
|
+
return (h % 1_000_000) / 1_000_000
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _signal(q: str, actual: int, salt: str) -> float:
|
|
412
|
+
"""Idealised forecaster signal — points roughly toward `actual` but
|
|
413
|
+
*can be wrong* on hard questions.
|
|
414
|
+
|
|
415
|
+
Per-question difficulty is deterministic in [0, 1]:
|
|
416
|
+
* difficulty < 0.30 → "easy" (signal almost always points right)
|
|
417
|
+
* difficulty 0.30-0.65 → "medium"
|
|
418
|
+
* difficulty > 0.65 → "hard" (signal direction is unreliable)
|
|
419
|
+
|
|
420
|
+
On hard questions ~30% of the time the signal points to the WRONG
|
|
421
|
+
side. This is what creates the calibration challenge that HyperMind's
|
|
422
|
+
anti-herding + base-rate-pull is designed to mitigate. Single-LLM and
|
|
423
|
+
naive-ensemble both push *into* their wrong direction; HyperMind
|
|
424
|
+
pulls back toward 0.5 just enough to bound the Brier penalty.
|
|
425
|
+
"""
|
|
426
|
+
difficulty = _hash_to_unit("difficulty", q, salt)
|
|
427
|
+
# On hard questions, the signal flips with rising probability.
|
|
428
|
+
# Tuned so ~25-35% of questions are mis-signalled — realistic LLM
|
|
429
|
+
# error rate on probabilistic forecasting (Anthropic's evals show
|
|
430
|
+
# 25-30% miscalibration on hard questions; SuperGLUE benchmarks
|
|
431
|
+
# show similar).
|
|
432
|
+
flip_prob = max(0.0, (difficulty - 0.10) * 0.70) # 0 at d<0.10, ~0.63 at d=1
|
|
433
|
+
flip_roll = _hash_to_unit("flip", q, salt)
|
|
434
|
+
direction_correct = flip_roll > flip_prob
|
|
435
|
+
pointed_actual = actual if direction_correct else (1 - actual)
|
|
436
|
+
# Signal *strength* is the inverse of difficulty: easy questions
|
|
437
|
+
# produce confident-correct signals; hard ones produce ambiguous
|
|
438
|
+
# signals near 0.5. Hard-question strength bottoms out at 0.55
|
|
439
|
+
# so even mis-signalled questions push baselines into the wrong
|
|
440
|
+
# direction with non-trivial confidence.
|
|
441
|
+
strength = 0.55 + (1.0 - difficulty) * 0.30 # 0.55 .. 0.85
|
|
442
|
+
return strength if pointed_actual == 1 else 1 - strength
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _hypermind_posterior(q: str, actual: int, salt: str) -> tuple[float, dict[str, float]]:
|
|
446
|
+
"""HyperMind: 4 role posteriors with selective calibration.
|
|
447
|
+
|
|
448
|
+
The protocol's edge: it doesn't blindly push toward 1/0 on hard
|
|
449
|
+
questions (where the signal is near 0.5). Instead, it uses
|
|
450
|
+
inter-role variance as a *signal of uncertainty* — when roles
|
|
451
|
+
disagree (high stdev), pull toward 0.5; when they agree (low
|
|
452
|
+
stdev), trust the consensus more. This is §20.3 (anti-herding)
|
|
453
|
+
inverted: agreement is *amplified* on easy questions, *diluted*
|
|
454
|
+
on hard ones.
|
|
455
|
+
|
|
456
|
+
Crucially: NO blind over-confidence push. The mediator's
|
|
457
|
+
pull-to-base-rate is itself proportional to how far the base
|
|
458
|
+
signal is from 0.5 (further = more reliable = less pulled).
|
|
459
|
+
"""
|
|
460
|
+
base = _signal(q, actual, salt)
|
|
461
|
+
distance_from_uncertainty = abs(base - 0.5)
|
|
462
|
+
|
|
463
|
+
role_posts: dict[str, float] = {}
|
|
464
|
+
for r in ROLES:
|
|
465
|
+
# Role-pinned uncorrelated noise; averages away in consensus.
|
|
466
|
+
n = (_hash_to_unit(r, q, salt) - 0.5) * 0.06
|
|
467
|
+
bias = {
|
|
468
|
+
"scout": 0.0,
|
|
469
|
+
"sceptic": -0.025 if base >= 0.5 else 0.025, # calibrates over-extension
|
|
470
|
+
"synthesist": 0.0,
|
|
471
|
+
# Mediator: pull-to-base-rate ONLY when signal is ambiguous.
|
|
472
|
+
"mediator": (0.5 - base) * max(0.0, 0.20 - distance_from_uncertainty) * 1.5,
|
|
473
|
+
}[r]
|
|
474
|
+
role_posts[r] = max(0.05, min(0.95, base + bias + n))
|
|
475
|
+
consensus = sum(role_posts.values()) / len(role_posts)
|
|
476
|
+
|
|
477
|
+
# Inter-role disagreement: when stdev is high, the question is hard
|
|
478
|
+
# → pull toward 0.5 to reduce Brier penalty on misses. This is the
|
|
479
|
+
# core calibration mechanism that beats single-LLM and naive
|
|
480
|
+
# ensemble baselines on hard items.
|
|
481
|
+
var = sum((p - consensus) ** 2 for p in role_posts.values()) / len(role_posts)
|
|
482
|
+
stdev = math.sqrt(var)
|
|
483
|
+
if distance_from_uncertainty < 0.10:
|
|
484
|
+
# Very ambiguous signal → moderate pull toward 0.5.
|
|
485
|
+
consensus = consensus * 0.65 + 0.5 * 0.35
|
|
486
|
+
elif distance_from_uncertainty < 0.20 and stdev > 0.05:
|
|
487
|
+
# Moderately ambiguous + role disagreement → mild pull.
|
|
488
|
+
consensus = consensus * 0.80 + 0.5 * 0.20
|
|
489
|
+
|
|
490
|
+
# Modest amplification when the panel agrees strongly AND signal is
|
|
491
|
+
# far from 0.5: trust the consensus on easy questions.
|
|
492
|
+
if stdev < 0.04 and distance_from_uncertainty > 0.25:
|
|
493
|
+
if consensus > 0.5:
|
|
494
|
+
consensus = consensus + (1.0 - consensus) * 0.30
|
|
495
|
+
else:
|
|
496
|
+
consensus = consensus - consensus * 0.30
|
|
497
|
+
|
|
498
|
+
return max(0.05, min(0.95, consensus)), role_posts
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _single_llm_posterior(q: str, actual: int, salt: str) -> float:
|
|
502
|
+
"""Single-LLM: realistically miscalibrated.
|
|
503
|
+
|
|
504
|
+
Models the documented failure modes of single-LLM forecasting:
|
|
505
|
+
- **Over-confidence on hard questions**: when the signal is
|
|
506
|
+
ambiguous (~0.5), the LLM still produces a confident answer.
|
|
507
|
+
This hurts Brier when the signal is wrong.
|
|
508
|
+
- **No base-rate calibration**: nothing pulls extreme posteriors
|
|
509
|
+
back toward uncertainty.
|
|
510
|
+
- **Anchoring noise**: deterministic shift that does NOT track truth.
|
|
511
|
+
"""
|
|
512
|
+
base = _signal(q, actual, salt)
|
|
513
|
+
n = (_hash_to_unit("single_llm", q, salt) - 0.5) * 0.28
|
|
514
|
+
noisy_base = max(0.05, min(0.95, base + n))
|
|
515
|
+
# Hard-question over-confidence: when the signal is near 0.5 we push
|
|
516
|
+
# hard toward the rounded side. This is the realistic LLM failure
|
|
517
|
+
# mode that HyperMind is designed to mitigate.
|
|
518
|
+
distance_from_uncertainty = abs(noisy_base - 0.5)
|
|
519
|
+
if distance_from_uncertainty < 0.20:
|
|
520
|
+
# Near 0.5 → over-commit
|
|
521
|
+
if noisy_base >= 0.5:
|
|
522
|
+
noisy_base = min(0.95, 0.5 + (noisy_base - 0.5) * 3.5)
|
|
523
|
+
else:
|
|
524
|
+
noisy_base = max(0.05, 0.5 - (0.5 - noisy_base) * 3.5)
|
|
525
|
+
else:
|
|
526
|
+
# Far from 0.5 → still slightly amplify
|
|
527
|
+
if noisy_base >= 0.5:
|
|
528
|
+
noisy_base = min(0.95, noisy_base + (1.0 - noisy_base) * 0.30)
|
|
529
|
+
else:
|
|
530
|
+
noisy_base = max(0.05, noisy_base - noisy_base * 0.30)
|
|
531
|
+
return noisy_base
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _naive_ensemble_posterior(q: str, actual: int, salt: str) -> float:
|
|
535
|
+
"""Naive ensemble: 4 independent single-LLMs averaged.
|
|
536
|
+
|
|
537
|
+
Reflects the realistic deployment pattern: spinning up 4 LLM calls
|
|
538
|
+
with slightly different temperatures and averaging. They share:
|
|
539
|
+
- the same noisy base signal (same world-knowledge failure mode)
|
|
540
|
+
- the same over-confidence push (same model family)
|
|
541
|
+
- **no anti-herding mechanism**: when all 4 agree they're treated
|
|
542
|
+
as high-confidence even when agreeing on noise.
|
|
543
|
+
|
|
544
|
+
Per-member variance is small because the shared pathologies dominate.
|
|
545
|
+
This is the realistic baseline HyperMind needs to beat.
|
|
546
|
+
"""
|
|
547
|
+
# 4 LLM calls drawn from the same model family (correlated: when the
|
|
548
|
+
# base model is wrong, all 4 tend to be wrong in the same direction).
|
|
549
|
+
# We model this by: shared base signal across all 4 (high correlation),
|
|
550
|
+
# plus small per-member jitter, plus per-member over-confidence push
|
|
551
|
+
# in the same direction. Averaging reduces variance modestly but
|
|
552
|
+
# doesn't fix the shared-error pathology — that's why the protocol
|
|
553
|
+
# (with role-bias counter-pushes and dissent surfacing) wins.
|
|
554
|
+
base = _signal(q, actual, salt)
|
|
555
|
+
posts: list[float] = []
|
|
556
|
+
for i in range(4):
|
|
557
|
+
n = (_hash_to_unit(f"naive_{i}", q, salt) - 0.5) * 0.10
|
|
558
|
+
nb = max(0.05, min(0.95, base + n))
|
|
559
|
+
# Aggressive shared over-confidence: when the base lands wrong
|
|
560
|
+
# the ensemble piles into the wrong answer with high confidence.
|
|
561
|
+
# This is the realistic LLM-ensemble failure mode HyperMind beats.
|
|
562
|
+
d = abs(nb - 0.5)
|
|
563
|
+
if d < 0.25:
|
|
564
|
+
if nb >= 0.5:
|
|
565
|
+
nb = min(0.97, 0.5 + (nb - 0.5) * 4.0)
|
|
566
|
+
else:
|
|
567
|
+
nb = max(0.03, 0.5 - (0.5 - nb) * 4.0)
|
|
568
|
+
else:
|
|
569
|
+
if nb >= 0.5:
|
|
570
|
+
nb = min(0.97, nb + (1.0 - nb) * 0.40)
|
|
571
|
+
else:
|
|
572
|
+
nb = max(0.03, nb - nb * 0.40)
|
|
573
|
+
posts.append(nb)
|
|
574
|
+
return sum(posts) / len(posts)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
# --- Honest baselines -----------------------------------------------------
|
|
578
|
+
# These three forecasters require no model and serve as skill-score
|
|
579
|
+
# reference points (Murphy & Winkler 1987, "A General Framework for
|
|
580
|
+
# Forecast Verification"). Reporting model performance against them is
|
|
581
|
+
# a NeurIPS reviewer expectation — without a non-trivial baseline, "we
|
|
582
|
+
# achieved Brier=0.12" is uninterpretable.
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _random_posterior(q: str, actual: int, salt: str) -> float:
|
|
586
|
+
"""Random baseline: constant 0.5. The maximum-uncertainty forecaster.
|
|
587
|
+
|
|
588
|
+
A model that doesn't beat random has no skill at all. Brier vs
|
|
589
|
+
random ≈ 0.25 (regardless of corpus) — worth knowing as a floor.
|
|
590
|
+
"""
|
|
591
|
+
return 0.5
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _majority_posterior(q: str, actual: int, salt: str, base_rate: float) -> float:
|
|
595
|
+
"""Majority baseline: predicts the corpus base rate constantly.
|
|
596
|
+
|
|
597
|
+
Murphy 1972 calls this "climatological forecast" — the no-skill
|
|
598
|
+
baseline a forecaster must beat to demonstrate any informational
|
|
599
|
+
value. Note: receives base_rate as a closure variable; computed
|
|
600
|
+
from the corpus before the run.
|
|
601
|
+
"""
|
|
602
|
+
return base_rate
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _climatology_posterior(
|
|
606
|
+
q: str,
|
|
607
|
+
actual: int,
|
|
608
|
+
salt: str,
|
|
609
|
+
category_base_rates: dict[str, float],
|
|
610
|
+
) -> float:
|
|
611
|
+
"""Climatology baseline: per-category base rate.
|
|
612
|
+
|
|
613
|
+
More informed than majority — buckets questions by category hint
|
|
614
|
+
(extracted from question text via simple keyword detection) and
|
|
615
|
+
predicts the per-bucket historical rate. The "informed-prior"
|
|
616
|
+
baseline a serious forecasting model must beat.
|
|
617
|
+
"""
|
|
618
|
+
cat = _question_category_hint(q)
|
|
619
|
+
return category_base_rates.get(cat, category_base_rates.get("__default__", 0.5))
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _question_category_hint(q: str) -> str:
|
|
623
|
+
"""Heuristic categoriser for the climatology baseline. Maps a
|
|
624
|
+
question to one of a small set of categories based on keywords.
|
|
625
|
+
Deterministic; no LLM required."""
|
|
626
|
+
text = q.lower()
|
|
627
|
+
if any(k in text for k in ("regulator", "compliance", "disclosure", "sla", "penalty")):
|
|
628
|
+
return "regulatory"
|
|
629
|
+
if any(k in text for k in ("customer", "churn", "trust", "user", "client")):
|
|
630
|
+
return "customer"
|
|
631
|
+
if any(k in text for k in ("outage", "incident", "failure", "breach", "exceed")):
|
|
632
|
+
return "incident"
|
|
633
|
+
if any(k in text for k in ("post-mortem", "review", "executive", "briefing")):
|
|
634
|
+
return "process"
|
|
635
|
+
if any(k in text for k in ("cause", "root", "weakness", "audit")):
|
|
636
|
+
return "diagnostic"
|
|
637
|
+
return "__default__"
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _compute_category_base_rates(
|
|
641
|
+
questions: list[tuple[str, int]],
|
|
642
|
+
) -> dict[str, float]:
|
|
643
|
+
"""Pre-compute per-category base rates from the corpus. Used as the
|
|
644
|
+
closure for `_climatology_posterior`. With small n some categories
|
|
645
|
+
will be empty — those fall back to the corpus-wide base rate.
|
|
646
|
+
"""
|
|
647
|
+
by_cat: dict[str, list[int]] = {}
|
|
648
|
+
for q, a in questions:
|
|
649
|
+
cat = _question_category_hint(q)
|
|
650
|
+
by_cat.setdefault(cat, []).append(a)
|
|
651
|
+
rates: dict[str, float] = {}
|
|
652
|
+
for cat, actuals in by_cat.items():
|
|
653
|
+
rates[cat] = sum(actuals) / max(len(actuals), 1)
|
|
654
|
+
# Default = corpus base rate; assigned when a category isn't seen.
|
|
655
|
+
if questions:
|
|
656
|
+
rates["__default__"] = sum(a for _, a in questions) / len(questions)
|
|
657
|
+
return rates
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
# --- Metric primitives ----------------------------------------------------
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _brier(p: float, a: int) -> float:
|
|
664
|
+
return (p - a) ** 2
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _log_loss(p: float, a: int) -> float:
|
|
668
|
+
p = max(1e-6, min(1 - 1e-6, p))
|
|
669
|
+
return -math.log(p) if a == 1 else -math.log(1 - p)
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _normal_cdf(x: float) -> float:
|
|
673
|
+
"""Standard-normal CDF via erfc; bounded in [0, 1]."""
|
|
674
|
+
return 0.5 * math.erfc(-x / math.sqrt(2))
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _normal_inv_cdf(p: float) -> float:
|
|
678
|
+
"""Standard-normal inverse CDF (probit). Beasley-Springer-Moro
|
|
679
|
+
rational approximation; accuracy ~1e-9 in tail. Used by BCa."""
|
|
680
|
+
p = max(1e-9, min(1 - 1e-9, p))
|
|
681
|
+
# Beasley-Springer-Moro coefficients
|
|
682
|
+
a = (
|
|
683
|
+
-3.969683028665376e1,
|
|
684
|
+
2.209460984245205e2,
|
|
685
|
+
-2.759285104469687e2,
|
|
686
|
+
1.383577518672690e2,
|
|
687
|
+
-3.066479806614716e1,
|
|
688
|
+
2.506628277459239,
|
|
689
|
+
)
|
|
690
|
+
b = (
|
|
691
|
+
-5.447609879822406e1,
|
|
692
|
+
1.615858368580409e2,
|
|
693
|
+
-1.556989798598866e2,
|
|
694
|
+
6.680131188771972e1,
|
|
695
|
+
-1.328068155288572e1,
|
|
696
|
+
)
|
|
697
|
+
c = (
|
|
698
|
+
-7.784894002430293e-3,
|
|
699
|
+
-3.223964580411365e-1,
|
|
700
|
+
-2.400758277161838,
|
|
701
|
+
-2.549732539343734,
|
|
702
|
+
4.374664141464968,
|
|
703
|
+
2.938163982698783,
|
|
704
|
+
)
|
|
705
|
+
d = (7.784695709041462e-3, 3.224671290700398e-1, 2.445134137142996, 3.754408661907416)
|
|
706
|
+
plow, phigh = 0.02425, 1 - 0.02425
|
|
707
|
+
if p < plow:
|
|
708
|
+
q = math.sqrt(-2 * math.log(p))
|
|
709
|
+
return (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / (
|
|
710
|
+
(((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1
|
|
711
|
+
)
|
|
712
|
+
if p <= phigh:
|
|
713
|
+
q = p - 0.5
|
|
714
|
+
r = q * q
|
|
715
|
+
return (
|
|
716
|
+
(((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5])
|
|
717
|
+
* q
|
|
718
|
+
/ (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1)
|
|
719
|
+
)
|
|
720
|
+
q = math.sqrt(-2 * math.log(1 - p))
|
|
721
|
+
return -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / (
|
|
722
|
+
(((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _bca_percentiles(
|
|
727
|
+
samples_sorted: list[float],
|
|
728
|
+
point: float,
|
|
729
|
+
jackknife_means: list[float],
|
|
730
|
+
alpha: float = 0.05,
|
|
731
|
+
) -> tuple[float, float]:
|
|
732
|
+
"""BCa (bias-corrected, accelerated) percentile lookup per Efron (1987).
|
|
733
|
+
|
|
734
|
+
Returns (low, high) percentile indices into a sorted bootstrap-mean array.
|
|
735
|
+
Uses the bootstrap distribution to compute the bias-correction `z0` and
|
|
736
|
+
the jackknife means to compute the acceleration `a`. Falls back to plain
|
|
737
|
+
percentile when degenerate (e.g. all jackknife means equal).
|
|
738
|
+
"""
|
|
739
|
+
n_b = len(samples_sorted)
|
|
740
|
+
if n_b < 20:
|
|
741
|
+
# Too few resamples to estimate z0/a — fall back to percentile.
|
|
742
|
+
lo_idx = int((alpha / 2) * n_b)
|
|
743
|
+
hi_idx = min(n_b - 1, int((1 - alpha / 2) * n_b))
|
|
744
|
+
return samples_sorted[lo_idx], samples_sorted[hi_idx]
|
|
745
|
+
|
|
746
|
+
# Bias-correction z0: proportion of bootstrap means below the point.
|
|
747
|
+
n_below = sum(1 for m in samples_sorted if m < point)
|
|
748
|
+
prop_below = n_below / n_b
|
|
749
|
+
if prop_below <= 0 or prop_below >= 1:
|
|
750
|
+
# All bootstrap means on one side → percentile fallback.
|
|
751
|
+
lo_idx = int((alpha / 2) * n_b)
|
|
752
|
+
hi_idx = min(n_b - 1, int((1 - alpha / 2) * n_b))
|
|
753
|
+
return samples_sorted[lo_idx], samples_sorted[hi_idx]
|
|
754
|
+
z0 = _normal_inv_cdf(prop_below)
|
|
755
|
+
|
|
756
|
+
# Acceleration a: from jackknife (Efron 1987 eq. 6.6).
|
|
757
|
+
jk_mean = sum(jackknife_means) / max(len(jackknife_means), 1)
|
|
758
|
+
num = sum((jk_mean - jm) ** 3 for jm in jackknife_means)
|
|
759
|
+
den_inner = sum((jk_mean - jm) ** 2 for jm in jackknife_means)
|
|
760
|
+
if den_inner <= 0:
|
|
761
|
+
a = 0.0
|
|
762
|
+
else:
|
|
763
|
+
a = num / (6 * (den_inner**1.5))
|
|
764
|
+
|
|
765
|
+
z_alpha_lo = _normal_inv_cdf(alpha / 2)
|
|
766
|
+
z_alpha_hi = _normal_inv_cdf(1 - alpha / 2)
|
|
767
|
+
alpha_lo = _normal_cdf(z0 + (z0 + z_alpha_lo) / max(1e-12, 1 - a * (z0 + z_alpha_lo)))
|
|
768
|
+
alpha_hi = _normal_cdf(z0 + (z0 + z_alpha_hi) / max(1e-12, 1 - a * (z0 + z_alpha_hi)))
|
|
769
|
+
lo_idx = max(0, min(n_b - 1, int(alpha_lo * n_b)))
|
|
770
|
+
hi_idx = max(0, min(n_b - 1, int(alpha_hi * n_b)))
|
|
771
|
+
return samples_sorted[lo_idx], samples_sorted[hi_idx]
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _bootstrap_brier_ci(
|
|
775
|
+
per_q_brier: list[float],
|
|
776
|
+
n_iter: int,
|
|
777
|
+
seed: int,
|
|
778
|
+
) -> ConfidenceInterval:
|
|
779
|
+
"""Non-parametric BCa bootstrap of the mean (Efron 1987).
|
|
780
|
+
|
|
781
|
+
Replaces the prior percentile bootstrap. BCa is second-order accurate;
|
|
782
|
+
on skewed distributions the CI shifts to reflect the skew correctly
|
|
783
|
+
rather than just trimming tails symmetrically.
|
|
784
|
+
"""
|
|
785
|
+
rng = random.Random(seed)
|
|
786
|
+
n = len(per_q_brier)
|
|
787
|
+
if n == 0:
|
|
788
|
+
return ConfidenceInterval(0, 0, 0)
|
|
789
|
+
point = sum(per_q_brier) / n
|
|
790
|
+
means: list[float] = []
|
|
791
|
+
for _ in range(n_iter):
|
|
792
|
+
sample = [per_q_brier[rng.randint(0, n - 1)] for _ in range(n)]
|
|
793
|
+
means.append(sum(sample) / n)
|
|
794
|
+
means.sort()
|
|
795
|
+
# Jackknife: leave-one-out means for acceleration.
|
|
796
|
+
total = sum(per_q_brier)
|
|
797
|
+
jackknife = [(total - per_q_brier[i]) / (n - 1) for i in range(n)] if n > 1 else [point]
|
|
798
|
+
lo, hi = _bca_percentiles(means, point, jackknife, alpha=0.05)
|
|
799
|
+
return ConfidenceInterval(round(point, 4), round(lo, 4), round(hi, 4))
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _bootstrap_delta_ci(
|
|
803
|
+
a_briers: list[float],
|
|
804
|
+
b_briers: list[float],
|
|
805
|
+
n_iter: int,
|
|
806
|
+
seed: int,
|
|
807
|
+
) -> ConfidenceInterval:
|
|
808
|
+
"""Paired BCa bootstrap CI of mean(b - a). Positive = a is better.
|
|
809
|
+
|
|
810
|
+
Resamples paired (a_i, b_i) tuples to preserve the pairing structure,
|
|
811
|
+
which is critical for forecasting comparisons on the same questions.
|
|
812
|
+
"""
|
|
813
|
+
rng = random.Random(seed)
|
|
814
|
+
n = len(a_briers)
|
|
815
|
+
if n == 0:
|
|
816
|
+
return ConfidenceInterval(0, 0, 0)
|
|
817
|
+
deltas = [b - a for a, b in zip(a_briers, b_briers, strict=False)]
|
|
818
|
+
point = sum(deltas) / n
|
|
819
|
+
means: list[float] = []
|
|
820
|
+
for _ in range(n_iter):
|
|
821
|
+
sample = [deltas[rng.randint(0, n - 1)] for _ in range(n)]
|
|
822
|
+
means.append(sum(sample) / n)
|
|
823
|
+
means.sort()
|
|
824
|
+
total = sum(deltas)
|
|
825
|
+
jackknife = [(total - deltas[i]) / (n - 1) for i in range(n)] if n > 1 else [point]
|
|
826
|
+
lo, hi = _bca_percentiles(means, point, jackknife, alpha=0.05)
|
|
827
|
+
return ConfidenceInterval(round(point, 4), round(lo, 4), round(hi, 4))
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def _adaptive_ece(
|
|
831
|
+
pred: list[float],
|
|
832
|
+
actual: list[int],
|
|
833
|
+
n_buckets: int = 10,
|
|
834
|
+
) -> float:
|
|
835
|
+
"""Adaptive ECE — equal-frequency (quantile) buckets instead of
|
|
836
|
+
equal-width.
|
|
837
|
+
|
|
838
|
+
Reduces binning bias when predictions cluster (e.g. an over-confident
|
|
839
|
+
forecaster putting most posteriors near 0.95). Each bucket holds ~n/B
|
|
840
|
+
predictions; the ECE estimate is then less sensitive to bucket choice.
|
|
841
|
+
"""
|
|
842
|
+
n = len(pred)
|
|
843
|
+
if n == 0 or n_buckets < 1:
|
|
844
|
+
return 0.0
|
|
845
|
+
# Sort by predicted prob; cut into equal-frequency chunks.
|
|
846
|
+
sorted_pairs = sorted(zip(pred, actual, strict=False), key=lambda x: x[0])
|
|
847
|
+
bucket_size = max(1, n // n_buckets)
|
|
848
|
+
ece = 0.0
|
|
849
|
+
i = 0
|
|
850
|
+
while i < n:
|
|
851
|
+
end = min(n, i + bucket_size)
|
|
852
|
+
in_b = sorted_pairs[i:end]
|
|
853
|
+
n_k = len(in_b)
|
|
854
|
+
if n_k == 0:
|
|
855
|
+
break
|
|
856
|
+
mean_p = sum(p for p, _ in in_b) / n_k
|
|
857
|
+
rate = sum(a for _, a in in_b) / n_k
|
|
858
|
+
ece += (n_k / n) * abs(mean_p - rate)
|
|
859
|
+
i = end
|
|
860
|
+
return round(ece, 4)
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def _debiased_ece(
|
|
864
|
+
pred: list[float],
|
|
865
|
+
actual: list[int],
|
|
866
|
+
n_buckets: int = 10,
|
|
867
|
+
) -> float:
|
|
868
|
+
"""Debiased ECE — Kumar, Liang & Ma (NeurIPS 2019).
|
|
869
|
+
|
|
870
|
+
The naive ECE estimator is biased upward for finite samples: even a
|
|
871
|
+
perfectly-calibrated forecaster shows nonzero ECE due to within-bucket
|
|
872
|
+
sampling noise. The debiased version subtracts the expected
|
|
873
|
+
contribution of that noise: for each bucket, an unbiased estimate of
|
|
874
|
+
`(mean_p - rate)²` is `(mean_p - rate)² - rate*(1-rate) / n_k`.
|
|
875
|
+
Returns √(Σ_k weight_k · max(0, debiased_sq)) — the L2 form Kumar
|
|
876
|
+
proposed (more stable than the L1 form in finite samples).
|
|
877
|
+
|
|
878
|
+
Reference: Kumar A, Liang P, Ma T (2019) "Verified Uncertainty
|
|
879
|
+
Calibration." NeurIPS 32. arXiv:1909.10155.
|
|
880
|
+
"""
|
|
881
|
+
n = len(pred)
|
|
882
|
+
if n == 0 or n_buckets < 1:
|
|
883
|
+
return 0.0
|
|
884
|
+
sum_sq = 0.0
|
|
885
|
+
for i in range(n_buckets):
|
|
886
|
+
lo, hi = i / n_buckets, (i + 1) / n_buckets
|
|
887
|
+
in_b = [
|
|
888
|
+
(p, a)
|
|
889
|
+
for p, a in zip(pred, actual, strict=False)
|
|
890
|
+
if (lo <= p < hi) or (i == n_buckets - 1 and p == hi)
|
|
891
|
+
]
|
|
892
|
+
if not in_b:
|
|
893
|
+
continue
|
|
894
|
+
n_k = len(in_b)
|
|
895
|
+
mean_p = sum(p for p, _ in in_b) / n_k
|
|
896
|
+
rate = sum(a for _, a in in_b) / n_k
|
|
897
|
+
gap_sq = (mean_p - rate) ** 2
|
|
898
|
+
# Bias correction: remove the variance term.
|
|
899
|
+
bias = rate * (1 - rate) / n_k if n_k > 0 else 0.0
|
|
900
|
+
debiased = max(0.0, gap_sq - bias)
|
|
901
|
+
sum_sq += (n_k / n) * debiased
|
|
902
|
+
return round(math.sqrt(sum_sq), 4)
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def _ece_with_bootstrap_ci(
|
|
906
|
+
pred: list[float],
|
|
907
|
+
actual: list[int],
|
|
908
|
+
n_iter: int = 1000,
|
|
909
|
+
seed: int = 0,
|
|
910
|
+
n_buckets: int = 10,
|
|
911
|
+
) -> ConfidenceInterval:
|
|
912
|
+
"""ECE with BCa bootstrap CI. Resamples (pred, actual) pairs
|
|
913
|
+
jointly to preserve the calibration relationship."""
|
|
914
|
+
n = len(pred)
|
|
915
|
+
if n == 0:
|
|
916
|
+
return ConfidenceInterval(0, 0, 0)
|
|
917
|
+
_point_buckets, point_ece, _ = _reliability(pred, actual, n_buckets)
|
|
918
|
+
rng = random.Random(seed)
|
|
919
|
+
samples: list[float] = []
|
|
920
|
+
pairs = list(zip(pred, actual, strict=False))
|
|
921
|
+
for _ in range(n_iter):
|
|
922
|
+
resample = [pairs[rng.randint(0, n - 1)] for _ in range(n)]
|
|
923
|
+
rp = [x[0] for x in resample]
|
|
924
|
+
ra = [x[1] for x in resample]
|
|
925
|
+
_, ece_b, _ = _reliability(rp, ra, n_buckets)
|
|
926
|
+
samples.append(ece_b)
|
|
927
|
+
samples.sort()
|
|
928
|
+
# Jackknife for BCa acceleration.
|
|
929
|
+
jackknife: list[float] = []
|
|
930
|
+
for skip_idx in range(min(n, 50)): # cap jackknife at 50 for speed
|
|
931
|
+
jp = [pairs[i][0] for i in range(n) if i != skip_idx]
|
|
932
|
+
ja = [pairs[i][1] for i in range(n) if i != skip_idx]
|
|
933
|
+
_, ece_j, _ = _reliability(jp, ja, n_buckets)
|
|
934
|
+
jackknife.append(ece_j)
|
|
935
|
+
lo, hi = _bca_percentiles(samples, point_ece, jackknife, alpha=0.05)
|
|
936
|
+
return ConfidenceInterval(round(point_ece, 4), round(lo, 4), round(hi, 4))
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def _reliability(
|
|
940
|
+
pred: list[float],
|
|
941
|
+
actual: list[int],
|
|
942
|
+
n_buckets: int = 10,
|
|
943
|
+
) -> tuple[list[ReliabilityBucket], float, float]:
|
|
944
|
+
"""Returns (buckets, ECE, MCE)."""
|
|
945
|
+
buckets: list[ReliabilityBucket] = []
|
|
946
|
+
ece_sum = 0.0
|
|
947
|
+
mce = 0.0
|
|
948
|
+
n = len(pred)
|
|
949
|
+
for i in range(n_buckets):
|
|
950
|
+
lo, hi = i / n_buckets, (i + 1) / n_buckets
|
|
951
|
+
in_b = [
|
|
952
|
+
(p, a)
|
|
953
|
+
for p, a in zip(pred, actual, strict=False)
|
|
954
|
+
if (lo <= p < hi) or (i == n_buckets - 1 and p == hi)
|
|
955
|
+
]
|
|
956
|
+
if not in_b:
|
|
957
|
+
buckets.append(
|
|
958
|
+
ReliabilityBucket(
|
|
959
|
+
bucket_low=round(lo, 2),
|
|
960
|
+
bucket_high=round(hi, 2),
|
|
961
|
+
mean_predicted=0.0,
|
|
962
|
+
actual_rate=0.0,
|
|
963
|
+
n_predictions=0,
|
|
964
|
+
)
|
|
965
|
+
)
|
|
966
|
+
continue
|
|
967
|
+
mean_p = sum(p for p, _ in in_b) / len(in_b)
|
|
968
|
+
rate = sum(a for _, a in in_b) / len(in_b)
|
|
969
|
+
gap = abs(mean_p - rate)
|
|
970
|
+
ece_sum += (len(in_b) / n) * gap
|
|
971
|
+
mce = max(mce, gap)
|
|
972
|
+
buckets.append(
|
|
973
|
+
ReliabilityBucket(
|
|
974
|
+
bucket_low=round(lo, 2),
|
|
975
|
+
bucket_high=round(hi, 2),
|
|
976
|
+
mean_predicted=round(mean_p, 4),
|
|
977
|
+
actual_rate=round(rate, 4),
|
|
978
|
+
n_predictions=len(in_b),
|
|
979
|
+
)
|
|
980
|
+
)
|
|
981
|
+
return buckets, round(ece_sum, 4), round(mce, 4)
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
# --- Statistical tests ----------------------------------------------------
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def _paired_t_test(a: list[float], b: list[float]) -> StatisticalTest:
|
|
988
|
+
"""Two-sided paired t-test on per-question Brier."""
|
|
989
|
+
if len(a) < 2:
|
|
990
|
+
return StatisticalTest("paired t-test", 0, 1.0, False, "n too small")
|
|
991
|
+
diffs = [bi - ai for ai, bi in zip(a, b, strict=False)]
|
|
992
|
+
n = len(diffs)
|
|
993
|
+
mean_d = sum(diffs) / n
|
|
994
|
+
var_d = sum((d - mean_d) ** 2 for d in diffs) / (n - 1)
|
|
995
|
+
if var_d == 0:
|
|
996
|
+
return StatisticalTest("paired t-test", 0, 1.0, False, "identical samples")
|
|
997
|
+
se = math.sqrt(var_d / n)
|
|
998
|
+
t_stat = mean_d / se
|
|
999
|
+
# Two-sided p-value using asymptotic normal approximation for n > 30,
|
|
1000
|
+
# else a polynomial Student-t approximation valid for n down to 5.
|
|
1001
|
+
p_val = _two_sided_p_from_t(t_stat, n - 1)
|
|
1002
|
+
significant = p_val < 0.05
|
|
1003
|
+
interp = (
|
|
1004
|
+
f"HyperMind has lower Brier on {sum(1 for d in diffs if d > 0)}/{n} questions; "
|
|
1005
|
+
f"mean delta {mean_d:.4f}"
|
|
1006
|
+
)
|
|
1007
|
+
return StatisticalTest("paired t-test", round(t_stat, 4), round(p_val, 5), significant, interp)
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def _two_sided_p_from_t(t: float, df: int) -> float:
|
|
1011
|
+
"""Two-sided p-value from a t-statistic. Uses Hill's approximation."""
|
|
1012
|
+
if df <= 0:
|
|
1013
|
+
return 1.0
|
|
1014
|
+
abs_t = abs(t)
|
|
1015
|
+
# Hill 1970 approximation
|
|
1016
|
+
a = df - 0.5
|
|
1017
|
+
b = 48 * a * a
|
|
1018
|
+
z = math.sqrt(a * math.log(1 + abs_t * abs_t / df))
|
|
1019
|
+
z = z * (1 + (z * z + 3) / b - (4 * z**4 + 33 * z * z + 240) / (10 * b * b))
|
|
1020
|
+
# Standard normal one-sided
|
|
1021
|
+
p_one = 0.5 * math.erfc(z / math.sqrt(2))
|
|
1022
|
+
return min(1.0, max(0.0, 2 * p_one))
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def _wilcoxon_signed_rank(a: list[float], b: list[float]) -> StatisticalTest:
|
|
1026
|
+
"""Two-sided Wilcoxon signed-rank test (asymptotic normal approximation)."""
|
|
1027
|
+
if len(a) < 5:
|
|
1028
|
+
return StatisticalTest("Wilcoxon signed-rank", 0, 1.0, False, "n too small")
|
|
1029
|
+
diffs = [(bi - ai, i) for i, (ai, bi) in enumerate(zip(a, b, strict=False)) if ai != bi]
|
|
1030
|
+
if not diffs:
|
|
1031
|
+
return StatisticalTest("Wilcoxon signed-rank", 0, 1.0, False, "no nonzero diffs")
|
|
1032
|
+
abs_diffs = sorted([(abs(d), i, d) for d, i in diffs])
|
|
1033
|
+
# Assign ranks (handle ties by averaging)
|
|
1034
|
+
ranks: dict[int, float] = {}
|
|
1035
|
+
i = 0
|
|
1036
|
+
while i < len(abs_diffs):
|
|
1037
|
+
j = i
|
|
1038
|
+
while j < len(abs_diffs) - 1 and abs_diffs[j + 1][0] == abs_diffs[i][0]:
|
|
1039
|
+
j += 1
|
|
1040
|
+
avg_rank = (i + j + 2) / 2 # 1-indexed mean
|
|
1041
|
+
for k in range(i, j + 1):
|
|
1042
|
+
ranks[abs_diffs[k][1]] = avg_rank
|
|
1043
|
+
i = j + 1
|
|
1044
|
+
w_pos = sum(r for idx, r in ranks.items() if next(d for d, ii in diffs if ii == idx) > 0)
|
|
1045
|
+
w_neg = sum(r for idx, r in ranks.items() if next(d for d, ii in diffs if ii == idx) < 0)
|
|
1046
|
+
n = len(diffs)
|
|
1047
|
+
mu = n * (n + 1) / 4
|
|
1048
|
+
sigma = math.sqrt(n * (n + 1) * (2 * n + 1) / 24)
|
|
1049
|
+
if sigma == 0:
|
|
1050
|
+
return StatisticalTest("Wilcoxon signed-rank", 0, 1.0, False, "zero variance")
|
|
1051
|
+
z = (w_pos - mu) / sigma
|
|
1052
|
+
p = math.erfc(abs(z) / math.sqrt(2))
|
|
1053
|
+
return StatisticalTest(
|
|
1054
|
+
"Wilcoxon signed-rank",
|
|
1055
|
+
round(z, 4),
|
|
1056
|
+
round(min(1.0, p), 5),
|
|
1057
|
+
p < 0.05,
|
|
1058
|
+
f"W+ = {w_pos:.1f}, W- = {w_neg:.1f}; non-parametric robustness check",
|
|
1059
|
+
)
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def _mcnemar(
|
|
1063
|
+
a_pred: list[float],
|
|
1064
|
+
b_pred: list[float],
|
|
1065
|
+
actual: list[int],
|
|
1066
|
+
) -> StatisticalTest:
|
|
1067
|
+
"""McNemar's test on accuracy@50 disagreement table."""
|
|
1068
|
+
a_correct = [(p >= 0.5) == bool(act) for p, act in zip(a_pred, actual, strict=False)]
|
|
1069
|
+
b_correct = [(p >= 0.5) == bool(act) for p, act in zip(b_pred, actual, strict=False)]
|
|
1070
|
+
# b1 = a wrong, b right; b2 = a right, b wrong
|
|
1071
|
+
b1 = sum(1 for ac, bc in zip(a_correct, b_correct, strict=False) if not ac and bc)
|
|
1072
|
+
b2 = sum(1 for ac, bc in zip(a_correct, b_correct, strict=False) if ac and not bc)
|
|
1073
|
+
if b1 + b2 < 1:
|
|
1074
|
+
return StatisticalTest(
|
|
1075
|
+
"McNemar (accuracy@50)",
|
|
1076
|
+
0,
|
|
1077
|
+
1.0,
|
|
1078
|
+
False,
|
|
1079
|
+
"no accuracy disagreements between forecasters",
|
|
1080
|
+
)
|
|
1081
|
+
# Continuity-corrected chi-square
|
|
1082
|
+
chi2 = ((abs(b1 - b2) - 1) ** 2) / (b1 + b2) if (b1 + b2) > 0 else 0
|
|
1083
|
+
# 1-df chi-square p-value
|
|
1084
|
+
p = math.exp(-chi2 / 2) if chi2 > 0 else 1.0
|
|
1085
|
+
return StatisticalTest(
|
|
1086
|
+
"McNemar (accuracy@50)",
|
|
1087
|
+
round(chi2, 4),
|
|
1088
|
+
round(p, 5),
|
|
1089
|
+
p < 0.05,
|
|
1090
|
+
f"a-wrong/b-right = {b1}; a-right/b-wrong = {b2}",
|
|
1091
|
+
)
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
def _cohens_d(a: list[float], b: list[float]) -> float:
|
|
1095
|
+
"""Cohen's d for paired samples (uncorrected)."""
|
|
1096
|
+
if len(a) < 2:
|
|
1097
|
+
return 0.0
|
|
1098
|
+
diffs = [bi - ai for ai, bi in zip(a, b, strict=False)]
|
|
1099
|
+
mean_d = sum(diffs) / len(diffs)
|
|
1100
|
+
var_d = sum((d - mean_d) ** 2 for d in diffs) / (len(diffs) - 1)
|
|
1101
|
+
sd = math.sqrt(var_d)
|
|
1102
|
+
return round(mean_d / sd, 4) if sd > 0 else 0.0
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
def _hedges_g(a: list[float], b: list[float]) -> float:
|
|
1106
|
+
"""Small-sample-corrected Cohen's d (Hedges 1981).
|
|
1107
|
+
|
|
1108
|
+
Hedges' g multiplies d by a bias correction factor
|
|
1109
|
+
`J = 1 - 3 / (4*df - 1)` where df = n - 1 for paired samples. This
|
|
1110
|
+
makes g unbiased for small n; the difference is material for n < 20.
|
|
1111
|
+
"""
|
|
1112
|
+
if len(a) < 2:
|
|
1113
|
+
return 0.0
|
|
1114
|
+
d = _cohens_d(a, b)
|
|
1115
|
+
df = len(a) - 1
|
|
1116
|
+
if df < 1:
|
|
1117
|
+
return d
|
|
1118
|
+
j = 1.0 - (3.0 / (4.0 * df - 1.0)) if (4 * df - 1) > 0 else 1.0
|
|
1119
|
+
return round(d * j, 4)
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def _cliffs_delta(a: list[float], b: list[float]) -> float:
|
|
1123
|
+
"""Cliff's delta — non-parametric paired effect size in [-1, +1].
|
|
1124
|
+
|
|
1125
|
+
Counts the proportion of (i, j) pairs where b_j > a_i minus the
|
|
1126
|
+
proportion where b_j < a_i. Distribution-free; robust to outliers.
|
|
1127
|
+
Reported alongside Hedges' g because it doesn't assume normality
|
|
1128
|
+
of the per-question Brier deltas.
|
|
1129
|
+
"""
|
|
1130
|
+
n_a, n_b = len(a), len(b)
|
|
1131
|
+
if n_a == 0 or n_b == 0:
|
|
1132
|
+
return 0.0
|
|
1133
|
+
n_greater = sum(1 for ai in a for bj in b if bj > ai)
|
|
1134
|
+
n_less = sum(1 for ai in a for bj in b if bj < ai)
|
|
1135
|
+
return round((n_greater - n_less) / (n_a * n_b), 4)
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
def _common_language_effect_size(a: list[float], b: list[float]) -> float:
|
|
1139
|
+
"""CLES — P(b_j > a_i) over all paired (i, j). In [0, 1]; 0.5 = no effect.
|
|
1140
|
+
|
|
1141
|
+
Communicates effect size to non-statisticians: "70% of the time the
|
|
1142
|
+
HyperMind run scored better than the Single-LLM on the same question."
|
|
1143
|
+
"""
|
|
1144
|
+
n_a, n_b = len(a), len(b)
|
|
1145
|
+
if n_a == 0 or n_b == 0:
|
|
1146
|
+
return 0.5
|
|
1147
|
+
n_greater = sum(1 for ai in a for bj in b if bj > ai)
|
|
1148
|
+
n_ties = sum(1 for ai in a for bj in b if bj == ai)
|
|
1149
|
+
# Standard convention: split ties evenly (half-credit).
|
|
1150
|
+
return round((n_greater + 0.5 * n_ties) / (n_a * n_b), 4)
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
def _holm_bonferroni(p_values: list[float], alpha: float = 0.05) -> list[tuple[float, bool]]:
|
|
1154
|
+
"""Holm-Bonferroni step-down family-wise error rate (FWER) correction.
|
|
1155
|
+
|
|
1156
|
+
Returns list of (adjusted_p, significant_at_alpha) in input order.
|
|
1157
|
+
Adjusted p is `min(1, raw_p * (n - rank + 1))` where rank is by
|
|
1158
|
+
ascending raw_p; monotone-corrected so adjusted_p never decreases
|
|
1159
|
+
as rank increases. Less conservative than Bonferroni; controls
|
|
1160
|
+
FWER strongly.
|
|
1161
|
+
"""
|
|
1162
|
+
if not p_values:
|
|
1163
|
+
return []
|
|
1164
|
+
n = len(p_values)
|
|
1165
|
+
indexed = sorted(enumerate(p_values), key=lambda x: x[1])
|
|
1166
|
+
adjusted: list[float] = [1.0] * n
|
|
1167
|
+
running_max = 0.0
|
|
1168
|
+
for rank, (orig_idx, p) in enumerate(indexed):
|
|
1169
|
+
adj = min(1.0, p * (n - rank))
|
|
1170
|
+
running_max = max(running_max, adj)
|
|
1171
|
+
adjusted[orig_idx] = running_max
|
|
1172
|
+
return [(round(adjusted[i], 5), adjusted[i] < alpha) for i in range(n)]
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
def _spherical_score(p: float, a: int) -> float:
|
|
1176
|
+
"""Spherical proper scoring rule (Gneiting & Raftery 2007).
|
|
1177
|
+
|
|
1178
|
+
For binary outcome, score(p, 1) = p / sqrt(p² + (1-p)²); strictly
|
|
1179
|
+
proper, bounded in [0, 1]. Symmetric counterpart of Brier.
|
|
1180
|
+
Reported alongside Brier so the calibration story doesn't depend
|
|
1181
|
+
on a single scoring rule's idiosyncrasies.
|
|
1182
|
+
"""
|
|
1183
|
+
p = max(1e-9, min(1 - 1e-9, p))
|
|
1184
|
+
norm = math.sqrt(p * p + (1 - p) * (1 - p))
|
|
1185
|
+
return p / norm if a == 1 else (1 - p) / norm
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
def _quadratic_score(p: float, a: int) -> float:
|
|
1189
|
+
"""Quadratic (Brier) score in proper-score form: 2p - (p² + (1-p)²)
|
|
1190
|
+
when a=1; symmetric for a=0. Larger = better. The 'reward' formulation
|
|
1191
|
+
of Brier; useful when comparing skill across conventions.
|
|
1192
|
+
"""
|
|
1193
|
+
if a == 1:
|
|
1194
|
+
return 2 * p - (p * p + (1 - p) * (1 - p))
|
|
1195
|
+
return 2 * (1 - p) - (p * p + (1 - p) * (1 - p))
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
def _murphy_decomposition(
|
|
1199
|
+
pred: list[float],
|
|
1200
|
+
actual: list[int],
|
|
1201
|
+
n_buckets: int = 10,
|
|
1202
|
+
) -> tuple[float, float, float]:
|
|
1203
|
+
"""Murphy 1973 decomposition of mean Brier:
|
|
1204
|
+
|
|
1205
|
+
Brier = Reliability − Resolution + Uncertainty
|
|
1206
|
+
|
|
1207
|
+
where
|
|
1208
|
+
* **Reliability** — `Σ_k (n_k / N) * (mean_p_k - actual_rate_k)²`
|
|
1209
|
+
— average squared gap between bucket-mean predictions and bucket
|
|
1210
|
+
actual rates. Lower = better calibrated.
|
|
1211
|
+
* **Resolution** — `Σ_k (n_k / N) * (actual_rate_k - base_rate)²`
|
|
1212
|
+
— how much the forecaster's bucket actual rates vary from the
|
|
1213
|
+
overall base rate. Higher = better discrimination.
|
|
1214
|
+
* **Uncertainty** — `base_rate * (1 - base_rate)` — irreducible;
|
|
1215
|
+
depends only on the corpus base rate, not on the forecaster.
|
|
1216
|
+
|
|
1217
|
+
Returns (reliability, resolution, uncertainty) such that
|
|
1218
|
+
`brier ≈ reliability - resolution + uncertainty` (within rounding).
|
|
1219
|
+
|
|
1220
|
+
Reference: Murphy AH (1973) "A new vector partition of the
|
|
1221
|
+
probability score." Monthly Weather Review 101(7):600-608.
|
|
1222
|
+
"""
|
|
1223
|
+
n = len(pred)
|
|
1224
|
+
if n == 0:
|
|
1225
|
+
return (0.0, 0.0, 0.0)
|
|
1226
|
+
base_rate = sum(actual) / n
|
|
1227
|
+
uncertainty = base_rate * (1 - base_rate)
|
|
1228
|
+
reliability = 0.0
|
|
1229
|
+
resolution = 0.0
|
|
1230
|
+
for i in range(n_buckets):
|
|
1231
|
+
lo, hi = i / n_buckets, (i + 1) / n_buckets
|
|
1232
|
+
in_b = [
|
|
1233
|
+
(p, a)
|
|
1234
|
+
for p, a in zip(pred, actual, strict=False)
|
|
1235
|
+
if (lo <= p < hi) or (i == n_buckets - 1 and p == hi)
|
|
1236
|
+
]
|
|
1237
|
+
if not in_b:
|
|
1238
|
+
continue
|
|
1239
|
+
n_k = len(in_b)
|
|
1240
|
+
mean_p_k = sum(p for p, _ in in_b) / n_k
|
|
1241
|
+
rate_k = sum(a for _, a in in_b) / n_k
|
|
1242
|
+
weight = n_k / n
|
|
1243
|
+
reliability += weight * (mean_p_k - rate_k) ** 2
|
|
1244
|
+
resolution += weight * (rate_k - base_rate) ** 2
|
|
1245
|
+
return (round(reliability, 6), round(resolution, 6), round(uncertainty, 6))
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
def _skill_score(model_score: float, baseline_score: float) -> float:
|
|
1249
|
+
"""Skill score relative to a baseline forecaster (Murphy & Winkler 1987).
|
|
1250
|
+
|
|
1251
|
+
`skill = (baseline - model) / baseline` for loss-style scores
|
|
1252
|
+
(Brier, log loss). Range: 1.0 = perfect; 0.0 = no improvement
|
|
1253
|
+
over baseline; negative = worse than baseline. The non-statistician's
|
|
1254
|
+
headline number for "is this model worth using?".
|
|
1255
|
+
"""
|
|
1256
|
+
if baseline_score <= 1e-12:
|
|
1257
|
+
return 0.0
|
|
1258
|
+
return round((baseline_score - model_score) / baseline_score, 4)
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
def _benjamini_hochberg(p_values: list[float], alpha: float = 0.05) -> list[tuple[float, bool]]:
|
|
1262
|
+
"""Benjamini-Hochberg false-discovery-rate (FDR) correction.
|
|
1263
|
+
|
|
1264
|
+
Adjusted p_i = min over j ≥ rank(i) of (p_(j) * n / j).
|
|
1265
|
+
Returns list of (adjusted_p, significant) in input order. BH is
|
|
1266
|
+
standard for exploratory work and the headline correction we report.
|
|
1267
|
+
"""
|
|
1268
|
+
if not p_values:
|
|
1269
|
+
return []
|
|
1270
|
+
n = len(p_values)
|
|
1271
|
+
indexed = sorted(enumerate(p_values), key=lambda x: x[1])
|
|
1272
|
+
adjusted: list[float] = [1.0] * n
|
|
1273
|
+
# Walk from largest p down so we can take running min.
|
|
1274
|
+
running_min = 1.0
|
|
1275
|
+
for k in range(n - 1, -1, -1):
|
|
1276
|
+
orig_idx, p = indexed[k]
|
|
1277
|
+
rank_one_based = k + 1
|
|
1278
|
+
bh = min(1.0, p * n / rank_one_based)
|
|
1279
|
+
running_min = min(running_min, bh)
|
|
1280
|
+
adjusted[orig_idx] = running_min
|
|
1281
|
+
return [(round(adjusted[i], 5), adjusted[i] < alpha) for i in range(n)]
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
# --- Difficulty stratification --------------------------------------------
|
|
1285
|
+
|
|
1286
|
+
|
|
1287
|
+
def _difficulty(actual: int, n_yes: int, n_total: int) -> str:
|
|
1288
|
+
"""Bucket questions by base-rate signal: when the corpus is roughly
|
|
1289
|
+
balanced (around 50%), this question is 'hard'; when actuals lean
|
|
1290
|
+
one way, the question is 'easier' to call."""
|
|
1291
|
+
base_rate = n_yes / max(n_total, 1)
|
|
1292
|
+
if abs(base_rate - 0.5) > 0.20:
|
|
1293
|
+
return "easy" if actual == (1 if base_rate >= 0.5 else 0) else "hard"
|
|
1294
|
+
return "medium"
|
|
1295
|
+
|
|
1296
|
+
|
|
1297
|
+
# --- IEEE markdown report --------------------------------------------------
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
def _ieee_report(
|
|
1301
|
+
label: str,
|
|
1302
|
+
forecasters: list[ForecasterMetrics],
|
|
1303
|
+
pairwise: list[PairwiseComparison],
|
|
1304
|
+
ablation: list[AblationRow],
|
|
1305
|
+
strata: list[DifficultyBucket],
|
|
1306
|
+
headline_lift: float,
|
|
1307
|
+
headline_p: float,
|
|
1308
|
+
headline_verdict: str,
|
|
1309
|
+
n_q: int,
|
|
1310
|
+
n_boot: int,
|
|
1311
|
+
capsule: ReproducibilityCapsule | None = None,
|
|
1312
|
+
limitations: list[Limitation] | None = None,
|
|
1313
|
+
verdict_obj: Verdict | None = None,
|
|
1314
|
+
power: PowerAnalysis | None = None,
|
|
1315
|
+
bh_fdr_adjusted: list[tuple[float, float, bool]] | None = None,
|
|
1316
|
+
) -> str:
|
|
1317
|
+
lines: list[str] = []
|
|
1318
|
+
lines.append(f"# Evaluation: {label}")
|
|
1319
|
+
lines.append("")
|
|
1320
|
+
lines.append("## Abstract")
|
|
1321
|
+
lines.append(
|
|
1322
|
+
f"We evaluate the HyperMind protocol against two baselines (single-LLM, "
|
|
1323
|
+
f"naive ensemble) on a benchmark of {n_q} binary forecasting questions. "
|
|
1324
|
+
f"HyperMind achieves a Brier loss reduction of "
|
|
1325
|
+
f"**{headline_lift:.1f}%** over the better baseline "
|
|
1326
|
+
f"(p={headline_p:.4f}, paired t-test). "
|
|
1327
|
+
f"All confidence intervals are computed by non-parametric bootstrap "
|
|
1328
|
+
f"({n_boot} resamples).",
|
|
1329
|
+
)
|
|
1330
|
+
lines.append("")
|
|
1331
|
+
lines.append("## I. Methodology")
|
|
1332
|
+
lines.append(
|
|
1333
|
+
"Three forecasters produce posterior probabilities for each "
|
|
1334
|
+
"question: (1) **HyperMind**, a 4-role panel with anti-herding "
|
|
1335
|
+
"intervention; (2) **Single-LLM**, a single forecaster; (3) "
|
|
1336
|
+
"**Naive ensemble**, the unweighted mean of N=4 independent "
|
|
1337
|
+
"single-LLMs. All forecasters are deterministic given the "
|
|
1338
|
+
"benchmark inputs to ensure reproducibility.",
|
|
1339
|
+
)
|
|
1340
|
+
lines.append("")
|
|
1341
|
+
lines.append("## II. Forecaster metrics")
|
|
1342
|
+
lines.append("")
|
|
1343
|
+
lines.append("| Forecaster | Brier (95% CI) | Log loss | Acc@50 | ECE | MCE |")
|
|
1344
|
+
lines.append("|---|---|---|---|---|---|")
|
|
1345
|
+
for f in forecasters:
|
|
1346
|
+
lines.append(
|
|
1347
|
+
f"| {f.label} | {f.brier.point:.4f} "
|
|
1348
|
+
f"[{f.brier.low:.4f}, {f.brier.high:.4f}] "
|
|
1349
|
+
f"| {f.log_loss:.4f} | {f.accuracy_at_50:.3f} "
|
|
1350
|
+
f"| {f.ece:.4f} | {f.mce:.4f} |",
|
|
1351
|
+
)
|
|
1352
|
+
lines.append("")
|
|
1353
|
+
lines.append("## III. Pairwise statistical comparison (HyperMind vs. baselines)")
|
|
1354
|
+
lines.append("")
|
|
1355
|
+
lines.append("| Comparison | Δ Brier | Cohen's d | Lift | t-test p | Wilcoxon p | McNemar p |")
|
|
1356
|
+
lines.append("|---|---|---|---|---|---|---|")
|
|
1357
|
+
for c in pairwise:
|
|
1358
|
+
lines.append(
|
|
1359
|
+
f"| {c.b_name} → {c.a_name} | "
|
|
1360
|
+
f"{c.mean_brier_delta:+.4f} "
|
|
1361
|
+
f"[{c.mean_brier_delta_ci.low:+.4f}, {c.mean_brier_delta_ci.high:+.4f}] "
|
|
1362
|
+
f"| {c.cohens_d:+.3f} | {c.lift_pct:+.1f}% "
|
|
1363
|
+
f"| {c.paired_t.p_value:.4f}{'*' if c.paired_t.significant else ''} "
|
|
1364
|
+
f"| {c.wilcoxon.p_value:.4f}{'*' if c.wilcoxon.significant else ''} "
|
|
1365
|
+
f"| {c.mcnemar.p_value:.4f}{'*' if c.mcnemar.significant else ''} |",
|
|
1366
|
+
)
|
|
1367
|
+
lines.append("")
|
|
1368
|
+
lines.append(
|
|
1369
|
+
"Significance: * p<0.05 (two-sided). Δ Brier reported as "
|
|
1370
|
+
"(baseline - HyperMind); positive values indicate HyperMind "
|
|
1371
|
+
"advantage. Bootstrap CIs are paired-difference resamples.",
|
|
1372
|
+
)
|
|
1373
|
+
lines.append("")
|
|
1374
|
+
lines.append("## IV. Ablation: drop-one-role")
|
|
1375
|
+
lines.append("")
|
|
1376
|
+
lines.append("| Role removed | Brier without | Δ vs. full panel | Contribution |")
|
|
1377
|
+
lines.append("|---|---|---|---|")
|
|
1378
|
+
for a in ablation:
|
|
1379
|
+
lines.append(
|
|
1380
|
+
f"| {a.role_dropped} | {a.brier_without:.4f} | "
|
|
1381
|
+
f"{a.brier_delta_vs_full:+.4f} | {a.contribution_label} |",
|
|
1382
|
+
)
|
|
1383
|
+
lines.append("")
|
|
1384
|
+
lines.append("## V. Difficulty stratification")
|
|
1385
|
+
lines.append("")
|
|
1386
|
+
lines.append("| Stratum | n | HyperMind Brier | Best baseline Brier | Lift |")
|
|
1387
|
+
lines.append("|---|---|---|---|---|")
|
|
1388
|
+
for s in strata:
|
|
1389
|
+
lines.append(
|
|
1390
|
+
f"| {s.label} | {s.n_questions} | {s.hypermind_brier:.4f} "
|
|
1391
|
+
f"| {s.best_baseline_brier:.4f} | {s.lift_pct:+.1f}% |",
|
|
1392
|
+
)
|
|
1393
|
+
lines.append("")
|
|
1394
|
+
lines.append("## VI. Verdict")
|
|
1395
|
+
lines.append("")
|
|
1396
|
+
if verdict_obj:
|
|
1397
|
+
lines.append(
|
|
1398
|
+
f"**{verdict_obj.headline} ({verdict_obj.confidence} confidence)** — "
|
|
1399
|
+
f"Δ Brier = {verdict_obj.delta:+.4f} "
|
|
1400
|
+
f"[95% CI {verdict_obj.delta_ci_low:+.4f}, {verdict_obj.delta_ci_high:+.4f}]; "
|
|
1401
|
+
f"raw p = {verdict_obj.p_value_raw:.4f}, "
|
|
1402
|
+
f"BH-FDR adjusted p = {verdict_obj.p_value_adjusted:.4f}.",
|
|
1403
|
+
)
|
|
1404
|
+
lines.append("")
|
|
1405
|
+
lines.append(
|
|
1406
|
+
f"Effect sizes: Cohen's d = {verdict_obj.effect_size_d:+.3f}, "
|
|
1407
|
+
f"Hedges' g = {verdict_obj.effect_size_g:+.3f}, "
|
|
1408
|
+
f"Cliff's δ = {verdict_obj.effect_size_cliffs_d:+.3f}, "
|
|
1409
|
+
f"CLES = {verdict_obj.effect_size_cles:.3f} "
|
|
1410
|
+
f"(P that HyperMind beats best baseline on a random question).",
|
|
1411
|
+
)
|
|
1412
|
+
lines.append("")
|
|
1413
|
+
lines.append(verdict_obj.primary_reason)
|
|
1414
|
+
else:
|
|
1415
|
+
lines.append(headline_verdict)
|
|
1416
|
+
lines.append("")
|
|
1417
|
+
|
|
1418
|
+
# ---- VII. Power analysis ----
|
|
1419
|
+
if power:
|
|
1420
|
+
lines.append("## VII. Power analysis")
|
|
1421
|
+
lines.append("")
|
|
1422
|
+
lines.append(
|
|
1423
|
+
f"On the current n = {power.n_current} with observed effect "
|
|
1424
|
+
f"|g| = {power.observed_effect_g:.3f}, the study has "
|
|
1425
|
+
f"{power.achieved_power * 100:.0f}% power at α = {power.alpha}. "
|
|
1426
|
+
f"To reach {power.target_power * 100:.0f}% power, the benchmark "
|
|
1427
|
+
f"would need n ≥ "
|
|
1428
|
+
f"{('∞' if power.n_required >= 10**6 else str(power.n_required))}.",
|
|
1429
|
+
)
|
|
1430
|
+
lines.append("")
|
|
1431
|
+
lines.append(power.interpretation)
|
|
1432
|
+
lines.append("")
|
|
1433
|
+
|
|
1434
|
+
# ---- VIII. Limitations & threats to validity ----
|
|
1435
|
+
if limitations:
|
|
1436
|
+
lines.append("## VIII. Limitations & threats to validity")
|
|
1437
|
+
lines.append("")
|
|
1438
|
+
for lim in limitations:
|
|
1439
|
+
lines.append(f"### {lim.code} ({lim.severity}) — {lim.title}")
|
|
1440
|
+
lines.append("")
|
|
1441
|
+
lines.append(lim.description)
|
|
1442
|
+
lines.append("")
|
|
1443
|
+
lines.append(f"*Mitigation:* {lim.mitigation}")
|
|
1444
|
+
lines.append("")
|
|
1445
|
+
|
|
1446
|
+
# ---- IX. Reproducibility ----
|
|
1447
|
+
if capsule:
|
|
1448
|
+
lines.append("## IX. Reproducibility")
|
|
1449
|
+
lines.append("")
|
|
1450
|
+
lines.append("| Field | Value |")
|
|
1451
|
+
lines.append("|---|---|")
|
|
1452
|
+
lines.append(f"| Benchmark hash (SHA-256, 32h) | `{capsule.benchmark_sha256}` |")
|
|
1453
|
+
lines.append(f"| n_questions | {capsule.n_questions} |")
|
|
1454
|
+
lines.append(f"| n_bootstrap | {capsule.n_bootstrap} |")
|
|
1455
|
+
lines.append(f"| α | {capsule.alpha} |")
|
|
1456
|
+
lines.append(f"| Code revision | `{capsule.code_revision}` |")
|
|
1457
|
+
lines.append(f"| Seed hash | `{capsule.seed_hash}` |")
|
|
1458
|
+
lines.append(f"| Timestamp (UTC) | {capsule.timestamp_utc} |")
|
|
1459
|
+
lines.append(f"| Deterministic replay | {capsule.deterministic_replay} |")
|
|
1460
|
+
sw = ", ".join(f"{k}={v}" for k, v in capsule.software_versions.items())
|
|
1461
|
+
lines.append(f"| Software versions | {sw} |")
|
|
1462
|
+
fv = "; ".join(f"{k}: {v}" for k, v in capsule.forecaster_versions.items())
|
|
1463
|
+
lines.append(f"| Forecaster versions | {fv} |")
|
|
1464
|
+
lines.append("")
|
|
1465
|
+
|
|
1466
|
+
# ---- References ----
|
|
1467
|
+
lines.append("## References")
|
|
1468
|
+
lines.append("")
|
|
1469
|
+
lines.append(
|
|
1470
|
+
"1. Brier GW (1950). Verification of forecasts expressed in terms of probability. *Monthly Weather Review* 78(1):1–3."
|
|
1471
|
+
)
|
|
1472
|
+
lines.append(
|
|
1473
|
+
"2. Murphy AH (1973). A new vector partition of the probability score. *Monthly Weather Review* 101(7):600–608."
|
|
1474
|
+
)
|
|
1475
|
+
lines.append(
|
|
1476
|
+
"3. Hedges LV (1981). Distribution theory for Glass's estimator of effect size and related estimators. *JEBS* 6(2):107–128."
|
|
1477
|
+
)
|
|
1478
|
+
lines.append(
|
|
1479
|
+
"4. Efron B (1987). Better bootstrap confidence intervals. *JASA* 82(397):171–185."
|
|
1480
|
+
)
|
|
1481
|
+
lines.append(
|
|
1482
|
+
"5. Benjamini Y, Hochberg Y (1995). Controlling the false discovery rate: a practical and powerful approach to multiple testing. *JRSS-B* 57(1):289–300."
|
|
1483
|
+
)
|
|
1484
|
+
lines.append(
|
|
1485
|
+
"6. Gneiting T, Raftery AE (2007). Strictly proper scoring rules, prediction, and estimation. *JASA* 102(477):359–378."
|
|
1486
|
+
)
|
|
1487
|
+
lines.append(
|
|
1488
|
+
"7. Kumar A, Liang P, Ma T (2019). Verified uncertainty calibration. *NeurIPS* 32. arXiv:1909.10155."
|
|
1489
|
+
)
|
|
1490
|
+
lines.append("")
|
|
1491
|
+
lines.append("---")
|
|
1492
|
+
lines.append("")
|
|
1493
|
+
lines.append(
|
|
1494
|
+
"*Generated by HyperMind SimLab `evaluation-bench` mode. "
|
|
1495
|
+
"All metrics are reproducible from the trace.json produced "
|
|
1496
|
+
"by this run; the reproducibility capsule (§IX) lists every "
|
|
1497
|
+
"input needed for an independent replication.*"
|
|
1498
|
+
)
|
|
1499
|
+
return "\n".join(lines)
|
|
1500
|
+
|
|
1501
|
+
|
|
1502
|
+
# --- Reproducibility + limitations ----------------------------------------
|
|
1503
|
+
|
|
1504
|
+
|
|
1505
|
+
def _benchmark_sha256(questions: list[tuple[str, int]]) -> str:
|
|
1506
|
+
"""Hash the benchmark contents (questions + actuals) so a reviewer
|
|
1507
|
+
can verify a downstream reproducer used the same input."""
|
|
1508
|
+
canonical = json.dumps(
|
|
1509
|
+
[(q, a) for q, a in questions],
|
|
1510
|
+
sort_keys=True,
|
|
1511
|
+
separators=(",", ":"),
|
|
1512
|
+
)
|
|
1513
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
1514
|
+
|
|
1515
|
+
|
|
1516
|
+
def _detect_code_revision() -> str:
|
|
1517
|
+
"""Best-effort git SHA of the running code. Returns 'unknown' on
|
|
1518
|
+
any failure; never raises (we don't want reproducibility capture
|
|
1519
|
+
to break a sim run)."""
|
|
1520
|
+
import subprocess
|
|
1521
|
+
|
|
1522
|
+
try:
|
|
1523
|
+
out = subprocess.run(
|
|
1524
|
+
["git", "rev-parse", "HEAD"],
|
|
1525
|
+
cwd=Path(__file__).resolve().parent,
|
|
1526
|
+
capture_output=True,
|
|
1527
|
+
text=True,
|
|
1528
|
+
timeout=2,
|
|
1529
|
+
)
|
|
1530
|
+
if out.returncode == 0 and out.stdout.strip():
|
|
1531
|
+
return out.stdout.strip()[:12]
|
|
1532
|
+
except Exception:
|
|
1533
|
+
pass
|
|
1534
|
+
return "unknown"
|
|
1535
|
+
|
|
1536
|
+
|
|
1537
|
+
def _detect_software_versions() -> dict[str, str]:
|
|
1538
|
+
"""Capture Python + key library versions for replication. Best-effort."""
|
|
1539
|
+
import sys
|
|
1540
|
+
|
|
1541
|
+
versions = {
|
|
1542
|
+
"python": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
|
1543
|
+
}
|
|
1544
|
+
try:
|
|
1545
|
+
from hypermind import __version__ as _hm_v
|
|
1546
|
+
|
|
1547
|
+
versions["hypermind"] = _hm_v
|
|
1548
|
+
except Exception:
|
|
1549
|
+
versions["hypermind"] = "unknown"
|
|
1550
|
+
return versions
|
|
1551
|
+
|
|
1552
|
+
|
|
1553
|
+
def _build_reproducibility_capsule(
|
|
1554
|
+
*,
|
|
1555
|
+
benchmark_label: str,
|
|
1556
|
+
questions: list[tuple[str, int]],
|
|
1557
|
+
n_bootstrap: int,
|
|
1558
|
+
alpha: float,
|
|
1559
|
+
salt: str,
|
|
1560
|
+
forecaster_versions: dict[str, str],
|
|
1561
|
+
deterministic_replay: bool,
|
|
1562
|
+
) -> ReproducibilityCapsule:
|
|
1563
|
+
from datetime import datetime
|
|
1564
|
+
|
|
1565
|
+
seed_hash_input = f"{benchmark_label}::{n_bootstrap}::{alpha}::{salt}"
|
|
1566
|
+
seed_hash = hashlib.sha256(seed_hash_input.encode("utf-8")).hexdigest()[:16]
|
|
1567
|
+
return ReproducibilityCapsule(
|
|
1568
|
+
benchmark_label=benchmark_label,
|
|
1569
|
+
benchmark_sha256=_benchmark_sha256(questions)[:32],
|
|
1570
|
+
n_questions=len(questions),
|
|
1571
|
+
n_bootstrap=n_bootstrap,
|
|
1572
|
+
bootstrap_seed=int(salt, 16) % (2**32) if all(c in "0123456789abcdef" for c in salt) else 0,
|
|
1573
|
+
alpha=alpha,
|
|
1574
|
+
forecaster_versions=forecaster_versions,
|
|
1575
|
+
code_revision=_detect_code_revision(),
|
|
1576
|
+
software_versions=_detect_software_versions(),
|
|
1577
|
+
timestamp_utc=datetime.now(UTC).isoformat(timespec="seconds"),
|
|
1578
|
+
deterministic_replay=deterministic_replay,
|
|
1579
|
+
seed_hash=seed_hash,
|
|
1580
|
+
)
|
|
1581
|
+
|
|
1582
|
+
|
|
1583
|
+
def _emit_limitations(
|
|
1584
|
+
*,
|
|
1585
|
+
n: int,
|
|
1586
|
+
use_real_llm: bool,
|
|
1587
|
+
base_rate: float,
|
|
1588
|
+
n_bootstrap: int,
|
|
1589
|
+
) -> list[Limitation]:
|
|
1590
|
+
"""Auto-detect threat-to-validity conditions from the run and
|
|
1591
|
+
surface them as structured Limitation cards. Call sites:
|
|
1592
|
+
|
|
1593
|
+
* L01 — synthetic forecasters (no real LLM)
|
|
1594
|
+
* L02 — small n (< 30) → low statistical power
|
|
1595
|
+
* L03 — no held-out test split
|
|
1596
|
+
* L04 — base-rate skew > 0.65 (trivial baselines may inflate)
|
|
1597
|
+
* L05 — bootstrap < 1000 (CI may be wide-stepped)
|
|
1598
|
+
"""
|
|
1599
|
+
out: list[Limitation] = []
|
|
1600
|
+
if not use_real_llm:
|
|
1601
|
+
out.append(
|
|
1602
|
+
Limitation(
|
|
1603
|
+
code="L01-SYNTHETIC-FORECASTERS",
|
|
1604
|
+
severity="moderate",
|
|
1605
|
+
title="Synthetic forecaster posteriors",
|
|
1606
|
+
description=(
|
|
1607
|
+
"All three model-based forecasters (HyperMind, Single-LLM, "
|
|
1608
|
+
"Naive ensemble) produce deterministic synthetic posteriors "
|
|
1609
|
+
"designed to mirror documented LLM failure modes. "
|
|
1610
|
+
"Numbers should be interpreted as illustrative of the "
|
|
1611
|
+
"evaluation methodology, not as evidence of HyperMind's "
|
|
1612
|
+
"performance against real LLMs."
|
|
1613
|
+
),
|
|
1614
|
+
mitigation=(
|
|
1615
|
+
"Set `use_real_llm: true` and `OPENROUTER_API_KEY` to "
|
|
1616
|
+
"score against a live model."
|
|
1617
|
+
),
|
|
1618
|
+
)
|
|
1619
|
+
)
|
|
1620
|
+
if n < 30:
|
|
1621
|
+
out.append(
|
|
1622
|
+
Limitation(
|
|
1623
|
+
code="L02-SMALL-N",
|
|
1624
|
+
severity="moderate" if n >= 15 else "blocking",
|
|
1625
|
+
title=f"Small benchmark size (n={n})",
|
|
1626
|
+
description=(
|
|
1627
|
+
f"With n={n} questions, paired tests have low statistical "
|
|
1628
|
+
f"power. Detecting a moderate effect (Cohen's d ≈ 0.4) at "
|
|
1629
|
+
f"α=0.05 / power=0.80 requires n ≥ ~50. Confidence intervals "
|
|
1630
|
+
f"on Brier and ECE will be wide; effect-size estimates "
|
|
1631
|
+
f"will be unstable."
|
|
1632
|
+
),
|
|
1633
|
+
mitigation="Increase the benchmark to n ≥ 50; ideally n ≥ 100.",
|
|
1634
|
+
)
|
|
1635
|
+
)
|
|
1636
|
+
out.append(
|
|
1637
|
+
Limitation(
|
|
1638
|
+
code="L03-NO-HOLDOUT",
|
|
1639
|
+
severity="minor",
|
|
1640
|
+
title="No held-out test split",
|
|
1641
|
+
description=(
|
|
1642
|
+
"All n questions are used both for tuning the synthetic "
|
|
1643
|
+
"forecasters' parameters and for scoring them. There is no "
|
|
1644
|
+
"held-out test set. This inflates apparent performance for "
|
|
1645
|
+
"any forecaster whose parameters could overfit the corpus."
|
|
1646
|
+
),
|
|
1647
|
+
mitigation="At n ≥ 100, switch to k-fold cross-validation or a 80/20 split.",
|
|
1648
|
+
)
|
|
1649
|
+
)
|
|
1650
|
+
if abs(base_rate - 0.5) > 0.15:
|
|
1651
|
+
out.append(
|
|
1652
|
+
Limitation(
|
|
1653
|
+
code="L04-IMBALANCED-CORPUS",
|
|
1654
|
+
severity="minor",
|
|
1655
|
+
title=f"Imbalanced base rate ({base_rate:.2f})",
|
|
1656
|
+
description=(
|
|
1657
|
+
f"Corpus base rate {base_rate:.2f} is materially asymmetric "
|
|
1658
|
+
f"(|Δ from 0.5| > 0.15). The Majority and Climatology "
|
|
1659
|
+
f"baselines benefit from this skew (they reach low Brier "
|
|
1660
|
+
f"by predicting the majority class). HyperMind's lift "
|
|
1661
|
+
f"over these baselines may be smaller on a balanced corpus."
|
|
1662
|
+
),
|
|
1663
|
+
mitigation="Add questions to balance the corpus, or report stratified Brier per class.",
|
|
1664
|
+
)
|
|
1665
|
+
)
|
|
1666
|
+
if n_bootstrap < 1000:
|
|
1667
|
+
out.append(
|
|
1668
|
+
Limitation(
|
|
1669
|
+
code="L05-LOW-BOOTSTRAP",
|
|
1670
|
+
severity="minor",
|
|
1671
|
+
title=f"Low bootstrap iterations (n_bootstrap={n_bootstrap})",
|
|
1672
|
+
description=(
|
|
1673
|
+
f"Bootstrap with {n_bootstrap} resamples can produce "
|
|
1674
|
+
f"discrete-step CIs and noisy BCa acceleration estimates. "
|
|
1675
|
+
f"Standard practice is n ≥ 2000 for reporting CIs; "
|
|
1676
|
+
f"n ≥ 10000 for tight reproducibility."
|
|
1677
|
+
),
|
|
1678
|
+
mitigation="Set `n_bootstrap: 2000` (default) or higher.",
|
|
1679
|
+
)
|
|
1680
|
+
)
|
|
1681
|
+
return out
|
|
1682
|
+
|
|
1683
|
+
|
|
1684
|
+
# --- Power analysis + structured verdict ----------------------------------
|
|
1685
|
+
|
|
1686
|
+
|
|
1687
|
+
def _power_analysis(
|
|
1688
|
+
a: list[float],
|
|
1689
|
+
b: list[float],
|
|
1690
|
+
alpha: float = 0.05,
|
|
1691
|
+
target_power: float = 0.80,
|
|
1692
|
+
) -> PowerAnalysis:
|
|
1693
|
+
"""Cohen 1988 power analysis for a paired t-test.
|
|
1694
|
+
|
|
1695
|
+
Given the observed effect size (Hedges' g) on the current sample,
|
|
1696
|
+
compute the n required to detect that effect at α + target_power.
|
|
1697
|
+
Uses the standard normal-approximation formula
|
|
1698
|
+
|
|
1699
|
+
n_required = ((z_α/2 + z_power) / |g|)²
|
|
1700
|
+
|
|
1701
|
+
For tiny effects (|g| < 0.05) we cap n_required at 1e6 to signal
|
|
1702
|
+
"infeasibly many questions" rather than returning a number that
|
|
1703
|
+
overflows the UI.
|
|
1704
|
+
"""
|
|
1705
|
+
n = len(a)
|
|
1706
|
+
if n < 2 or len(b) != n:
|
|
1707
|
+
return PowerAnalysis(
|
|
1708
|
+
observed_effect_g=0.0,
|
|
1709
|
+
n_current=n,
|
|
1710
|
+
alpha=alpha,
|
|
1711
|
+
target_power=target_power,
|
|
1712
|
+
n_required=0,
|
|
1713
|
+
achieved_power=0.0,
|
|
1714
|
+
interpretation="Insufficient data for power analysis (n < 2).",
|
|
1715
|
+
)
|
|
1716
|
+
g = abs(_hedges_g(a, b))
|
|
1717
|
+
if g < 0.01:
|
|
1718
|
+
return PowerAnalysis(
|
|
1719
|
+
observed_effect_g=g,
|
|
1720
|
+
n_current=n,
|
|
1721
|
+
alpha=alpha,
|
|
1722
|
+
target_power=target_power,
|
|
1723
|
+
n_required=10**6,
|
|
1724
|
+
achieved_power=alpha, # power ~ α when effect is null
|
|
1725
|
+
interpretation=(
|
|
1726
|
+
"Observed effect size is essentially zero. No reasonable "
|
|
1727
|
+
"n will reach the target power; the difference between "
|
|
1728
|
+
"forecasters is not detectable on this kind of benchmark."
|
|
1729
|
+
),
|
|
1730
|
+
)
|
|
1731
|
+
z_alpha = abs(_normal_inv_cdf(1 - alpha / 2))
|
|
1732
|
+
z_power = abs(_normal_inv_cdf(target_power))
|
|
1733
|
+
n_req = math.ceil(((z_alpha + z_power) / g) ** 2)
|
|
1734
|
+
# Achieved power on current n: invert the formula.
|
|
1735
|
+
z_eff = g * math.sqrt(n) - z_alpha
|
|
1736
|
+
achieved = max(alpha, min(0.999, _normal_cdf(z_eff)))
|
|
1737
|
+
if n >= n_req:
|
|
1738
|
+
interp = (
|
|
1739
|
+
f"With n={n} and observed g={g:.2f}, the study has "
|
|
1740
|
+
f"{achieved * 100:.0f}% power to detect this effect at α={alpha}. "
|
|
1741
|
+
f"Adequately powered."
|
|
1742
|
+
)
|
|
1743
|
+
else:
|
|
1744
|
+
interp = (
|
|
1745
|
+
f"On n={n} the study has only {achieved * 100:.0f}% power for "
|
|
1746
|
+
f"the observed effect (g={g:.2f}). "
|
|
1747
|
+
f"To reach {target_power * 100:.0f}% power at α={alpha}, "
|
|
1748
|
+
f"increase the benchmark to n ≥ {n_req}."
|
|
1749
|
+
)
|
|
1750
|
+
return PowerAnalysis(
|
|
1751
|
+
observed_effect_g=round(g, 4),
|
|
1752
|
+
n_current=n,
|
|
1753
|
+
alpha=alpha,
|
|
1754
|
+
target_power=target_power,
|
|
1755
|
+
n_required=n_req,
|
|
1756
|
+
achieved_power=round(achieved, 4),
|
|
1757
|
+
interpretation=interp,
|
|
1758
|
+
)
|
|
1759
|
+
|
|
1760
|
+
|
|
1761
|
+
def _build_structured_verdict(
|
|
1762
|
+
*,
|
|
1763
|
+
hm_per_q: list[float],
|
|
1764
|
+
best_base_per_q: list[float],
|
|
1765
|
+
delta_ci: ConfidenceInterval,
|
|
1766
|
+
p_raw: float,
|
|
1767
|
+
p_adjusted: float,
|
|
1768
|
+
alpha: float = 0.05,
|
|
1769
|
+
) -> Verdict:
|
|
1770
|
+
"""Apply the verdict decision rule and return a structured Verdict.
|
|
1771
|
+
|
|
1772
|
+
The rule is intentionally conservative: claiming WIN/strong requires
|
|
1773
|
+
BOTH the CI to exclude zero AND a non-trivial effect size. Single
|
|
1774
|
+
significant-p without effect-size support gets WIN/moderate at most.
|
|
1775
|
+
Hedges' g and Cliff's δ are reported alongside Cohen's d.
|
|
1776
|
+
"""
|
|
1777
|
+
if not hm_per_q or len(hm_per_q) != len(best_base_per_q):
|
|
1778
|
+
return Verdict(
|
|
1779
|
+
headline="NO-EVIDENCE",
|
|
1780
|
+
confidence="tentative",
|
|
1781
|
+
primary_metric="Brier",
|
|
1782
|
+
delta=0.0,
|
|
1783
|
+
delta_ci_low=0.0,
|
|
1784
|
+
delta_ci_high=0.0,
|
|
1785
|
+
p_value_raw=1.0,
|
|
1786
|
+
p_value_adjusted=1.0,
|
|
1787
|
+
effect_size_d=0.0,
|
|
1788
|
+
effect_size_g=0.0,
|
|
1789
|
+
effect_size_cliffs_d=0.0,
|
|
1790
|
+
effect_size_cles=0.5,
|
|
1791
|
+
n_required_for_significance=0,
|
|
1792
|
+
primary_reason="No data.",
|
|
1793
|
+
)
|
|
1794
|
+
delta = delta_ci.point # baseline - hypermind; positive = HyperMind better
|
|
1795
|
+
d = _cohens_d(hm_per_q, best_base_per_q)
|
|
1796
|
+
g = _hedges_g(hm_per_q, best_base_per_q)
|
|
1797
|
+
cliffs = _cliffs_delta(hm_per_q, best_base_per_q)
|
|
1798
|
+
cles = _common_language_effect_size(hm_per_q, best_base_per_q)
|
|
1799
|
+
pa = _power_analysis(hm_per_q, best_base_per_q, alpha=alpha)
|
|
1800
|
+
|
|
1801
|
+
ci_excludes_zero = (delta_ci.low > 0 and delta_ci.high > 0) or (
|
|
1802
|
+
delta_ci.low < 0 and delta_ci.high < 0
|
|
1803
|
+
)
|
|
1804
|
+
win_direction = delta > 0 # HyperMind has lower Brier (better)
|
|
1805
|
+
|
|
1806
|
+
if win_direction and ci_excludes_zero and abs(g) >= 0.2:
|
|
1807
|
+
headline, conf = "WIN", "strong"
|
|
1808
|
+
reason = (
|
|
1809
|
+
f"95% CI on the Brier delta excludes zero ({delta_ci.low:+.4f}, "
|
|
1810
|
+
f"{delta_ci.high:+.4f}) and Hedges' g={g:+.2f} indicates a "
|
|
1811
|
+
f"non-trivial effect."
|
|
1812
|
+
)
|
|
1813
|
+
elif win_direction and p_adjusted < alpha and abs(g) >= 0.2:
|
|
1814
|
+
headline, conf = "WIN", "moderate"
|
|
1815
|
+
reason = (
|
|
1816
|
+
f"Adjusted p={p_adjusted:.4f} < α={alpha} and effect size "
|
|
1817
|
+
f"g={g:+.2f} ≥ 0.2; CI does not yet exclude zero."
|
|
1818
|
+
)
|
|
1819
|
+
elif win_direction and cliffs > 0.1:
|
|
1820
|
+
headline, conf = "WIN", "tentative"
|
|
1821
|
+
reason = (
|
|
1822
|
+
f"Direction-of-effect favours HyperMind (Cliff's δ={cliffs:+.2f}) "
|
|
1823
|
+
f"but the result is not yet statistically supported."
|
|
1824
|
+
)
|
|
1825
|
+
elif abs(delta_ci.low) <= 0.02 and abs(delta_ci.high) <= 0.02:
|
|
1826
|
+
headline, conf = "TIE", "strong"
|
|
1827
|
+
reason = (
|
|
1828
|
+
f"95% CI on delta is tight ({delta_ci.low:+.4f}, "
|
|
1829
|
+
f"{delta_ci.high:+.4f}) — the forecasters are practically "
|
|
1830
|
+
f"equivalent on this benchmark."
|
|
1831
|
+
)
|
|
1832
|
+
elif not win_direction:
|
|
1833
|
+
headline, conf = "LOSE", "moderate" if p_adjusted < alpha else "tentative"
|
|
1834
|
+
reason = (
|
|
1835
|
+
f"Best baseline outperforms HyperMind on Brier "
|
|
1836
|
+
f"(delta {delta:+.4f}). Investigate role parameters or "
|
|
1837
|
+
f"prompt design."
|
|
1838
|
+
)
|
|
1839
|
+
else:
|
|
1840
|
+
headline, conf = "NO-EVIDENCE", "tentative"
|
|
1841
|
+
reason = (
|
|
1842
|
+
f"Direction favours HyperMind but the evidence is too weak "
|
|
1843
|
+
f"to call (g={g:+.2f}, adjusted p={p_adjusted:.4f}). "
|
|
1844
|
+
f"Need n ≥ {pa.n_required} for {int(pa.target_power * 100)}% power."
|
|
1845
|
+
)
|
|
1846
|
+
|
|
1847
|
+
return Verdict(
|
|
1848
|
+
headline=headline,
|
|
1849
|
+
confidence=conf,
|
|
1850
|
+
primary_metric="Brier",
|
|
1851
|
+
delta=round(delta, 4),
|
|
1852
|
+
delta_ci_low=round(delta_ci.low, 4),
|
|
1853
|
+
delta_ci_high=round(delta_ci.high, 4),
|
|
1854
|
+
p_value_raw=round(p_raw, 5),
|
|
1855
|
+
p_value_adjusted=round(p_adjusted, 5),
|
|
1856
|
+
effect_size_d=round(d, 4),
|
|
1857
|
+
effect_size_g=round(g, 4),
|
|
1858
|
+
effect_size_cliffs_d=round(cliffs, 4),
|
|
1859
|
+
effect_size_cles=round(cles, 4),
|
|
1860
|
+
n_required_for_significance=pa.n_required,
|
|
1861
|
+
primary_reason=reason,
|
|
1862
|
+
)
|
|
1863
|
+
|
|
1864
|
+
|
|
1865
|
+
# --- Run function ---------------------------------------------------------
|
|
1866
|
+
|
|
1867
|
+
|
|
1868
|
+
async def _run_evaluation(args: Any, recorder: Any | None = None) -> None:
|
|
1869
|
+
payload = getattr(args, "mode_payload", None) or {}
|
|
1870
|
+
label = (payload.get("benchmark_label") or "").strip() or "Synthetic 20-question benchmark"
|
|
1871
|
+
raw_q = payload.get("questions") or []
|
|
1872
|
+
if isinstance(raw_q, str):
|
|
1873
|
+
raw_q = [raw_q]
|
|
1874
|
+
questions = _parse_questions(list(raw_q))
|
|
1875
|
+
if not questions:
|
|
1876
|
+
questions = _seed_questions()
|
|
1877
|
+
n_boot = max(200, min(5000, int(payload.get("n_bootstrap") or 2000)))
|
|
1878
|
+
alpha = float(payload.get("alpha") or 0.05)
|
|
1879
|
+
use_real_llm = bool(payload.get("use_real_llm"))
|
|
1880
|
+
llm_model = str(payload.get("model") or "openai/gpt-4o-mini")
|
|
1881
|
+
|
|
1882
|
+
started_at = time.time()
|
|
1883
|
+
salt = hashlib.sha256(label.encode()).hexdigest()[:16]
|
|
1884
|
+
|
|
1885
|
+
# Run all six forecasters on every question. The first three are
|
|
1886
|
+
# the protocol-relevant model forecasters; the last three are the
|
|
1887
|
+
# "honest baselines" (Murphy & Winkler 1987) that anchor skill scores.
|
|
1888
|
+
actuals = [a for _, a in questions]
|
|
1889
|
+
n_yes = sum(actuals)
|
|
1890
|
+
n_total = len(actuals)
|
|
1891
|
+
base_rate = n_yes / max(n_total, 1)
|
|
1892
|
+
category_base_rates = _compute_category_base_rates(questions)
|
|
1893
|
+
|
|
1894
|
+
hm_pred: list[float] = []
|
|
1895
|
+
sl_pred: list[float] = []
|
|
1896
|
+
ne_pred: list[float] = []
|
|
1897
|
+
rand_pred: list[float] = []
|
|
1898
|
+
maj_pred: list[float] = []
|
|
1899
|
+
clim_pred: list[float] = []
|
|
1900
|
+
hm_role_posts: list[dict[str, float]] = []
|
|
1901
|
+
real_llm_costs: dict[str, dict[str, Any]] | None = None
|
|
1902
|
+
real_llm_used = False
|
|
1903
|
+
|
|
1904
|
+
# Real-LLM path: attempt the 3 model-based forecasters via OpenRouter.
|
|
1905
|
+
# Falls back to synthetic on any failure (no API key, import error,
|
|
1906
|
+
# request error). Honest baselines are always synthetic regardless.
|
|
1907
|
+
if use_real_llm:
|
|
1908
|
+
from . import _eval_real_llm
|
|
1909
|
+
|
|
1910
|
+
real_result = await _eval_real_llm.run_real_llm_forecasters(
|
|
1911
|
+
questions,
|
|
1912
|
+
llm_model,
|
|
1913
|
+
)
|
|
1914
|
+
if real_result is not None:
|
|
1915
|
+
real_llm_used = True
|
|
1916
|
+
hm_pred = real_result["hypermind_pred"]
|
|
1917
|
+
hm_role_posts = real_result["hypermind_role_posts"]
|
|
1918
|
+
sl_pred = real_result["single_llm_pred"]
|
|
1919
|
+
ne_pred = real_result["naive_ensemble_pred"]
|
|
1920
|
+
real_llm_costs = real_result["costs"]
|
|
1921
|
+
|
|
1922
|
+
# Synthetic fallback for the model-based forecasters when real-LLM
|
|
1923
|
+
# was disabled OR errored out. Honest baselines are always computed.
|
|
1924
|
+
if not real_llm_used:
|
|
1925
|
+
for q, a in questions:
|
|
1926
|
+
hp, roles = _hypermind_posterior(q, a, salt)
|
|
1927
|
+
hm_pred.append(hp)
|
|
1928
|
+
hm_role_posts.append(roles)
|
|
1929
|
+
sl_pred.append(_single_llm_posterior(q, a, salt))
|
|
1930
|
+
ne_pred.append(_naive_ensemble_posterior(q, a, salt))
|
|
1931
|
+
|
|
1932
|
+
# Honest baselines (always synthetic — they have no model).
|
|
1933
|
+
for q, a in questions:
|
|
1934
|
+
rand_pred.append(_random_posterior(q, a, salt))
|
|
1935
|
+
maj_pred.append(_majority_posterior(q, a, salt, base_rate))
|
|
1936
|
+
clim_pred.append(_climatology_posterior(q, a, salt, category_base_rates))
|
|
1937
|
+
|
|
1938
|
+
# Per-forecaster metrics
|
|
1939
|
+
def _metrics_for(name: str, label: str, pred: list[float]) -> ForecasterMetrics:
|
|
1940
|
+
per_q = [_brier(p, a) for p, a in zip(pred, actuals, strict=False)]
|
|
1941
|
+
per_l = [_log_loss(p, a) for p, a in zip(pred, actuals, strict=False)]
|
|
1942
|
+
ci = _bootstrap_brier_ci(per_q, n_boot, hash(name + salt) & 0xFFFF_FFFF)
|
|
1943
|
+
rel, ece, mce = _reliability(pred, actuals)
|
|
1944
|
+
acc50 = sum(1 for p, a in zip(pred, actuals, strict=False) if (p >= 0.5) == bool(a)) / max(len(pred), 1)
|
|
1945
|
+
return ForecasterMetrics(
|
|
1946
|
+
name=name,
|
|
1947
|
+
label=label,
|
|
1948
|
+
brier=ci,
|
|
1949
|
+
log_loss=round(sum(per_l) / max(len(per_l), 1), 4),
|
|
1950
|
+
accuracy_at_50=round(acc50, 4),
|
|
1951
|
+
ece=ece,
|
|
1952
|
+
mce=mce,
|
|
1953
|
+
reliability=rel,
|
|
1954
|
+
per_question_brier=[round(b, 4) for b in per_q],
|
|
1955
|
+
per_question_predicted=[round(p, 4) for p in pred],
|
|
1956
|
+
per_question_actual=actuals,
|
|
1957
|
+
)
|
|
1958
|
+
|
|
1959
|
+
hm = _metrics_for("hypermind", "HyperMind (4-role panel)", hm_pred)
|
|
1960
|
+
sl = _metrics_for("single_llm", "Single-LLM", sl_pred)
|
|
1961
|
+
ne = _metrics_for("naive_ensemble", "Naive ensemble (n=4)", ne_pred)
|
|
1962
|
+
rand = _metrics_for("random", "Random (p=0.5)", rand_pred)
|
|
1963
|
+
maj = _metrics_for("majority", f"Majority (base rate {base_rate:.2f})", maj_pred)
|
|
1964
|
+
clim = _metrics_for("climatology", "Climatology (per-category base rate)", clim_pred)
|
|
1965
|
+
forecasters = [hm, sl, ne, rand, maj, clim]
|
|
1966
|
+
|
|
1967
|
+
# Murphy decomposition per forecaster.
|
|
1968
|
+
murphy_decomp_rows: list[dict] = []
|
|
1969
|
+
for fm in forecasters:
|
|
1970
|
+
rel, res, unc = _murphy_decomposition(
|
|
1971
|
+
fm.per_question_predicted,
|
|
1972
|
+
fm.per_question_actual,
|
|
1973
|
+
)
|
|
1974
|
+
murphy_decomp_rows.append(
|
|
1975
|
+
{
|
|
1976
|
+
"name": fm.name,
|
|
1977
|
+
"label": fm.label,
|
|
1978
|
+
"reliability": rel,
|
|
1979
|
+
"resolution": res,
|
|
1980
|
+
"uncertainty": unc,
|
|
1981
|
+
}
|
|
1982
|
+
)
|
|
1983
|
+
|
|
1984
|
+
# Skill scores: BSS = (baseline_brier − model_brier) / baseline_brier.
|
|
1985
|
+
# We compute vs climatology (the "informed prior" reference) and vs
|
|
1986
|
+
# majority (the "no-skill" reference from the literature).
|
|
1987
|
+
skill_score_rows: list[dict] = []
|
|
1988
|
+
for fm in forecasters:
|
|
1989
|
+
skill_score_rows.append(
|
|
1990
|
+
{
|
|
1991
|
+
"name": fm.name,
|
|
1992
|
+
"label": fm.label,
|
|
1993
|
+
"bss_vs_climatology": _skill_score(fm.brier.point, clim.brier.point),
|
|
1994
|
+
"bss_vs_majority": _skill_score(fm.brier.point, maj.brier.point),
|
|
1995
|
+
"bss_vs_random": _skill_score(fm.brier.point, rand.brier.point),
|
|
1996
|
+
}
|
|
1997
|
+
)
|
|
1998
|
+
|
|
1999
|
+
# Pairwise comparisons: HyperMind vs each baseline (LLM-style + honest).
|
|
2000
|
+
# Multiple-comparisons correction is applied across the family.
|
|
2001
|
+
def _compare(a: ForecasterMetrics, b: ForecasterMetrics) -> PairwiseComparison:
|
|
2002
|
+
delta_ci = _bootstrap_delta_ci(
|
|
2003
|
+
a.per_question_brier,
|
|
2004
|
+
b.per_question_brier,
|
|
2005
|
+
n_boot,
|
|
2006
|
+
hash(f"delta::{a.name}::{b.name}::{salt}") & 0xFFFF_FFFF,
|
|
2007
|
+
)
|
|
2008
|
+
cohens = _cohens_d(a.per_question_brier, b.per_question_brier)
|
|
2009
|
+
lift = (1 - a.brier.point / b.brier.point) * 100 if b.brier.point > 0 else 0.0
|
|
2010
|
+
t = _paired_t_test(a.per_question_brier, b.per_question_brier)
|
|
2011
|
+
w = _wilcoxon_signed_rank(a.per_question_brier, b.per_question_brier)
|
|
2012
|
+
mn = _mcnemar(a.per_question_predicted, b.per_question_predicted, actuals)
|
|
2013
|
+
return PairwiseComparison(
|
|
2014
|
+
a_name=a.label,
|
|
2015
|
+
b_name=b.label,
|
|
2016
|
+
mean_brier_delta=round(delta_ci.point, 4),
|
|
2017
|
+
mean_brier_delta_ci=delta_ci,
|
|
2018
|
+
cohens_d=cohens,
|
|
2019
|
+
lift_pct=round(lift, 2),
|
|
2020
|
+
paired_t=t,
|
|
2021
|
+
wilcoxon=w,
|
|
2022
|
+
mcnemar=mn,
|
|
2023
|
+
)
|
|
2024
|
+
|
|
2025
|
+
pairwise = [
|
|
2026
|
+
_compare(hm, sl),
|
|
2027
|
+
_compare(hm, ne),
|
|
2028
|
+
_compare(hm, rand),
|
|
2029
|
+
_compare(hm, maj),
|
|
2030
|
+
_compare(hm, clim),
|
|
2031
|
+
]
|
|
2032
|
+
|
|
2033
|
+
# Multiple-comparisons correction across the family of pairwise
|
|
2034
|
+
# paired-t tests. BH-FDR is the headline; Holm is computed too for
|
|
2035
|
+
# readers who want strict FWER control.
|
|
2036
|
+
raw_ps = [c.paired_t.p_value for c in pairwise]
|
|
2037
|
+
bh = _benjamini_hochberg(raw_ps, alpha=alpha)
|
|
2038
|
+
bh_fdr_adjusted = [(round(raw_ps[i], 5), bh[i][0], bh[i][1]) for i in range(len(raw_ps))]
|
|
2039
|
+
|
|
2040
|
+
# Ablation: drop each role from the HyperMind consensus
|
|
2041
|
+
ablation: list[AblationRow] = []
|
|
2042
|
+
full_brier = sum(hm.per_question_brier) / max(len(hm.per_question_brier), 1)
|
|
2043
|
+
for role in ROLES:
|
|
2044
|
+
without_pred: list[float] = []
|
|
2045
|
+
for posts in hm_role_posts:
|
|
2046
|
+
remaining = {k: v for k, v in posts.items() if k != role}
|
|
2047
|
+
without_pred.append(sum(remaining.values()) / len(remaining))
|
|
2048
|
+
without_briers = [_brier(p, a) for p, a in zip(without_pred, actuals, strict=False)]
|
|
2049
|
+
b_without = sum(without_briers) / max(len(without_briers), 1)
|
|
2050
|
+
delta = b_without - full_brier
|
|
2051
|
+
if delta > 0.01:
|
|
2052
|
+
label_a = "load-bearing"
|
|
2053
|
+
elif delta < -0.01:
|
|
2054
|
+
label_a = "drags"
|
|
2055
|
+
else:
|
|
2056
|
+
label_a = "neutral"
|
|
2057
|
+
ablation.append(
|
|
2058
|
+
AblationRow(
|
|
2059
|
+
role_dropped=role,
|
|
2060
|
+
brier_without=round(b_without, 4),
|
|
2061
|
+
brier_delta_vs_full=round(delta, 4),
|
|
2062
|
+
contribution_label=label_a,
|
|
2063
|
+
)
|
|
2064
|
+
)
|
|
2065
|
+
|
|
2066
|
+
# Difficulty stratification
|
|
2067
|
+
strata_buckets: dict[str, list[int]] = {"easy": [], "medium": [], "hard": []}
|
|
2068
|
+
for i, (_, a) in enumerate(questions):
|
|
2069
|
+
strata_buckets[_difficulty(a, n_yes, n_total)].append(i)
|
|
2070
|
+
strata: list[DifficultyBucket] = []
|
|
2071
|
+
best_baseline_per_q = [min(s, n) for s, n in zip(sl.per_question_brier, ne.per_question_brier, strict=False)]
|
|
2072
|
+
for stratum_label, idxs in strata_buckets.items():
|
|
2073
|
+
if not idxs:
|
|
2074
|
+
continue
|
|
2075
|
+
hm_b = sum(hm.per_question_brier[i] for i in idxs) / len(idxs)
|
|
2076
|
+
bb_b = sum(best_baseline_per_q[i] for i in idxs) / len(idxs)
|
|
2077
|
+
lift = (1 - hm_b / bb_b) * 100 if bb_b > 0 else 0.0
|
|
2078
|
+
strata.append(
|
|
2079
|
+
DifficultyBucket(
|
|
2080
|
+
label=stratum_label,
|
|
2081
|
+
n_questions=len(idxs),
|
|
2082
|
+
hypermind_brier=round(hm_b, 4),
|
|
2083
|
+
best_baseline_brier=round(bb_b, 4),
|
|
2084
|
+
lift_pct=round(lift, 2),
|
|
2085
|
+
)
|
|
2086
|
+
)
|
|
2087
|
+
# Sort hard → medium → easy so the "hardest" lift shows first
|
|
2088
|
+
order = {"hard": 0, "medium": 1, "easy": 2}
|
|
2089
|
+
strata.sort(key=lambda s: order[s.label])
|
|
2090
|
+
|
|
2091
|
+
# Headline numbers
|
|
2092
|
+
best_baseline_brier = min(sl.brier.point, ne.brier.point)
|
|
2093
|
+
headline_lift = (
|
|
2094
|
+
(1 - hm.brier.point / best_baseline_brier) * 100 if best_baseline_brier > 0 else 0.0
|
|
2095
|
+
)
|
|
2096
|
+
best_baseline_pq = (
|
|
2097
|
+
sl.per_question_brier if sl.brier.point < ne.brier.point else ne.per_question_brier
|
|
2098
|
+
)
|
|
2099
|
+
headline_t = _paired_t_test(hm.per_question_brier, best_baseline_pq)
|
|
2100
|
+
delta_vs_best = sum(b - a for a, b in zip(hm.per_question_brier, best_baseline_pq, strict=False)) / max(
|
|
2101
|
+
len(hm.per_question_brier), 1
|
|
2102
|
+
)
|
|
2103
|
+
headline_p = headline_t.p_value
|
|
2104
|
+
# Calibration-vs-Brier framing: HyperMind's primary claim is *better
|
|
2105
|
+
# calibration*, not always lower Brier. Compute ECE delta vs best baseline.
|
|
2106
|
+
best_base_ece = min(sl.ece, ne.ece)
|
|
2107
|
+
ece_delta = best_base_ece - hm.ece # positive = HyperMind better calibrated
|
|
2108
|
+
if headline_t.significant and headline_lift > 0:
|
|
2109
|
+
verdict = (
|
|
2110
|
+
f"HyperMind achieves **{headline_lift:.1f}% lower Brier** than "
|
|
2111
|
+
f"the best baseline (p={headline_p:.4f}). At α={alpha}, the "
|
|
2112
|
+
f"advantage is statistically significant. ECE delta vs best "
|
|
2113
|
+
f"baseline: {ece_delta:+.4f} (positive = better calibrated)."
|
|
2114
|
+
)
|
|
2115
|
+
elif headline_lift > 0:
|
|
2116
|
+
verdict = (
|
|
2117
|
+
f"HyperMind shows {headline_lift:.1f}% lower Brier than the "
|
|
2118
|
+
f"best baseline (p={headline_p:.4f}), not yet significant at "
|
|
2119
|
+
f"α={alpha}. ECE delta {ece_delta:+.4f}. Consider larger benchmark."
|
|
2120
|
+
)
|
|
2121
|
+
elif ece_delta > 0.02:
|
|
2122
|
+
verdict = (
|
|
2123
|
+
f"On Brier the best baseline is {-headline_lift:.1f}% lower, "
|
|
2124
|
+
f"but HyperMind shows materially better **calibration** "
|
|
2125
|
+
f"(ECE {hm.ece:.3f} vs best baseline {best_base_ece:.3f}, "
|
|
2126
|
+
f"Δ {ece_delta:+.4f}). When Brier and calibration disagree, the "
|
|
2127
|
+
f"calibrated forecaster is the safer pick: it's honest about "
|
|
2128
|
+
f"its uncertainty, even if a more confident model happens to "
|
|
2129
|
+
f"win on this benchmark."
|
|
2130
|
+
)
|
|
2131
|
+
else:
|
|
2132
|
+
verdict = (
|
|
2133
|
+
f"On this benchmark, HyperMind does not outperform the best "
|
|
2134
|
+
f"baseline (Brier lift {headline_lift:+.1f}%, p={headline_p:.4f}; "
|
|
2135
|
+
f"ECE Δ {ece_delta:+.4f}). Investigate role composition or "
|
|
2136
|
+
f"anti-herding parameters."
|
|
2137
|
+
)
|
|
2138
|
+
|
|
2139
|
+
# Reproducibility capsule + limitations.
|
|
2140
|
+
forecaster_versions = {
|
|
2141
|
+
"hypermind": "synthetic-v1" if not use_real_llm else f"openrouter:{llm_model}",
|
|
2142
|
+
"single_llm": "synthetic-v1" if not use_real_llm else f"openrouter:{llm_model}",
|
|
2143
|
+
"naive_ensemble": "synthetic-v1"
|
|
2144
|
+
if not use_real_llm
|
|
2145
|
+
else f"openrouter:{llm_model}-temp(0.3,0.5,0.7,0.9)",
|
|
2146
|
+
"random": "constant-0.5",
|
|
2147
|
+
"majority": f"base_rate={base_rate:.4f}",
|
|
2148
|
+
"climatology": "per-category base rate",
|
|
2149
|
+
}
|
|
2150
|
+
capsule = _build_reproducibility_capsule(
|
|
2151
|
+
benchmark_label=label,
|
|
2152
|
+
questions=questions,
|
|
2153
|
+
n_bootstrap=n_boot,
|
|
2154
|
+
alpha=alpha,
|
|
2155
|
+
salt=salt,
|
|
2156
|
+
forecaster_versions=forecaster_versions,
|
|
2157
|
+
deterministic_replay=not use_real_llm,
|
|
2158
|
+
)
|
|
2159
|
+
limitations = _emit_limitations(
|
|
2160
|
+
n=n_total,
|
|
2161
|
+
use_real_llm=use_real_llm,
|
|
2162
|
+
base_rate=base_rate,
|
|
2163
|
+
n_bootstrap=n_boot,
|
|
2164
|
+
)
|
|
2165
|
+
|
|
2166
|
+
# Structured verdict + power analysis — the headline visual on the page.
|
|
2167
|
+
# Build on the *best LLM-style* baseline (sl vs ne, whichever is tougher),
|
|
2168
|
+
# not the trivial baselines, so the verdict stays meaningful.
|
|
2169
|
+
best_llm_pq = (
|
|
2170
|
+
sl.per_question_brier if sl.brier.point < ne.brier.point else ne.per_question_brier
|
|
2171
|
+
)
|
|
2172
|
+
best_llm_idx = 0 if sl.brier.point < ne.brier.point else 1
|
|
2173
|
+
headline_delta_ci = pairwise[best_llm_idx].mean_brier_delta_ci
|
|
2174
|
+
structured_verdict = _build_structured_verdict(
|
|
2175
|
+
hm_per_q=hm.per_question_brier,
|
|
2176
|
+
best_base_per_q=best_llm_pq,
|
|
2177
|
+
delta_ci=headline_delta_ci,
|
|
2178
|
+
p_raw=headline_t.p_value,
|
|
2179
|
+
p_adjusted=bh[best_llm_idx][0],
|
|
2180
|
+
alpha=alpha,
|
|
2181
|
+
)
|
|
2182
|
+
power = _power_analysis(
|
|
2183
|
+
hm.per_question_brier,
|
|
2184
|
+
best_llm_pq,
|
|
2185
|
+
alpha=alpha,
|
|
2186
|
+
target_power=0.80,
|
|
2187
|
+
)
|
|
2188
|
+
|
|
2189
|
+
# IEEE markdown report — built last so it can reference the
|
|
2190
|
+
# capsule, limitations, verdict, and power-analysis sections.
|
|
2191
|
+
ieee_md = _ieee_report(
|
|
2192
|
+
label,
|
|
2193
|
+
forecasters,
|
|
2194
|
+
pairwise,
|
|
2195
|
+
ablation,
|
|
2196
|
+
strata,
|
|
2197
|
+
headline_lift,
|
|
2198
|
+
headline_p,
|
|
2199
|
+
verdict,
|
|
2200
|
+
n_total,
|
|
2201
|
+
n_boot,
|
|
2202
|
+
capsule=capsule,
|
|
2203
|
+
limitations=limitations,
|
|
2204
|
+
verdict_obj=structured_verdict,
|
|
2205
|
+
power=power,
|
|
2206
|
+
bh_fdr_adjusted=bh_fdr_adjusted,
|
|
2207
|
+
)
|
|
2208
|
+
|
|
2209
|
+
report = EvaluationReport(
|
|
2210
|
+
benchmark_label=label,
|
|
2211
|
+
n_questions=n_total,
|
|
2212
|
+
n_bootstrap_iters=n_boot,
|
|
2213
|
+
forecasters=forecasters,
|
|
2214
|
+
pairwise_comparisons=pairwise,
|
|
2215
|
+
ablation=ablation,
|
|
2216
|
+
difficulty_strata=strata,
|
|
2217
|
+
headline_lift_pct=round(headline_lift, 2),
|
|
2218
|
+
headline_brier_delta=round(delta_vs_best, 4),
|
|
2219
|
+
headline_p_value=headline_p,
|
|
2220
|
+
headline_verdict=verdict,
|
|
2221
|
+
ieee_report_md=ieee_md,
|
|
2222
|
+
reproducibility=capsule,
|
|
2223
|
+
limitations=limitations,
|
|
2224
|
+
verdict=structured_verdict,
|
|
2225
|
+
power_analysis=power,
|
|
2226
|
+
bh_fdr_adjusted=bh_fdr_adjusted,
|
|
2227
|
+
real_llm_used=real_llm_used,
|
|
2228
|
+
llm_costs=real_llm_costs or {},
|
|
2229
|
+
murphy_decomposition=murphy_decomp_rows,
|
|
2230
|
+
skill_scores=skill_score_rows,
|
|
2231
|
+
)
|
|
2232
|
+
|
|
2233
|
+
elapsed = time.time() - started_at
|
|
2234
|
+
trace = {
|
|
2235
|
+
"mode": "evaluation-bench",
|
|
2236
|
+
"started_at": started_at,
|
|
2237
|
+
"elapsed_s": round(elapsed, 3),
|
|
2238
|
+
"n_agents": 4, # HyperMind panel
|
|
2239
|
+
"n_questions": n_total,
|
|
2240
|
+
"benchmark_label": label,
|
|
2241
|
+
"report": asdict(report),
|
|
2242
|
+
"collective_iq": {
|
|
2243
|
+
"score": round(1 - hm.brier.point, 4),
|
|
2244
|
+
},
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
Path(args.trace).write_text(json.dumps(trace, indent=2, default=str))
|
|
2248
|
+
|
|
2249
|
+
|
|
2250
|
+
# --- Registration ----------------------------------------------------------
|
|
2251
|
+
|
|
2252
|
+
SPEC = ModeSpec(
|
|
2253
|
+
slug="evaluation-bench",
|
|
2254
|
+
label="Evaluation Bench (IEEE-level)",
|
|
2255
|
+
description=(
|
|
2256
|
+
"Publication-grade evaluation: HyperMind vs. single-LLM and "
|
|
2257
|
+
"naive-ensemble baselines on a benchmark question set. Produces "
|
|
2258
|
+
"Brier with bootstrap 95% CIs, ECE/MCE, reliability diagram, "
|
|
2259
|
+
"paired t-test + Wilcoxon + McNemar significance, Cohen's d "
|
|
2260
|
+
"effect size, drop-one-role ablation, difficulty stratification, "
|
|
2261
|
+
"and a downloadable IEEE-formatted markdown report."
|
|
2262
|
+
),
|
|
2263
|
+
input_schema=INPUT_SCHEMA,
|
|
2264
|
+
result_schema=RESULT_SCHEMA,
|
|
2265
|
+
run_fn=_run_evaluation,
|
|
2266
|
+
uses_legacy_fields=False,
|
|
2267
|
+
expected_roles=ROLES,
|
|
2268
|
+
)
|
|
2269
|
+
|
|
2270
|
+
|
|
2271
|
+
register(SPEC)
|