claimeval 1.0.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.
claimeval/__init__.py ADDED
@@ -0,0 +1,95 @@
1
+ """claimeval -- test the claims made about AI agents against the data behind them.
2
+
3
+ A claim ("#1", "beats X", "as good at lower cost", "on the cost-accuracy
4
+ frontier") gets the probability that it holds, from a bootstrap over the right
5
+ unit (tasks, or clusters such as repositories), and a verdict. Input is a
6
+ stream of (config, task, seed, success, cost[, group]) runs from any harness,
7
+ or a public leaderboard release read by `claimeval.core.adapters`. Also: frontier
8
+ membership and dominance probabilities, a Hausdorff frontier band, a
9
+ winner's-curse correction, wild cluster bootstrap intervals, and a task-vs-seed
10
+ variance decomposition.
11
+ """
12
+
13
+ import sys
14
+
15
+ # Subpackages
16
+ from . import core
17
+ from . import stats
18
+ from . import analysis
19
+ from . import sim
20
+
21
+ # Core data structures and loaders
22
+ from .core.schema import Rollout, RolloutSet
23
+ from .core.ingest import FieldMap, guess_map, inspect_path, load
24
+
25
+ # Statistical methods, frontier geometry & diagnostics
26
+ from .stats.frontier import dominance, frontier_band, frontier_points, hausdorff, log_cost_range, pareto_mask
27
+ from .stats.bootstrap import BootstrapResult, auto_mode, hierarchical_bootstrap, variance_decomposition
28
+ from .stats.wild import wild_intervals
29
+ from .stats.bias import seed_count_curve, split_sample_debias
30
+ from .stats.cost_axis import diagnose_cost_axis, format_diagnosis
31
+
32
+ # Analysis, claims & reporting
33
+ from .analysis.claims import Claim, evaluate_claims
34
+ from .analysis.report import analyze, format_report, headline
35
+
36
+ # Simulation & coverage validation
37
+ from .sim.simulate import make_population, population_truth, sample_dataset, simulate
38
+ from .sim.coverage import coverage_study, format_coverage
39
+
40
+ # Backward compatibility aliases for module paths
41
+ sys.modules["claimeval.schema"] = core.schema
42
+ sys.modules["claimeval.ingest"] = core.ingest
43
+ sys.modules["claimeval.adapters"] = core.adapters
44
+ sys.modules["claimeval.bootstrap"] = stats.bootstrap
45
+ sys.modules["claimeval.wild"] = stats.wild
46
+ sys.modules["claimeval.bca"] = stats.bca
47
+ sys.modules["claimeval.calibrate"] = stats.calibrate
48
+ sys.modules["claimeval.bias"] = stats.bias
49
+ sys.modules["claimeval.cost_axis"] = stats.cost_axis
50
+ sys.modules["claimeval.frontier"] = stats.frontier
51
+ sys.modules["claimeval.claims"] = analysis.claims
52
+ sys.modules["claimeval.compare"] = analysis.compare
53
+ sys.modules["claimeval.baselines"] = analysis.baselines
54
+ sys.modules["claimeval.report"] = analysis.report
55
+ sys.modules["claimeval.simulate"] = sim.simulate
56
+ sys.modules["claimeval.coverage"] = sim.coverage
57
+
58
+ __version__ = "1.0.0"
59
+
60
+ __all__ = [
61
+ "core",
62
+ "stats",
63
+ "analysis",
64
+ "sim",
65
+ "Claim",
66
+ "evaluate_claims",
67
+ "auto_mode",
68
+ "wild_intervals",
69
+ "Rollout",
70
+ "RolloutSet",
71
+ "FieldMap",
72
+ "guess_map",
73
+ "inspect_path",
74
+ "load",
75
+ "analyze",
76
+ "headline",
77
+ "format_report",
78
+ "hierarchical_bootstrap",
79
+ "BootstrapResult",
80
+ "variance_decomposition",
81
+ "pareto_mask",
82
+ "dominance",
83
+ "frontier_points",
84
+ "hausdorff",
85
+ "frontier_band",
86
+ "log_cost_range",
87
+ "split_sample_debias",
88
+ "seed_count_curve",
89
+ "simulate",
90
+ "make_population",
91
+ "sample_dataset",
92
+ "population_truth",
93
+ "coverage_study",
94
+ "format_coverage",
95
+ ]
claimeval/__main__.py ADDED
@@ -0,0 +1,177 @@
1
+ """CLI: claimeval {demo,inspect,analyze,claims,seed-curve,coverage}"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import glob
7
+ import os
8
+ import json
9
+ import sys
10
+
11
+ import numpy as np
12
+
13
+ from claimeval.stats.bias import seed_count_curve
14
+ from claimeval.core.ingest import FieldMap, guess_map, inspect_path, load
15
+ from claimeval.analysis.report import analyze
16
+ from claimeval.core.schema import RolloutSet
17
+ from claimeval.sim.simulate import simulate
18
+
19
+
20
+ def _load(args) -> RolloutSet:
21
+ src = getattr(args, "source", None)
22
+ if src:
23
+ from claimeval.core import adapters
24
+ if src == "tau2":
25
+ rs, skipped = adapters.tau2(sorted(glob.glob(os.path.join(args.path, "*.json")))
26
+ if os.path.isdir(args.path) else [args.path])
27
+ elif src == "swebench-cost":
28
+ rs, skipped = adapters.swebench(args.path, with_cost=True)
29
+ else:
30
+ rs, skipped = {"swebench": adapters.swebench,
31
+ "terminal-bench": adapters.terminal_bench}[src](args.path)
32
+ for k, v in skipped.items():
33
+ print(f"[skipped] {k}: {v}", file=sys.stderr)
34
+ return rs
35
+ if args.map:
36
+ fmap = FieldMap.from_dict(json.loads(open(args.map).read()))
37
+ else:
38
+ info = inspect_path(args.path)
39
+ fmap = info["guess"]
40
+ print(f"[guessed mapping] {fmap}\n", file=sys.stderr)
41
+ rs, dropped = load(args.path, fmap)
42
+ if dropped:
43
+ print(f"[dropped records] {dropped}\n", file=sys.stderr)
44
+ return rs
45
+
46
+
47
+ def _print_curve(rows: list[dict]) -> None:
48
+ print(f"\n{'k':>3}{'Jaccard vs full':>18}{'Hausdorff':>12}"
49
+ f"{'acc inflation':>16}{'cost inflation':>16}{'|front|':>9}")
50
+ print("-" * 74)
51
+ for r in rows:
52
+ print(
53
+ f"{r['k']:>3}{r['jaccard_vs_full']:>18.3f}{r['hausdorff_vs_full']:>12.4f}"
54
+ f"{r['acc_inflation']:>+16.4f}{r['cost_inflation']:>+16.4f}"
55
+ f"{r['frontier_size']:>9.1f}"
56
+ )
57
+ print("\nacc inflation = what a k-seed study claims for its own frontier winners,")
58
+ print("minus what the full data says about those same configs.")
59
+
60
+
61
+ def main(argv: list[str] | None = None) -> int:
62
+ ap = argparse.ArgumentParser(prog="claimeval")
63
+ sub = ap.add_subparsers(dest="cmd", required=True)
64
+
65
+ d = sub.add_parser("demo", help="end-to-end run on simulated data")
66
+ d.add_argument("--seeds", type=int, default=20)
67
+ d.add_argument("--tasks", type=int, default=50)
68
+ d.add_argument("--configs", type=int, default=9)
69
+ d.add_argument("--boot", type=int, default=2000)
70
+
71
+ i = sub.add_parser("inspect", help="discover the schema of a results dump")
72
+ i.add_argument("path")
73
+
74
+ a = sub.add_parser("analyze", help="audit frontier claims in a results dump")
75
+ a.add_argument("path")
76
+ a.add_argument("--map", help="JSON field mapping; omit to auto-guess")
77
+ a.add_argument("--from", dest="source", choices=["swebench", "swebench-cost", "terminal-bench", "tau2"],
78
+ help="read a public leaderboard release directly (see claimeval.adapters)")
79
+
80
+ a.add_argument("--boot", type=int, default=2000)
81
+ a.add_argument("--threshold", type=float, default=0.90)
82
+
83
+ c = sub.add_parser("claims", help="test published claims (top1, pairwise, margin, "
84
+ "cost_dominance, frontier) against a results dump")
85
+ c.add_argument("path")
86
+ c.add_argument("claims", help="JSON list of {kind, subject, comparators, margin, id}")
87
+ c.add_argument("--map")
88
+ c.add_argument("--from", dest="source", choices=["swebench", "swebench-cost", "terminal-bench", "tau2"],
89
+ help="read a public leaderboard release directly (see claimeval.adapters)")
90
+
91
+ c.add_argument("--boot", type=int, default=4000)
92
+ c.add_argument("--threshold", type=float, default=0.90)
93
+ c.add_argument("--mode", default=None,
94
+ choices=["task", "cluster_t"],
95
+ help="resampling unit; default: clusters if the data has them, else tasks")
96
+
97
+ s = sub.add_parser("seed-curve", help="error of a k-seed frontier vs the full data")
98
+ s.add_argument("path")
99
+ s.add_argument("--map")
100
+ s.add_argument("--ks", default="1,2,3,5,10")
101
+
102
+ cv = sub.add_parser("coverage", help="validate the estimator against known truth")
103
+ cv.add_argument("--reps", type=int, default=200)
104
+ cv.add_argument("--configs", type=int, default=9)
105
+ cv.add_argument("--tasks", type=int, default=50)
106
+ cv.add_argument("--seeds", type=int, default=5)
107
+ cv.add_argument("--boot", type=int, default=500)
108
+ cv.add_argument("--level", type=float, default=0.95)
109
+ cv.add_argument("--threshold", type=float, default=0.90)
110
+ cv.add_argument("--mode", default="task", choices=["task", "both", "seed"],
111
+ help="bootstrap mode; 'both' double-counts within-cell noise")
112
+
113
+ args = ap.parse_args(argv)
114
+ rng = np.random.default_rng(0)
115
+
116
+ if args.cmd == "demo":
117
+ rolls, truth = simulate(args.configs, args.tasks, args.seeds, rng)
118
+ rs = RolloutSet(rolls)
119
+ report, head = analyze(rs, n_boot=args.boot, rng=rng)
120
+ print(report)
121
+ print("\nGround truth accuracy (simulator):")
122
+ for c, t in zip(truth["configs"], truth["accuracy"]):
123
+ print(f" {c:<26}{t:.3f}")
124
+ _print_curve(seed_count_curve(rs, (1, 2, 3, 5, 10), n_repeats=30, rng=rng))
125
+ return 0
126
+
127
+ if args.cmd == "coverage":
128
+ from claimeval.sim.coverage import coverage_study, format_coverage
129
+
130
+ res = coverage_study(
131
+ n_reps=args.reps, n_configs=args.configs, n_tasks=args.tasks,
132
+ n_seeds=args.seeds, n_boot=args.boot, level=args.level,
133
+ threshold=args.threshold, boot_mode=args.mode,
134
+ )
135
+ print(format_coverage(res))
136
+ return 0
137
+
138
+ if args.cmd == "inspect":
139
+ info = inspect_path(args.path)
140
+ print(f"{info['n_records']} records scanned\n")
141
+ print(f"{'field':<46}{'count':>8} samples")
142
+ print("-" * 78)
143
+ for k, n in info["keys"].most_common(60):
144
+ ex = ", ".join(str(v)[:18] for v in info["samples"].get(k, []))
145
+ print(f"{k[:46]:<46}{n:>8} {ex[:24]}")
146
+ print(f"\nguessed mapping: {info['guess']}")
147
+ print("Verify this, then pass it with --map. Wrong success/cost fields")
148
+ print("produce a confident, meaningless frontier.")
149
+ return 0
150
+
151
+ rs = _load(args)
152
+ if args.cmd == "claims":
153
+ from claimeval.analysis.claims import Claim, evaluate_claims
154
+
155
+ spec = json.loads(open(args.claims).read())
156
+ cl = [Claim(x["kind"], x["subject"], x.get("comparators", []), x.get("margin", 0.0),
157
+ x.get("id", ""), x.get("k", 1)) for x in spec]
158
+ res = evaluate_claims(rs, cl, n_boot=args.boot, mode=args.mode, rng=rng, thr=args.threshold)
159
+ print(f"{'id':<10}{'kind':<16}{'subject':<28}{'P(claim)':>9} verdict")
160
+ for r in res:
161
+ v = "supported" if r["supported"] else (
162
+ "NOT supported" if r["holds_at_point_estimate"] else "contradicted by point estimate")
163
+ print(f"{r['id'][:9]:<10}{r['kind']:<16}{r['subject'][:27]:<28}{r['probability']:>9.3f} {v}")
164
+ print(f"\nsupported = P >= {args.threshold}; resampling: {res[0]['mode'] if res else '-'}")
165
+ return 0
166
+ if args.cmd == "analyze":
167
+ report, _ = analyze(rs, n_boot=args.boot, threshold=args.threshold, rng=rng)
168
+ print(report)
169
+ else:
170
+ ks = tuple(int(x) for x in args.ks.split(","))
171
+ print(rs.describe())
172
+ _print_curve(seed_count_curve(rs, ks, n_repeats=30, rng=rng))
173
+ return 0
174
+
175
+
176
+ if __name__ == "__main__":
177
+ raise SystemExit(main())
@@ -0,0 +1,26 @@
1
+ """Claim evaluation, comparison, and reporting."""
2
+
3
+ from claimeval.stats.frontier import dominance, frontier_band, frontier_points, hausdorff, log_cost_range, pareto_mask
4
+ from .claims import Claim, evaluate_claims
5
+ from .compare import Scenario, compare, format_rows
6
+ from .baselines import METHODS, run_method
7
+ from .report import analyze, format_report, headline
8
+
9
+ __all__ = [
10
+ "Claim",
11
+ "evaluate_claims",
12
+ "dominance",
13
+ "frontier_band",
14
+ "frontier_points",
15
+ "hausdorff",
16
+ "log_cost_range",
17
+ "pareto_mask",
18
+ "Scenario",
19
+ "compare",
20
+ "format_rows",
21
+ "METHODS",
22
+ "run_method",
23
+ "analyze",
24
+ "format_report",
25
+ "headline",
26
+ ]
@@ -0,0 +1,135 @@
1
+ """Existing uncertainty methods, in the same output format as ours.
2
+
3
+ Every method returns a BootstrapResult: B joint draws of (accuracy, cost) for
4
+ all configurations. Frontier-membership and dominance probabilities, intervals
5
+ and certified claims are then computed by the SAME code for every method, so a
6
+ comparison measures the error model and nothing else.
7
+
8
+ Methods (paper names in brackets):
9
+
10
+ naive [current practice] point estimates, no uncertainty.
11
+ wald [per-model error bars] independent normal intervals per
12
+ configuration, tasks treated as independent. The +/- usually
13
+ printed next to a leaderboard score.
14
+ paired_clt [Miller 2024, paired] joint normal over all configurations
15
+ with the full task-level covariance, so comparisons are
16
+ paired. Tasks independent.
17
+ clustered_clt [Miller 2024, clustered] as paired_clt with cluster-robust
18
+ (sandwich) covariance over task groups, small-sample
19
+ corrected. The strongest formula-based competitor.
20
+ boot_both [naive bootstrap] resample tasks and runs together.
21
+ boot_task [ours, unclustered] task resampling.
22
+ boot_cluster_t [ours, clustered] intact clusters + few-cluster widening.
23
+ boot_group [our earlier version] two-stage; double-counts within-cluster noise.
24
+ boot_cluster [ablation] intact clusters, no widening.
25
+
26
+ The formula methods have no native notion of P(on frontier). They get the
27
+ fairest extension available: draws from their own sampling distribution,
28
+ pushed through the same frontier code (a parametric bootstrap).
29
+
30
+ Median cost has no simple standard error. The formula methods use its
31
+ influence function, psi = (1/2 - 1{x <= m}) / f(m), with a Gaussian kernel
32
+ density estimate of f at the median -- the textbook delta-method route.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import warnings
38
+
39
+ import numpy as np
40
+
41
+ from claimeval.stats.bootstrap import BootstrapResult, hierarchical_bootstrap
42
+ from claimeval.core.schema import RolloutSet
43
+
44
+ METHODS = ("naive", "wald", "paired_clt", "clustered_clt", "boot_both", "boot_task", "boot_group",
45
+ "boot_cluster", "boot_cluster_t", "boot_cluster_cr2", "boot_cluster_kish")
46
+
47
+
48
+ def _task_means(rs: RolloutSet) -> tuple[np.ndarray, np.ndarray]:
49
+ """(C, T) per-task mean success and cost, averaged over runs."""
50
+ with warnings.catch_warnings():
51
+ warnings.simplefilter("ignore", RuntimeWarning)
52
+ return np.nanmean(rs.success, axis=2), np.nanmean(rs.cost, axis=2)
53
+
54
+
55
+ def _cost_influence(cost_t: np.ndarray, stat: str) -> tuple[np.ndarray, np.ndarray]:
56
+ """Point estimate and per-task influence values for the cost statistic.
57
+
58
+ Returns (est (C,), psi (C, T)) such that est is approximately the mean of
59
+ est + psi, i.e. psi plays the role of centred per-task contributions.
60
+ """
61
+ if stat == "mean":
62
+ est = np.nanmean(cost_t, axis=1)
63
+ return est, cost_t - est[:, None]
64
+ est = np.nanmedian(cost_t, axis=1)
65
+ psi = np.zeros_like(cost_t)
66
+ for c in range(cost_t.shape[0]):
67
+ x = cost_t[c][~np.isnan(cost_t[c])]
68
+ n = len(x)
69
+ sd = np.std(x, ddof=1)
70
+ iqr = np.subtract(*np.quantile(x, [0.75, 0.25]))
71
+ h = 0.9 * min(sd, iqr / 1.34 if iqr > 0 else sd) * n ** -0.2 # Silverman
72
+ h = max(h, 1e-12)
73
+ f = np.mean(np.exp(-0.5 * ((x - est[c]) / h) ** 2)) / (h * np.sqrt(2 * np.pi))
74
+ f = max(f, 1e-12)
75
+ psi[c] = (0.5 - (cost_t[c] <= est[c])) / f
76
+ return est, psi
77
+
78
+
79
+ def _normal_draws(mean: np.ndarray, cov: np.ndarray, B: int, rng) -> np.ndarray:
80
+ # eigen-decomposition tolerates the rank deficiency of a covariance
81
+ # estimated from fewer clusters than dimensions
82
+ w, V = np.linalg.eigh((cov + cov.T) / 2)
83
+ w = np.clip(w, 0, None)
84
+ return mean + (rng.standard_normal((B, len(mean))) * np.sqrt(w)) @ V.T
85
+
86
+
87
+ def _formula(rs: RolloutSet, B: int, rng, cost_stat: str, kind: str) -> BootstrapResult:
88
+ acc_t, cost_t = _task_means(rs)
89
+ C, T = acc_t.shape
90
+ acc = np.nanmean(acc_t, axis=1)
91
+ cost_est, psi = _cost_influence(cost_t, cost_stat)
92
+ # centred per-task contributions for all 2C statistics
93
+ Z = np.vstack([acc_t - acc[:, None], psi]) # (2C, T)
94
+ Z = np.nan_to_num(Z)
95
+ mean = np.concatenate([acc, cost_est])
96
+
97
+ if kind == "wald":
98
+ var = (Z ** 2).sum(axis=1) / (T * (T - 1))
99
+ cov = np.diag(var) # every configuration independent
100
+ elif kind == "paired_clt":
101
+ cov = (Z @ Z.T) / (T * (T - 1))
102
+ elif kind == "clustered_clt":
103
+ G = rs.n_groups
104
+ S = np.zeros((2 * C, G))
105
+ for g, idx in enumerate(rs.group_tasks):
106
+ S[:, g] = Z[:, idx].sum(axis=1)
107
+ cov = (S @ S.T) / T ** 2 * (G / (G - 1)) # CR1 small-sample correction
108
+ else:
109
+ raise ValueError(kind)
110
+
111
+ draws = _normal_draws(mean, cov, B, rng)
112
+ return BootstrapResult(draws[:, :C], draws[:, C:], list(rs.configs), kind)
113
+
114
+
115
+ def run_method(rs: RolloutSet, method: str, B: int = 1000, rng=None,
116
+ cost_stat: str = "median") -> BootstrapResult:
117
+ rng = rng or np.random.default_rng(0)
118
+ if method == "naive":
119
+ acc, cost = rs.point_estimates(cost_stat)
120
+ return BootstrapResult(acc[None, :], cost[None, :], list(rs.configs), "naive")
121
+ if method in ("wald", "paired_clt", "clustered_clt"):
122
+ if method == "clustered_clt" and not rs.is_clustered:
123
+ method = "paired_clt" # no groups: the clustered estimator is the paired one
124
+ return _formula(rs, B, rng, cost_stat, method)
125
+ if method in ("boot_cluster_cr2", "boot_cluster_kish"):
126
+ mode = method[5:] if rs.is_clustered else "task"
127
+ return hierarchical_bootstrap(rs, n_boot=B, mode=mode, rng=rng, cost_stat=cost_stat)
128
+ if method == "boot_cluster_t":
129
+ mode = "cluster_t" if rs.is_clustered else "task"
130
+ return hierarchical_bootstrap(rs, n_boot=B, mode=mode, rng=rng, cost_stat=cost_stat)
131
+ mode = {"boot_both": "both", "boot_task": "task", "boot_group": "group",
132
+ "boot_cluster": "cluster"}[method]
133
+ if mode in ("group", "cluster") and not rs.is_clustered:
134
+ mode = "task"
135
+ return hierarchical_bootstrap(rs, n_boot=B, mode=mode, rng=rng, cost_stat=cost_stat)
@@ -0,0 +1,97 @@
1
+ """Test the kinds of claims papers and leaderboards actually make.
2
+
3
+ A claim names a subject configuration and, usually, comparators:
4
+
5
+ top1 subject is the most accurate of {subject} + comparators
6
+ pairwise subject is more accurate than each comparator
7
+ margin subject beats each comparator by at least `margin` (accuracy, 0-1)
8
+ cost_dominance subject is at least as accurate and no more costly than each comparator
9
+ frontier subject is on the cost-accuracy Pareto frontier of the given set
10
+ topk at most k-1 comparators are more accurate than subject ("#k", "top 5")
11
+ equivalent subject is within `margin` of each comparator ("as good as", "on par")
12
+ cheaper_noninferior subject costs less than each comparator and is no more than
13
+ `margin` less accurate ("as good as, but cheaper")
14
+
15
+ Each claim gets a bootstrap probability. It is *supported* at level `thr`
16
+ (default 0.9) when that probability reaches it -- the same certification rule
17
+ used throughout the paper. "Not supported" means the data cannot distinguish
18
+ the claim from its negation at that confidence; it does not mean the claim is
19
+ false.
20
+
21
+ from claimeval import RolloutSet
22
+ from claimeval.claims import Claim, evaluate_claims
23
+ res = evaluate_claims(rs, [Claim("top1", "agentA", ["agentB", "agentC"])])
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from dataclasses import dataclass, field
29
+
30
+ import numpy as np
31
+
32
+ from claimeval.stats.bootstrap import Mode, auto_mode, hierarchical_bootstrap
33
+ from claimeval.stats.frontier import pareto_mask
34
+ from claimeval.core.schema import RolloutSet
35
+
36
+ KINDS = ("top1", "pairwise", "margin", "cost_dominance", "frontier", "topk", "equivalent",
37
+ "cheaper_noninferior")
38
+
39
+
40
+ @dataclass
41
+ class Claim:
42
+ kind: str
43
+ subject: str
44
+ comparators: list[str] = field(default_factory=list)
45
+ margin: float = 0.0
46
+ id: str = ""
47
+ k: int = 1
48
+
49
+ def __post_init__(self):
50
+ if self.kind not in KINDS:
51
+ raise ValueError(f"unknown claim kind {self.kind!r}; expected one of {KINDS}")
52
+
53
+
54
+ def claim_probability(acc: np.ndarray, cost: np.ndarray, claim: Claim, idx: dict) -> np.ndarray:
55
+ """Per-replicate truth value of the claim. acc, cost: (B, n_configs)."""
56
+ s = idx[claim.subject]
57
+ comp = [idx[c] for c in claim.comparators]
58
+ a, c = acc[:, s], cost[:, s]
59
+ if claim.kind == "top1":
60
+ return np.all(a[:, None] > acc[:, comp], axis=1)
61
+ if claim.kind == "pairwise":
62
+ return np.all(a[:, None] > acc[:, comp], axis=1)
63
+ if claim.kind == "margin":
64
+ return np.all(a[:, None] - acc[:, comp] >= claim.margin, axis=1)
65
+ if claim.kind == "topk":
66
+ return (acc[:, comp] > a[:, None]).sum(axis=1) <= claim.k - 1
67
+ if claim.kind == "equivalent":
68
+ return np.all(np.abs(a[:, None] - acc[:, comp]) <= claim.margin, axis=1)
69
+ if claim.kind == "cheaper_noninferior":
70
+ return np.all((c[:, None] < cost[:, comp]) & (a[:, None] >= acc[:, comp] - claim.margin), axis=1)
71
+ if claim.kind == "cost_dominance":
72
+ return np.all((a[:, None] >= acc[:, comp]) & (c[:, None] <= cost[:, comp]), axis=1)
73
+ keep = [s] + comp
74
+ return pareto_mask(cost[:, keep], acc[:, keep])[:, 0]
75
+
76
+
77
+ def evaluate_claims(rs: RolloutSet, claims: list[Claim], n_boot: int = 4000, mode: Mode | None = None,
78
+ rng: np.random.Generator | None = None, thr: float = 0.9,
79
+ cost_stat: str = "median") -> list[dict]:
80
+ """Bootstrap probability and verdict for each claim, from one shared set of draws."""
81
+ rng = rng or np.random.default_rng(0)
82
+ mode = mode or auto_mode(rs)
83
+ b = hierarchical_bootstrap(rs, n_boot, mode, rng, cost_stat)
84
+ acc0, cost0 = rs.point_estimates(cost_stat)
85
+ idx = {k: i for i, k in enumerate(rs.configs)}
86
+ out = []
87
+ for cl in claims:
88
+ missing = [k for k in [cl.subject, *cl.comparators] if k not in idx]
89
+ if missing:
90
+ raise KeyError(f"claim {cl.id or cl.subject}: unknown configs {missing}")
91
+ p = float(claim_probability(b.acc, b.cost, cl, idx).mean())
92
+ pt = bool(claim_probability(acc0[None], cost0[None], cl, idx)[0])
93
+ out.append({"id": cl.id, "kind": cl.kind, "subject": cl.subject,
94
+ "comparators": list(cl.comparators), "probability": p,
95
+ "holds_at_point_estimate": pt, "supported": p >= thr, "mode": mode,
96
+ "subject_acc": float(acc0[idx[cl.subject]])})
97
+ return out
@@ -0,0 +1,121 @@
1
+ """Head-to-head comparison of uncertainty methods on shared simulated data.
2
+
3
+ Each replication draws one dataset from a scenario with known truth; every
4
+ method sees that same dataset. Reported per method:
5
+
6
+ acc_cov / cost_cov 95% interval coverage (target 0.95)
7
+ acc_w / cost_w mean interval width (coverage bought by width is no win)
8
+ gap_cov coverage of accuracy differences between adjacent configs
9
+ dom_prec share of certified dominance claims (P >= 0.9) that are true
10
+ dom_n certified dominance claims per replication (power)
11
+ dom_ece calibration error of P(i dominates j)
12
+ front_prec/recall certified frontier membership vs the true frontier
13
+
14
+ "naive" has no intervals; its claims are the point-estimate claims, asserted
15
+ with certainty, which is what current practice does.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass
21
+ from typing import Callable
22
+
23
+ import numpy as np
24
+
25
+ from .baselines import run_method
26
+ from claimeval.sim.coverage import Calibration
27
+ from claimeval.stats.frontier import dominance, pareto_mask
28
+ from claimeval.core.schema import RolloutSet
29
+
30
+
31
+ @dataclass
32
+ class Scenario:
33
+ name: str
34
+ draw: Callable[[np.random.Generator], RolloutSet] # one dataset
35
+ true_acc: np.ndarray
36
+ true_cost: np.ndarray
37
+ cost_stat: str = "median"
38
+
39
+
40
+ def compare(scenario: Scenario, methods, reps: int = 200, B: int = 800,
41
+ thr: float = 0.9, level: float = 0.95, seed: int = 0) -> list[dict]:
42
+ rng = np.random.default_rng(seed)
43
+ ta, tc = scenario.true_acc, scenario.true_cost
44
+ true_dom = dominance(tc, ta)
45
+ np.fill_diagonal(true_dom, False)
46
+ true_front = pareto_mask(tc, ta)
47
+ order = np.argsort(ta)
48
+ adj = list(zip(order[1:], order[:-1])) # (better, worse) by true accuracy
49
+ lo_q, hi_q = (1 - level) / 2, 1 - (1 - level) / 2
50
+
51
+ acc_names = {m: {k: [] for k in ("acc_cov", "cost_cov", "acc_w", "cost_w", "gap_cov",
52
+ "dom_true", "front_p", "front_r")} for m in methods}
53
+ dom_n = {m: 0 for m in methods}
54
+ cal = {m: Calibration() for m in methods}
55
+
56
+ for _ in range(reps):
57
+ rs = scenario.draw(rng)
58
+ idx = {k: i for i, k in enumerate(rs.configs)}
59
+ assert len(idx) == len(ta), "scenario configs must match truth"
60
+ a_hat, c_hat = rs.point_estimates(scenario.cost_stat)
61
+ naive_dom = dominance(c_hat, a_hat)
62
+ np.fill_diagonal(naive_dom, False)
63
+ naive_front = pareto_mask(c_hat, a_hat)
64
+ for m in methods:
65
+ b = run_method(rs, m, B=B, rng=rng, cost_stat=scenario.cost_stat)
66
+ r = acc_names[m]
67
+ if m != "naive":
68
+ alo, ahi = np.quantile(b.acc, [lo_q, hi_q], axis=0)
69
+ clo, chi = np.quantile(b.cost, [lo_q, hi_q], axis=0)
70
+ r["acc_cov"].append(np.mean((alo <= ta) & (ta <= ahi)))
71
+ r["cost_cov"].append(np.mean((clo <= tc) & (tc <= chi)))
72
+ r["acc_w"].append(np.mean(ahi - alo))
73
+ r["cost_w"].append(np.mean((chi - clo) / np.maximum(tc, 1e-12)))
74
+ gd = [b.acc[:, i] - b.acc[:, j] for i, j in adj]
75
+ r["gap_cov"].append(np.mean([
76
+ np.quantile(g, lo_q) <= ta[i] - ta[j] <= np.quantile(g, hi_q)
77
+ for g, (i, j) in zip(gd, adj)]))
78
+ pd = b.dominance_prob()
79
+ pf = b.frontier_prob()
80
+ off = ~np.eye(len(ta), dtype=bool)
81
+ cal[m].pred = np.append(cal[m].pred, pd[off])
82
+ cal[m].actual = np.append(cal[m].actual, true_dom[off].astype(float))
83
+ cert_dom = naive_dom & (pd >= thr)
84
+ cert_front = naive_front & (pf >= thr)
85
+ else:
86
+ cert_dom, cert_front = naive_dom, naive_front
87
+ dom_n[m] += int(cert_dom.sum())
88
+ r["dom_true"].extend(true_dom[cert_dom].tolist())
89
+ if cert_front.any():
90
+ r["front_p"].append(true_front[cert_front].mean())
91
+ r["front_r"].append(cert_front[true_front].mean() if true_front.any() else np.nan)
92
+
93
+ rows = []
94
+ for m in methods:
95
+ r = acc_names[m]
96
+ mean = lambda k: float(np.nanmean(r[k])) if r[k] else None # noqa: E731
97
+ rows.append({
98
+ "scenario": scenario.name, "method": m,
99
+ "acc_cov": mean("acc_cov"), "cost_cov": mean("cost_cov"),
100
+ "acc_w": mean("acc_w"), "cost_w": mean("cost_w"), "gap_cov": mean("gap_cov"),
101
+ "dom_prec": float(np.mean(r["dom_true"])) if r["dom_true"] else None,
102
+ "dom_n": dom_n[m] / reps,
103
+ "dom_ece": cal[m].ece() if m != "naive" else None,
104
+ "front_prec": mean("front_p"), "front_recall": mean("front_r"),
105
+ "reps": reps,
106
+ })
107
+ return rows
108
+
109
+
110
+ def format_rows(rows: list[dict]) -> str:
111
+ def f(v, d=3):
112
+ return " --" if v is None else f"{v:.{d}f}"
113
+ head = (f"{'method':14s}{'acc cov':>8s}{'width':>7s}{'cost cov':>9s}{'gap cov':>8s}"
114
+ f"{'dom prec':>9s}{'dom/rep':>8s}{'dom ECE':>8s}{'front P':>8s}{'front R':>8s}")
115
+ out = [head, "-" * len(head)]
116
+ for r in rows:
117
+ out.append(f"{r['method']:14s}{f(r['acc_cov']):>8s}{f(r['acc_w']):>7s}"
118
+ f"{f(r['cost_cov']):>9s}{f(r['gap_cov']):>8s}{f(r['dom_prec']):>9s}"
119
+ f"{r['dom_n']:>8.1f}{f(r['dom_ece']):>8s}{f(r['front_prec']):>8s}"
120
+ f"{f(r['front_recall']):>8s}")
121
+ return "\n".join(out)