abeval 0.1.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.
abeval/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ """abeval: A/B-test statistics for LLM evals.
2
+
3
+ Error bars for a single run, paired significance tests for comparing two
4
+ runs, sample-size planning before you spend on inference, and judge-noise
5
+ reliability. Standard library only.
6
+ """
7
+
8
+ from .ci import Interval, clustered_mean_ci, mean_ci, proportion_ci
9
+ from .compare import Comparison, mcnemar_exact, paired_compare
10
+ from .data import extract, pair, read_jsonl
11
+ from .power import mde, power_simulated, sample_size, sd_diff_from_rates
12
+ from .reliability import Reliability, judge_reliability
13
+
14
+ __version__ = "0.1.0"
15
+
16
+ __all__ = [
17
+ "Comparison",
18
+ "Interval",
19
+ "Reliability",
20
+ "__version__",
21
+ "clustered_mean_ci",
22
+ "extract",
23
+ "judge_reliability",
24
+ "mcnemar_exact",
25
+ "mde",
26
+ "mean_ci",
27
+ "pair",
28
+ "paired_compare",
29
+ "power_simulated",
30
+ "proportion_ci",
31
+ "read_jsonl",
32
+ "sample_size",
33
+ "sd_diff_from_rates",
34
+ ]
abeval/__main__.py ADDED
@@ -0,0 +1,191 @@
1
+ """Command-line interface for abeval."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+
9
+ from . import __version__
10
+ from .ci import clustered_mean_ci, mean_ci, proportion_ci
11
+ from .compare import paired_compare
12
+ from .data import extract, pair, read_jsonl
13
+ from .power import mde, sample_size, sd_diff_from_rates
14
+ from .reliability import judge_reliability
15
+
16
+
17
+ def _fmt_pct(x: float) -> str:
18
+ return f"{100.0 * x:.1f}%"
19
+
20
+
21
+ def _is_binary(values: list[float]) -> bool:
22
+ return all(v in (0.0, 1.0) for v in values)
23
+
24
+
25
+ def cmd_ci(args: argparse.Namespace) -> int:
26
+ records = read_jsonl(args.results)
27
+ values_by_id = extract(records, args.metric, args.id_key)
28
+ values = list(values_by_id.values())
29
+ if _is_binary(values) and not args.bootstrap:
30
+ interval = proportion_ci(int(sum(values)), len(values), args.level)
31
+ elif args.cluster_key:
32
+ clusters = [rec[args.cluster_key] for rec in records]
33
+ interval = clustered_mean_ci(values, clusters, args.level)
34
+ elif args.bootstrap:
35
+ interval = mean_ci(values, args.level, method="bootstrap", reps=args.reps, seed=args.seed)
36
+ else:
37
+ interval = mean_ci(values, args.level, method="t")
38
+ if args.json:
39
+ print(json.dumps(interval.as_dict(), indent=2))
40
+ return 0
41
+ pct = _is_binary(values)
42
+ fmt = _fmt_pct if pct else lambda v: f"{v:.4g}"
43
+ print(f"{args.metric}: {fmt(interval.estimate)} "
44
+ f"[{fmt(interval.lo)}, {fmt(interval.hi)}] "
45
+ f"(n={interval.n}, {int(interval.level * 100)}% CI, {interval.method})")
46
+ return 0
47
+
48
+
49
+ def cmd_compare(args: argparse.Namespace) -> int:
50
+ rec_a = read_jsonl(args.a)
51
+ rec_b = read_jsonl(args.b)
52
+ map_a = extract(rec_a, args.metric, args.id_key)
53
+ map_b = extract(rec_b, args.metric, args.id_key)
54
+ values_a, values_b, dropped = pair(map_a, map_b)
55
+ if not values_a:
56
+ print("error: no shared item ids between the two runs", file=sys.stderr)
57
+ return 2
58
+ if dropped:
59
+ print(f"note: {dropped} unpaired item(s) dropped", file=sys.stderr)
60
+ result = paired_compare(values_a, values_b, args.level, args.reps, args.seed)
61
+ if args.json:
62
+ print(json.dumps(result.as_dict(), indent=2))
63
+ return 0
64
+
65
+ pct = result.binary
66
+ fmt = _fmt_pct if pct else lambda v: f"{v:+.4g}"
67
+ plain = _fmt_pct if pct else lambda v: f"{v:.4g}"
68
+ sign = "+" if result.diff >= 0 else ""
69
+ print(f"A: {plain(result.mean_a)} B: {plain(result.mean_b)} (n={result.n} paired items)")
70
+ print(f"B - A: {sign}{fmt(result.diff).lstrip('+') if not pct else fmt(result.diff)} "
71
+ f"[{fmt(result.ci_lo)}, {fmt(result.ci_hi)}] ({int(result.level * 100)}% CI)")
72
+ print(f"p (sign-flip permutation): {result.p_permutation:.4f} p (paired t): {result.p_t:.4f}")
73
+ if result.mcnemar is not None:
74
+ mc = result.mcnemar
75
+ print(f"discordant items: B-only wins {mc['b_only']}, A-only wins {mc['a_only']} "
76
+ f"p (exact McNemar): {mc['p']:.4f}")
77
+ verdict = (
78
+ "significant at the chosen level"
79
+ if result.p_permutation < 1.0 - args.level
80
+ else "NOT significant — the difference is within noise at this sample size"
81
+ )
82
+ print(f"verdict: {verdict}")
83
+ return 0
84
+
85
+
86
+ def cmd_power(args: argparse.Namespace) -> int:
87
+ sd = sd_diff_from_rates(args.baseline, args.baseline + args.delta, args.corr)
88
+ n = sample_size(args.delta, sd, args.level, args.power)
89
+ out = {
90
+ "baseline": args.baseline,
91
+ "delta": args.delta,
92
+ "corr": args.corr,
93
+ "sd_diff": sd,
94
+ "level": args.level,
95
+ "power": args.power,
96
+ "n_items": n,
97
+ }
98
+ if args.json:
99
+ print(json.dumps(out, indent=2))
100
+ return 0
101
+ print(f"to detect {_fmt_pct(abs(args.delta))} difference from a "
102
+ f"{_fmt_pct(args.baseline)} baseline (corr={args.corr}):")
103
+ print(f" n = {n} paired items "
104
+ f"({int(args.power * 100)}% power, {int(args.level * 100)}% confidence)")
105
+ for n_have in (100, 200, 500, 1000):
106
+ d = mde(n_have, sd, args.level, args.power)
107
+ print(f" with {n_have:>4} items you can detect >= {_fmt_pct(d)}")
108
+ return 0
109
+
110
+
111
+ def cmd_reliability(args: argparse.Namespace) -> int:
112
+ records = read_jsonl(args.results)
113
+ groups: dict = {}
114
+ for rec in records:
115
+ if args.id_key not in rec or args.metric not in rec:
116
+ raise ValueError(f"record missing {args.id_key!r} or {args.metric!r}: {rec}")
117
+ groups.setdefault(rec[args.id_key], []).append(rec[args.metric])
118
+ rel = judge_reliability(groups)
119
+ if args.json:
120
+ print(json.dumps(rel.as_dict(), indent=2))
121
+ return 0
122
+ print(f"{rel.n_items} items, {rel.n_judgments} judgments "
123
+ f"({rel.mean_reps:.1f} per item)")
124
+ print(f"ICC (signal share of variance): {rel.icc:.3f}")
125
+ print(f"between-item sd: {rel.sd_between:.4g} judge noise sd: {rel.sd_within:.4g}")
126
+ print(f"exact agreement across repeats: {_fmt_pct(rel.exact_agreement)}")
127
+ reps = rel.repeats_for(0.1)
128
+ if reps is not None:
129
+ print(f"repeats per item to push judge noise under 10%: {reps}")
130
+ return 0
131
+
132
+
133
+ def main(argv: list[str] | None = None) -> int:
134
+ parser = argparse.ArgumentParser(
135
+ prog="abeval",
136
+ description="A/B-test statistics for LLM evals.",
137
+ )
138
+ parser.add_argument("--version", action="version", version=f"abeval {__version__}")
139
+ sub = parser.add_subparsers(dest="command", required=True)
140
+
141
+ common = argparse.ArgumentParser(add_help=False)
142
+ common.add_argument("--metric", default="score", help="metric field name (default: score)")
143
+ common.add_argument("--id-key", default="id", help="item id field name (default: id)")
144
+ common.add_argument("--level", type=float, default=0.95, help="confidence level (default: 0.95)")
145
+ common.add_argument("--seed", type=int, default=0, help="RNG seed (default: 0)")
146
+ common.add_argument("--reps", type=int, default=10_000,
147
+ help="bootstrap/permutation resamples (default: 10000)")
148
+ common.add_argument("--json", action="store_true", help="emit JSON instead of text")
149
+
150
+ p_ci = sub.add_parser("ci", parents=[common],
151
+ help="confidence interval for one run's metric")
152
+ p_ci.add_argument("results", help="JSONL file with per-item results")
153
+ p_ci.add_argument("--bootstrap", action="store_true",
154
+ help="percentile bootstrap instead of t/Wilson")
155
+ p_ci.add_argument("--cluster-key", default=None,
156
+ help="field defining dependence clusters (e.g. source document)")
157
+ p_ci.set_defaults(fn=cmd_ci)
158
+
159
+ p_cmp = sub.add_parser("compare", parents=[common],
160
+ help="paired comparison of two runs on shared items")
161
+ p_cmp.add_argument("a", help="baseline run JSONL")
162
+ p_cmp.add_argument("b", help="candidate run JSONL")
163
+ p_cmp.set_defaults(fn=cmd_compare)
164
+
165
+ p_pow = sub.add_parser("power", parents=[common],
166
+ help="how many items you need before you run")
167
+ p_pow.add_argument("--baseline", type=float, required=True,
168
+ help="expected baseline accuracy, e.g. 0.75")
169
+ p_pow.add_argument("--delta", type=float, required=True,
170
+ help="difference you care about detecting, e.g. 0.03")
171
+ p_pow.add_argument("--corr", type=float, default=0.5,
172
+ help="item-level correlation between runs (default: 0.5)")
173
+ p_pow.add_argument("--power", type=float, default=0.8,
174
+ help="target power (default: 0.8)")
175
+ p_pow.set_defaults(fn=cmd_power)
176
+
177
+ p_rel = sub.add_parser("reliability", parents=[common],
178
+ help="judge-noise decomposition from repeated judgments")
179
+ p_rel.add_argument("results", help="JSONL with repeated judgments per item id")
180
+ p_rel.set_defaults(fn=cmd_reliability)
181
+
182
+ args = parser.parse_args(argv)
183
+ try:
184
+ return args.fn(args)
185
+ except (ValueError, OSError) as exc:
186
+ print(f"error: {exc}", file=sys.stderr)
187
+ return 2
188
+
189
+
190
+ if __name__ == "__main__":
191
+ raise SystemExit(main())
abeval/_dist.py ADDED
@@ -0,0 +1,109 @@
1
+ """Distribution helpers used by the estimators.
2
+
3
+ Everything here is standard-library only. The Student-t CDF goes through the
4
+ regularized incomplete beta function (continued-fraction expansion), and the
5
+ quantile inverts it by bisection — slower than a closed form but robust for
6
+ every df, which is what a stats tool should optimize for.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from statistics import NormalDist
13
+
14
+ _NORMAL = NormalDist()
15
+
16
+
17
+ def z_quantile(p: float) -> float:
18
+ """Standard normal quantile."""
19
+ if not 0.0 < p < 1.0:
20
+ raise ValueError("p must be in (0, 1)")
21
+ return _NORMAL.inv_cdf(p)
22
+
23
+
24
+ def normal_cdf(x: float) -> float:
25
+ return _NORMAL.cdf(x)
26
+
27
+
28
+ def _betacf(a: float, b: float, x: float) -> float:
29
+ """Continued fraction for the incomplete beta function (Lentz's method)."""
30
+ max_iter = 300
31
+ eps = 3e-12
32
+ fpmin = 1e-300
33
+ qab = a + b
34
+ qap = a + 1.0
35
+ qam = a - 1.0
36
+ c = 1.0
37
+ d = 1.0 - qab * x / qap
38
+ if abs(d) < fpmin:
39
+ d = fpmin
40
+ d = 1.0 / d
41
+ h = d
42
+ for m in range(1, max_iter + 1):
43
+ m2 = 2 * m
44
+ aa = m * (b - m) * x / ((qam + m2) * (a + m2))
45
+ d = 1.0 + aa * d
46
+ if abs(d) < fpmin:
47
+ d = fpmin
48
+ c = 1.0 + aa / c
49
+ if abs(c) < fpmin:
50
+ c = fpmin
51
+ d = 1.0 / d
52
+ h *= d * c
53
+ aa = -(a + m) * (qab + m) * x / ((a + m2) * (qap + m2))
54
+ d = 1.0 + aa * d
55
+ if abs(d) < fpmin:
56
+ d = fpmin
57
+ c = 1.0 + aa / c
58
+ if abs(c) < fpmin:
59
+ c = fpmin
60
+ d = 1.0 / d
61
+ delta = d * c
62
+ h *= delta
63
+ if abs(delta - 1.0) < eps:
64
+ break
65
+ return h
66
+
67
+
68
+ def reg_inc_beta(a: float, b: float, x: float) -> float:
69
+ """Regularized incomplete beta I_x(a, b)."""
70
+ if x <= 0.0:
71
+ return 0.0
72
+ if x >= 1.0:
73
+ return 1.0
74
+ ln_front = (
75
+ math.lgamma(a + b)
76
+ - math.lgamma(a)
77
+ - math.lgamma(b)
78
+ + a * math.log(x)
79
+ + b * math.log1p(-x)
80
+ )
81
+ front = math.exp(ln_front)
82
+ if x < (a + 1.0) / (a + b + 2.0):
83
+ return front * _betacf(a, b, x) / a
84
+ return 1.0 - front * _betacf(b, a, 1.0 - x) / b
85
+
86
+
87
+ def t_cdf(t: float, df: float) -> float:
88
+ """CDF of Student's t with `df` degrees of freedom."""
89
+ if df <= 0:
90
+ raise ValueError("df must be positive")
91
+ x = df / (df + t * t)
92
+ p_tail = 0.5 * reg_inc_beta(df / 2.0, 0.5, x)
93
+ return 1.0 - p_tail if t > 0 else p_tail
94
+
95
+
96
+ def t_quantile(p: float, df: float) -> float:
97
+ """Quantile of Student's t, by bisection on the CDF."""
98
+ if not 0.0 < p < 1.0:
99
+ raise ValueError("p must be in (0, 1)")
100
+ if df <= 0:
101
+ raise ValueError("df must be positive")
102
+ lo, hi = -1e8, 1e8
103
+ for _ in range(200):
104
+ mid = (lo + hi) / 2.0
105
+ if t_cdf(mid, df) < p:
106
+ lo = mid
107
+ else:
108
+ hi = mid
109
+ return (lo + hi) / 2.0
abeval/ci.py ADDED
@@ -0,0 +1,124 @@
1
+ """Confidence intervals for a single eval run."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import random
7
+ from dataclasses import dataclass
8
+
9
+ from ._dist import t_quantile, z_quantile
10
+
11
+
12
+ @dataclass
13
+ class Interval:
14
+ estimate: float
15
+ lo: float
16
+ hi: float
17
+ n: int
18
+ method: str
19
+ level: float
20
+ se: float | None = None
21
+
22
+ def as_dict(self) -> dict:
23
+ return {
24
+ "estimate": self.estimate,
25
+ "lo": self.lo,
26
+ "hi": self.hi,
27
+ "n": self.n,
28
+ "method": self.method,
29
+ "level": self.level,
30
+ "se": self.se,
31
+ }
32
+
33
+
34
+ def proportion_ci(successes: int, n: int, level: float = 0.95) -> Interval:
35
+ """Wilson score interval for a binomial proportion."""
36
+ if n <= 0:
37
+ raise ValueError("n must be positive")
38
+ if not 0 <= successes <= n:
39
+ raise ValueError("successes must be in [0, n]")
40
+ z = z_quantile(1.0 - (1.0 - level) / 2.0)
41
+ p_hat = successes / n
42
+ denom = 1.0 + z * z / n
43
+ center = (p_hat + z * z / (2.0 * n)) / denom
44
+ half = (z / denom) * math.sqrt(p_hat * (1.0 - p_hat) / n + z * z / (4.0 * n * n))
45
+ return Interval(
46
+ estimate=p_hat,
47
+ lo=max(0.0, center - half),
48
+ hi=min(1.0, center + half),
49
+ n=n,
50
+ method="wilson",
51
+ level=level,
52
+ )
53
+
54
+
55
+ def _mean(values: list[float]) -> float:
56
+ return sum(values) / len(values)
57
+
58
+
59
+ def _sample_sd(values: list[float]) -> float:
60
+ n = len(values)
61
+ m = _mean(values)
62
+ return math.sqrt(sum((v - m) ** 2 for v in values) / (n - 1))
63
+
64
+
65
+ def mean_ci(
66
+ values: list[float],
67
+ level: float = 0.95,
68
+ method: str = "t",
69
+ reps: int = 10_000,
70
+ seed: int = 0,
71
+ ) -> Interval:
72
+ """CI for the mean of per-item values.
73
+
74
+ method="t" uses the t distribution; method="bootstrap" uses the percentile
75
+ bootstrap with a seeded RNG so results are reproducible.
76
+ """
77
+ n = len(values)
78
+ if n < 2:
79
+ raise ValueError("need at least 2 values")
80
+ m = _mean(values)
81
+ if method == "t":
82
+ se = _sample_sd(values) / math.sqrt(n)
83
+ t = t_quantile(1.0 - (1.0 - level) / 2.0, n - 1)
84
+ return Interval(m, m - t * se, m + t * se, n, "t", level, se=se)
85
+ if method == "bootstrap":
86
+ rng = random.Random(seed)
87
+ stats = sorted(
88
+ _mean([values[rng.randrange(n)] for _ in range(n)]) for _ in range(reps)
89
+ )
90
+ alpha = (1.0 - level) / 2.0
91
+ lo = stats[int(alpha * reps)]
92
+ hi = stats[min(reps - 1, int((1.0 - alpha) * reps))]
93
+ return Interval(m, lo, hi, n, "bootstrap", level)
94
+ raise ValueError(f"unknown method {method!r}")
95
+
96
+
97
+ def clustered_mean_ci(
98
+ values: list[float],
99
+ clusters: list,
100
+ level: float = 0.95,
101
+ ) -> Interval:
102
+ """CI for a mean when items are not independent within clusters.
103
+
104
+ Uses the cluster-robust variance of the mean (sum of squared within-cluster
105
+ residual sums over n^2) with a t reference on G-1 degrees of freedom, where
106
+ G is the number of clusters. With few clusters (< ~20) treat the interval
107
+ as approximate.
108
+ """
109
+ if len(values) != len(clusters):
110
+ raise ValueError("values and clusters must have the same length")
111
+ n = len(values)
112
+ if n < 2:
113
+ raise ValueError("need at least 2 values")
114
+ m = _mean(values)
115
+ sums: dict = {}
116
+ for v, c in zip(values, clusters):
117
+ sums[c] = sums.get(c, 0.0) + (v - m)
118
+ n_clusters = len(sums)
119
+ if n_clusters < 2:
120
+ raise ValueError("need at least 2 clusters")
121
+ var = sum(s * s for s in sums.values()) / (n * n)
122
+ se = math.sqrt(var)
123
+ t = t_quantile(1.0 - (1.0 - level) / 2.0, n_clusters - 1)
124
+ return Interval(m, m - t * se, m + t * se, n, f"clustered-t ({n_clusters} clusters)", level, se=se)
abeval/compare.py ADDED
@@ -0,0 +1,131 @@
1
+ """Paired comparison of two eval runs on the same items."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import random
7
+ from dataclasses import dataclass, field
8
+
9
+ from ._dist import t_cdf
10
+
11
+
12
+ @dataclass
13
+ class Comparison:
14
+ n: int
15
+ mean_a: float
16
+ mean_b: float
17
+ diff: float # mean(b - a): positive means B is better
18
+ ci_lo: float
19
+ ci_hi: float
20
+ level: float
21
+ p_permutation: float
22
+ p_t: float
23
+ reps: int
24
+ seed: int
25
+ binary: bool
26
+ mcnemar: dict | None = field(default=None)
27
+
28
+ def as_dict(self) -> dict:
29
+ out = {
30
+ "n": self.n,
31
+ "mean_a": self.mean_a,
32
+ "mean_b": self.mean_b,
33
+ "diff": self.diff,
34
+ "ci_lo": self.ci_lo,
35
+ "ci_hi": self.ci_hi,
36
+ "level": self.level,
37
+ "p_permutation": self.p_permutation,
38
+ "p_t": self.p_t,
39
+ "reps": self.reps,
40
+ "seed": self.seed,
41
+ "binary": self.binary,
42
+ }
43
+ if self.mcnemar is not None:
44
+ out["mcnemar"] = self.mcnemar
45
+ return out
46
+
47
+
48
+ def mcnemar_exact(a: list[float], b: list[float]) -> dict:
49
+ """Exact McNemar test for paired binary outcomes.
50
+
51
+ b_only = items B got right and A got wrong; a_only = the reverse.
52
+ The p-value is the exact two-sided binomial sign test on the discordant
53
+ pairs (p = 0.5 under the null), clamped to 1.
54
+ """
55
+ a_only = sum(1 for x, y in zip(a, b) if x == 1 and y == 0)
56
+ b_only = sum(1 for x, y in zip(a, b) if x == 0 and y == 1)
57
+ n_disc = a_only + b_only
58
+ if n_disc == 0:
59
+ return {"a_only": 0, "b_only": 0, "p": 1.0}
60
+ k = min(a_only, b_only)
61
+ tail = sum(math.comb(n_disc, i) for i in range(k + 1)) / 2.0**n_disc
62
+ return {"a_only": a_only, "b_only": b_only, "p": min(1.0, 2.0 * tail)}
63
+
64
+
65
+ def paired_compare(
66
+ a: list[float],
67
+ b: list[float],
68
+ level: float = 0.95,
69
+ reps: int = 10_000,
70
+ seed: int = 0,
71
+ ) -> Comparison:
72
+ """Compare two runs scored on the same items.
73
+
74
+ Reports mean(b - a) with a paired percentile-bootstrap CI, a sign-flip
75
+ permutation p-value (exact under item exchangeability), a paired-t
76
+ p-value, and — when both runs are 0/1 — an exact McNemar test.
77
+ """
78
+ if len(a) != len(b):
79
+ raise ValueError("runs must have the same length")
80
+ n = len(a)
81
+ if n < 2:
82
+ raise ValueError("need at least 2 paired items")
83
+ diffs = [y - x for x, y in zip(a, b)]
84
+ mean_diff = sum(diffs) / n
85
+
86
+ rng = random.Random(seed)
87
+
88
+ # Percentile bootstrap on the per-item differences.
89
+ boot = sorted(
90
+ sum(diffs[rng.randrange(n)] for _ in range(n)) / n for _ in range(reps)
91
+ )
92
+ alpha = (1.0 - level) / 2.0
93
+ ci_lo = boot[int(alpha * reps)]
94
+ ci_hi = boot[min(reps - 1, int((1.0 - alpha) * reps))]
95
+
96
+ # Sign-flip permutation test: under H0 each difference is symmetric
97
+ # around 0, so flipping signs at random regenerates the null.
98
+ obs = abs(mean_diff)
99
+ hits = 0
100
+ for _ in range(reps):
101
+ total = 0.0
102
+ for d in diffs:
103
+ total += d if rng.random() < 0.5 else -d
104
+ if abs(total / n) >= obs - 1e-15:
105
+ hits += 1
106
+ p_perm = (1 + hits) / (reps + 1)
107
+
108
+ # Paired t-test.
109
+ sd = math.sqrt(sum((d - mean_diff) ** 2 for d in diffs) / (n - 1))
110
+ if sd == 0.0:
111
+ p_t = 1.0 if mean_diff == 0.0 else 0.0
112
+ else:
113
+ t_stat = mean_diff / (sd / math.sqrt(n))
114
+ p_t = 2.0 * (1.0 - t_cdf(abs(t_stat), n - 1))
115
+
116
+ binary = all(v in (0.0, 1.0) for v in a) and all(v in (0.0, 1.0) for v in b)
117
+ return Comparison(
118
+ n=n,
119
+ mean_a=sum(a) / n,
120
+ mean_b=sum(b) / n,
121
+ diff=mean_diff,
122
+ ci_lo=ci_lo,
123
+ ci_hi=ci_hi,
124
+ level=level,
125
+ p_permutation=p_perm,
126
+ p_t=p_t,
127
+ reps=reps,
128
+ seed=seed,
129
+ binary=binary,
130
+ mcnemar=mcnemar_exact(a, b) if binary else None,
131
+ )
abeval/data.py ADDED
@@ -0,0 +1,54 @@
1
+ """Reading eval results and pairing two runs on a shared item id."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+
9
+ def read_jsonl(path: str | Path) -> list[dict]:
10
+ """Read a JSONL file into a list of dicts. Blank lines are skipped."""
11
+ records = []
12
+ with open(path, encoding="utf-8") as fh:
13
+ for line_no, line in enumerate(fh, 1):
14
+ line = line.strip()
15
+ if not line:
16
+ continue
17
+ try:
18
+ records.append(json.loads(line))
19
+ except json.JSONDecodeError as exc:
20
+ raise ValueError(f"{path}:{line_no}: invalid JSON: {exc}") from exc
21
+ return records
22
+
23
+
24
+ def extract(records: list[dict], metric: str, id_key: str = "id") -> dict:
25
+ """Map item id -> metric value. Duplicate ids and missing fields are errors."""
26
+ out: dict = {}
27
+ for rec in records:
28
+ if id_key not in rec:
29
+ raise ValueError(f"record missing id key {id_key!r}: {rec}")
30
+ if metric not in rec:
31
+ raise ValueError(f"record {rec[id_key]!r} missing metric {metric!r}")
32
+ item_id = rec[id_key]
33
+ if item_id in out:
34
+ raise ValueError(f"duplicate id {item_id!r}")
35
+ value = rec[metric]
36
+ if isinstance(value, bool):
37
+ value = int(value)
38
+ if not isinstance(value, (int, float)):
39
+ raise ValueError(f"metric {metric!r} for id {item_id!r} is not numeric: {value!r}")
40
+ out[item_id] = float(value)
41
+ return out
42
+
43
+
44
+ def pair(
45
+ a: dict, b: dict
46
+ ) -> tuple[list[float], list[float], int]:
47
+ """Inner-join two id->value maps.
48
+
49
+ Returns (a_values, b_values, n_dropped) where n_dropped counts ids present
50
+ in only one of the two runs. Order follows a's insertion order.
51
+ """
52
+ shared = [item_id for item_id in a if item_id in b]
53
+ dropped = (len(a) - len(shared)) + (len(b) - len(shared))
54
+ return [a[i] for i in shared], [b[i] for i in shared], dropped
abeval/power.py ADDED
@@ -0,0 +1,127 @@
1
+ """Sample-size planning and minimum detectable effects for paired evals."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import random
7
+
8
+ from ._dist import z_quantile
9
+
10
+
11
+ def sd_diff_from_rates(p_a: float, p_b: float, corr: float) -> float:
12
+ """SD of the per-item difference for paired binary outcomes.
13
+
14
+ corr is the item-level correlation between the two runs' outcomes
15
+ (0 = independent, higher = the models succeed and fail on the same items,
16
+ which is the typical case and what makes paired designs cheap).
17
+ """
18
+ for p in (p_a, p_b):
19
+ if not 0.0 < p < 1.0:
20
+ raise ValueError("rates must be in (0, 1)")
21
+ if not -1.0 < corr < 1.0:
22
+ raise ValueError("corr must be in (-1, 1)")
23
+ var = (
24
+ p_a * (1.0 - p_a)
25
+ + p_b * (1.0 - p_b)
26
+ - 2.0 * corr * math.sqrt(p_a * (1.0 - p_a) * p_b * (1.0 - p_b))
27
+ )
28
+ if var <= 0.0:
29
+ raise ValueError("implied variance is not positive; check the inputs")
30
+ return math.sqrt(var)
31
+
32
+
33
+ def sample_size(
34
+ delta: float,
35
+ sd_diff: float,
36
+ level: float = 0.95,
37
+ power: float = 0.8,
38
+ ) -> int:
39
+ """Paired items needed to detect a true difference `delta`.
40
+
41
+ Normal-approximation two-sided test on the mean of per-item differences.
42
+ """
43
+ if delta == 0.0:
44
+ raise ValueError("delta must be nonzero")
45
+ if sd_diff <= 0.0:
46
+ raise ValueError("sd_diff must be positive")
47
+ z_alpha = z_quantile(1.0 - (1.0 - level) / 2.0)
48
+ z_power = z_quantile(power)
49
+ n = ((z_alpha + z_power) * sd_diff / abs(delta)) ** 2
50
+ return max(2, math.ceil(n))
51
+
52
+
53
+ def mde(
54
+ n: int,
55
+ sd_diff: float,
56
+ level: float = 0.95,
57
+ power: float = 0.8,
58
+ ) -> float:
59
+ """Minimum detectable difference at a given number of paired items."""
60
+ if n < 2:
61
+ raise ValueError("n must be at least 2")
62
+ if sd_diff <= 0.0:
63
+ raise ValueError("sd_diff must be positive")
64
+ z_alpha = z_quantile(1.0 - (1.0 - level) / 2.0)
65
+ z_power = z_quantile(power)
66
+ return (z_alpha + z_power) * sd_diff / math.sqrt(n)
67
+
68
+
69
+ def paired_binary_cells(p_a: float, p_b: float, corr: float) -> dict:
70
+ """Joint distribution of paired 0/1 outcomes with the given margins/corr."""
71
+ sd_a = math.sqrt(p_a * (1.0 - p_a))
72
+ sd_b = math.sqrt(p_b * (1.0 - p_b))
73
+ p_both = p_a * p_b + corr * sd_a * sd_b
74
+ cells = {
75
+ "both": p_both,
76
+ "a_only": p_a - p_both,
77
+ "b_only": p_b - p_both,
78
+ "neither": 1.0 - p_a - p_b + p_both,
79
+ }
80
+ if any(v < -1e-12 for v in cells.values()):
81
+ raise ValueError("that correlation is not achievable with these rates")
82
+ return {k: max(0.0, v) for k, v in cells.items()}
83
+
84
+
85
+ def power_simulated(
86
+ n: int,
87
+ p_a: float,
88
+ p_b: float,
89
+ corr: float,
90
+ level: float = 0.95,
91
+ reps: int = 2_000,
92
+ seed: int = 0,
93
+ ) -> float:
94
+ """Monte Carlo power of the paired z-test for binary outcomes.
95
+
96
+ A check on the normal-approximation formulas: simulates `reps` evals of n
97
+ paired items with the given accuracy margins and item-level correlation,
98
+ and reports the fraction where the test rejects at the given level.
99
+ """
100
+ cells = paired_binary_cells(p_a, p_b, corr)
101
+ thresholds = []
102
+ acc = 0.0
103
+ for key in ("both", "a_only", "b_only", "neither"):
104
+ acc += cells[key]
105
+ thresholds.append((acc, key))
106
+ diff_of = {"both": 0.0, "neither": 0.0, "a_only": -1.0, "b_only": 1.0}
107
+ z_alpha = z_quantile(1.0 - (1.0 - level) / 2.0)
108
+ rng = random.Random(seed)
109
+ rejections = 0
110
+ for _ in range(reps):
111
+ diffs = []
112
+ for _ in range(n):
113
+ u = rng.random()
114
+ for cutoff, key in thresholds:
115
+ if u <= cutoff:
116
+ diffs.append(diff_of[key])
117
+ break
118
+ else:
119
+ diffs.append(0.0)
120
+ m = sum(diffs) / n
121
+ var = sum((d - m) ** 2 for d in diffs) / (n - 1)
122
+ if var == 0.0:
123
+ continue
124
+ z_stat = m / math.sqrt(var / n)
125
+ if abs(z_stat) > z_alpha:
126
+ rejections += 1
127
+ return rejections / reps
abeval/reliability.py ADDED
@@ -0,0 +1,85 @@
1
+ """How noisy is the judge? Variance decomposition over repeated judgments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass
7
+
8
+
9
+ @dataclass
10
+ class Reliability:
11
+ n_items: int
12
+ n_judgments: int
13
+ mean_reps: float
14
+ icc: float
15
+ sd_between: float # spread of true item scores
16
+ sd_within: float # judge noise on a single judgment
17
+ exact_agreement: float # fraction of items where every repeat matched
18
+
19
+ def repeats_for(self, noise_share: float = 0.1) -> int | None:
20
+ """Repeats per item so judge noise is at most `noise_share` of the
21
+ variance of an item's averaged score. None when the between-item
22
+ variance is zero (no signal to protect)."""
23
+ if not 0.0 < noise_share < 1.0:
24
+ raise ValueError("noise_share must be in (0, 1)")
25
+ var_b = self.sd_between**2
26
+ var_w = self.sd_within**2
27
+ if var_b <= 0.0:
28
+ return None
29
+ if var_w == 0.0:
30
+ return 1
31
+ return max(1, math.ceil(var_w * (1.0 - noise_share) / (noise_share * var_b)))
32
+
33
+ def as_dict(self) -> dict:
34
+ return {
35
+ "n_items": self.n_items,
36
+ "n_judgments": self.n_judgments,
37
+ "mean_reps": self.mean_reps,
38
+ "icc": self.icc,
39
+ "sd_between": self.sd_between,
40
+ "sd_within": self.sd_within,
41
+ "exact_agreement": self.exact_agreement,
42
+ }
43
+
44
+
45
+ def judge_reliability(groups: dict) -> Reliability:
46
+ """One-way random-effects decomposition of repeated judge scores.
47
+
48
+ `groups` maps item id -> list of scores from repeated judgments of the
49
+ same item. Unbalanced designs are fine (k0 correction). Returns the
50
+ intraclass correlation (share of variance that is real item signal rather
51
+ than judge noise) plus both variance components.
52
+ """
53
+ groups = {k: [float(v) for v in vals] for k, vals in groups.items() if len(vals) >= 1}
54
+ if len(groups) < 2:
55
+ raise ValueError("need at least 2 items")
56
+ if all(len(v) < 2 for v in groups.values()):
57
+ raise ValueError("need repeated judgments (at least one item with 2+ scores)")
58
+
59
+ n_total = sum(len(v) for v in groups.values())
60
+ n_groups = len(groups)
61
+ grand = sum(sum(v) for v in groups.values()) / n_total
62
+
63
+ ss_between = sum(len(v) * (sum(v) / len(v) - grand) ** 2 for v in groups.values())
64
+ ss_within = sum(
65
+ sum((x - sum(v) / len(v)) ** 2 for x in v) for v in groups.values()
66
+ )
67
+ ms_between = ss_between / (n_groups - 1)
68
+ ms_within = ss_within / (n_total - n_groups) if n_total > n_groups else 0.0
69
+
70
+ k0 = (n_total - sum(len(v) ** 2 for v in groups.values()) / n_total) / (n_groups - 1)
71
+ var_within = ms_within
72
+ var_between = max(0.0, (ms_between - ms_within) / k0) if k0 > 0 else 0.0
73
+ total = var_between + var_within
74
+ icc = var_between / total if total > 0 else 0.0
75
+
76
+ exact = sum(1 for v in groups.values() if len(set(v)) == 1) / n_groups
77
+ return Reliability(
78
+ n_items=n_groups,
79
+ n_judgments=n_total,
80
+ mean_reps=n_total / n_groups,
81
+ icc=icc,
82
+ sd_between=math.sqrt(var_between),
83
+ sd_within=math.sqrt(var_within),
84
+ exact_agreement=exact,
85
+ )
@@ -0,0 +1,201 @@
1
+ Metadata-Version: 2.4
2
+ Name: abeval
3
+ Version: 0.1.0
4
+ Summary: A/B-test statistics for LLM evals: error bars, paired comparisons, and sample-size planning
5
+ Project-URL: Homepage, https://github.com/mohammadi-hadi/abeval
6
+ Project-URL: Calculator, https://mohammadi.cv/abeval/
7
+ Author: Hadi Mohammadi
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: ab-testing,evaluation,llm,sample-size,significance,statistics
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
17
+ Requires-Python: >=3.10
18
+ Provides-Extra: dev
19
+ Requires-Dist: build; extra == 'dev'
20
+ Requires-Dist: pytest; extra == 'dev'
21
+ Requires-Dist: ruff; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # abeval
25
+
26
+ A/B-test statistics for LLM evals.
27
+
28
+ Eval scores are sample estimates, but they're routinely reported as exact
29
+ numbers: "our prompt scores 76.5%, the old one 63.5%, ship it." On 200 items
30
+ much smaller gaps than that are pure noise. abeval treats an eval like an A/B
31
+ test — confidence intervals for a single run, paired significance tests for
32
+ comparing two runs on the same items, and sample-size planning *before* you
33
+ spend on inference. Standard library only, no dependencies.
34
+
35
+ **Sample-size calculator:** https://mohammadi.cv/abeval/
36
+
37
+ ## Install
38
+
39
+ ```
40
+ pip install abeval
41
+ ```
42
+
43
+ Or from source: `git clone https://github.com/mohammadi-hadi/abeval && cd abeval && make install`.
44
+
45
+ ## Is my new prompt actually better?
46
+
47
+ Point `abeval compare` at two JSONL result files that share item ids:
48
+
49
+ ```
50
+ $ abeval compare examples/run_a.jsonl examples/run_b.jsonl
51
+ A: 63.5% B: 76.5% (n=200 paired items)
52
+ B - A: +13.0% [4.5%, 21.5%] (95% CI)
53
+ p (sign-flip permutation): 0.0041 p (paired t): 0.0034
54
+ discordant items: B-only wins 53, A-only wins 27 p (exact McNemar): 0.0049
55
+ verdict: significant at the chosen level
56
+ ```
57
+
58
+ The comparison is **paired**: both runs are scored on the same items, so
59
+ item difficulty cancels out and you detect much smaller differences than two
60
+ independent runs would allow. Items present in only one run are dropped (and
61
+ reported).
62
+
63
+ Every command takes `--json` for machine-readable output, and `--seed` makes
64
+ the resampling reproducible.
65
+
66
+ ## Error bars for one run
67
+
68
+ ```
69
+ $ abeval ci examples/run_a.jsonl
70
+ score: 63.5% [56.6%, 69.9%] (n=200, 95% CI, wilson)
71
+ ```
72
+
73
+ Binary metrics get a Wilson score interval, continuous metrics a t interval
74
+ (or `--bootstrap`). If your items aren't independent — 5 questions generated
75
+ from each source document, say — pass `--cluster-key document` and the
76
+ interval widens to account for within-cluster correlation.
77
+
78
+ ## How many items do I need?
79
+
80
+ Run this *before* the eval, not after:
81
+
82
+ ```
83
+ $ abeval power --baseline 0.75 --delta 0.03
84
+ to detect 3.0% difference from a 75.0% baseline (corr=0.5):
85
+ n = 1568 paired items (80% power, 95% confidence)
86
+ with 100 items you can detect >= 11.9%
87
+ with 200 items you can detect >= 8.4%
88
+ with 500 items you can detect >= 5.3%
89
+ with 1000 items you can detect >= 3.8%
90
+ ```
91
+
92
+ The lesson generalizes: a 200-item eval cannot see a 3-point improvement.
93
+ `--corr` is the item-level correlation between the two runs' outcomes (0.5 is
94
+ a reasonable default for two variants of the same system; higher correlation
95
+ means fewer items needed). There's an interactive version at
96
+ https://mohammadi.cv/abeval/.
97
+
98
+ ## How noisy is my judge?
99
+
100
+ If an LLM judge scores the same item differently on repeat calls, that noise
101
+ eats your statistical power. Feed repeated judgments to `reliability`:
102
+
103
+ ```
104
+ $ abeval reliability examples/judge_repeats.jsonl
105
+ 40 items, 120 judgments (3.0 per item)
106
+ ICC (signal share of variance): 0.764
107
+ between-item sd: 1.708 judge noise sd: 0.9487
108
+ exact agreement across repeats: 15.0%
109
+ repeats per item to push judge noise under 10%: 3
110
+ ```
111
+
112
+ ICC is the share of score variance that is real item signal rather than judge
113
+ noise, from a one-way random-effects decomposition. The last line answers the
114
+ practical question: how many repeat judgments to average per item.
115
+
116
+ ## Data format
117
+
118
+ JSONL, one item per line, any extra fields ignored:
119
+
120
+ ```json
121
+ {"id": "item-001", "score": 1}
122
+ {"id": "item-002", "score": 0, "category": "reasoning"}
123
+ ```
124
+
125
+ `--metric` and `--id-key` rename the fields; booleans are accepted as 0/1.
126
+ The files in `examples/` are generated by `examples/make_fixtures.py`
127
+ (seeded, so they're reproducible).
128
+
129
+ ## Python API
130
+
131
+ Everything the CLI does is a plain function:
132
+
133
+ ```python
134
+ from abeval import paired_compare, proportion_ci, sample_size, sd_diff_from_rates
135
+
136
+ proportion_ci(127, 200) # Wilson interval, level=0.95
137
+ result = paired_compare(scores_a, scores_b, seed=0)
138
+ result.diff, result.ci_lo, result.ci_hi, result.p_permutation
139
+
140
+ sd = sd_diff_from_rates(0.75, 0.78, corr=0.5)
141
+ sample_size(0.03, sd) # -> 1568 paired items
142
+ ```
143
+
144
+ ## What's inside
145
+
146
+ | Question | Method |
147
+ |---|---|
148
+ | CI for a pass rate | Wilson score interval |
149
+ | CI for a mean score | Student t, percentile bootstrap, or cluster-robust t |
150
+ | Is B better than A? | Sign-flip permutation test on paired differences (primary), paired t, exact McNemar on discordant pairs (binary) |
151
+ | How many items? | Normal-approximation power for the paired design, with a Monte-Carlo check (`power_simulated`) |
152
+ | Judge noise | One-way random-effects ICC with unbalanced-design correction |
153
+
154
+ The permutation p-value is the primary test: it's exact under item
155
+ exchangeability and makes no normality assumption. The paired t and McNemar
156
+ values are printed alongside because reviewers ask for them.
157
+
158
+ The approach follows Miller,
159
+ ["Adding Error Bars to Evals"](https://arxiv.org/abs/2411.00640) (2024) —
160
+ report standard errors, use paired designs, plan sample sizes — packaged as a
161
+ tool you can point at result files.
162
+
163
+ ## Honest limitations
164
+
165
+ - The power formulas use the normal approximation; for very small samples or
166
+ rates near 0/1 trust `power_simulated` over the closed form.
167
+ - Cluster-robust intervals are approximate with fewer than ~20 clusters.
168
+ - Multiple-comparison correction is on you: if you compare ten prompts and
169
+ ship the best p-value, it's inflated (a `sweep` command with Bonferroni
170
+ correction is on the roadmap).
171
+ - This is not a stats library. If you need regressions or GLMs, use
172
+ statsmodels; abeval covers the eval loop with zero dependencies.
173
+
174
+ ## Sponsoring
175
+
176
+ abeval is MIT-licensed and dependency-free, and it stays that way. Sponsoring
177
+ funds the roadmap below and the maintenance time to keep the statistics
178
+ trustworthy. Sponsors are credited in release notes and vote on what lands
179
+ next: [GitHub Sponsors](https://github.com/sponsors/mohammadi-hadi).
180
+
181
+ ## Roadmap
182
+
183
+ - `sweep`: compare N runs against a baseline with multiple-comparison
184
+ correction.
185
+ - Confidence intervals for pairwise win rates (Bradley-Terry) for
186
+ head-to-head judge outputs.
187
+ - Variance-reduction guidance: how much power you buy by averaging k judge
188
+ repeats, given your measured ICC.
189
+
190
+ ## Related projects
191
+
192
+ - [judgewatch](https://github.com/mohammadi-hadi/judgewatch) — monthly bias
193
+ audits of LLM judges; abeval tells you whether this month's change is real.
194
+ - [judgekit](https://github.com/mohammadi-hadi/judgekit) — audit your own
195
+ judge pipelines.
196
+ - [trajectory-judge](https://github.com/mohammadi-hadi/trajectory-judge) —
197
+ evaluating agent trajectories with LLM judges.
198
+
199
+ ## License
200
+
201
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,13 @@
1
+ abeval/__init__.py,sha256=9N4TC2HGKp5MR1K2mYDdrne986Vmr0AMg7pA_ndIkqE,902
2
+ abeval/__main__.py,sha256=_gXHiC5klfM8bIZJkggW2pd6sxL5S5hEbWv6XIS5bMg,8118
3
+ abeval/_dist.py,sha256=7xGFRocjFR_vKidhUN_ILMiH9qbsNYjr8HtaABcmQdI,2899
4
+ abeval/ci.py,sha256=RPTAOHTh6lYQMSiC4_4XXQFn9YCMOxYu0bsq3Dt897w,3737
5
+ abeval/compare.py,sha256=zVfDlBVDyXsVFoYiqgEkfL-pIr-9pqJl1V4trACQA4I,3877
6
+ abeval/data.py,sha256=-gnQoxhApCWduPXR1lH3dP8rjCxwD2QaejvE_FNL90s,1955
7
+ abeval/power.py,sha256=SmYBmx9BdF3CIEUd9Dxq2klSvPrKAqY9QqdFGawpfpI,4054
8
+ abeval/reliability.py,sha256=MKJOcWGB8DnxZYd-cJzR8ZXCCrfP7sbLv8wXOl25zu0,3200
9
+ abeval-0.1.0.dist-info/METADATA,sha256=_MgYaOfWv2OiibsmCuXS4SFB9-V7Urb1GYx0UNyhzYI,7530
10
+ abeval-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
11
+ abeval-0.1.0.dist-info/entry_points.txt,sha256=7yE5AcyOrFQsKiB0VlKzOhcbt7celHftfkCSYP1OG-g,48
12
+ abeval-0.1.0.dist-info/licenses/LICENSE,sha256=F-4iu05PfGFM6P0UkwKzuusSISVTwYeaC14QMud0b9g,1071
13
+ abeval-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ abeval = abeval.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hadi Mohammadi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.