evalproof 0.2.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.
@@ -0,0 +1,227 @@
1
+ """Scorers that need no model: string, pattern, schema and numeric checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import re
8
+ from typing import Any
9
+
10
+ from evalproof.models import Case, Score, SystemOutput
11
+
12
+ NUMBER = re.compile(r"[-+]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?")
13
+
14
+
15
+ def normalize(text: str) -> str:
16
+ text = text.strip().lower()
17
+ text = re.sub(r"\s+", " ", text)
18
+ return text.strip(" .!?;:\"'`")
19
+
20
+
21
+ def _case_list(case: Case, field: str | None) -> list[str]:
22
+ if not field:
23
+ return []
24
+ value = case.field(field)
25
+ if value is None:
26
+ return []
27
+ if isinstance(value, str):
28
+ return [value]
29
+ return [str(v) for v in value]
30
+
31
+
32
+ def _binary(ok: bool, reason: str | None = None) -> Score:
33
+ return Score(value=1.0 if ok else 0.0, passed=ok, reason=reason)
34
+
35
+
36
+ class ExactMatch:
37
+ def __init__(self, name: str, field: str = "reference", normalize: bool = True) -> None:
38
+ self.name, self.field, self.norm = name, field, normalize
39
+
40
+ def score(self, case: Case, out: SystemOutput) -> Score:
41
+ expected = case.field(self.field)
42
+ if expected is None:
43
+ return Score(value=None, reason=f"case has no {self.field!r}")
44
+ got, want = out.output, str(expected)
45
+ if self.norm:
46
+ got, want = normalize(got), normalize(want)
47
+ return _binary(got == want)
48
+
49
+
50
+ class Contains:
51
+ """Substring checks. ``all_of`` must all appear; ``any_of`` needs at least one."""
52
+
53
+ def __init__(
54
+ self,
55
+ name: str,
56
+ any_of: list[str] | None = None,
57
+ all_of: list[str] | None = None,
58
+ any_of_field: str | None = None,
59
+ all_of_field: str | None = None,
60
+ case_sensitive: bool = False,
61
+ ) -> None:
62
+ self.name = name
63
+ self.any_of, self.all_of = any_of or [], all_of or []
64
+ self.any_of_field, self.all_of_field = any_of_field, all_of_field
65
+ self.case_sensitive = case_sensitive
66
+
67
+ def score(self, case: Case, out: SystemOutput) -> Score:
68
+ text = out.output if self.case_sensitive else out.output.lower()
69
+
70
+ def fold(s: str) -> str:
71
+ return s if self.case_sensitive else s.lower()
72
+
73
+ any_of = self.any_of + _case_list(case, self.any_of_field)
74
+ all_of = self.all_of + _case_list(case, self.all_of_field)
75
+ if not any_of and not all_of:
76
+ return Score(value=None, reason="nothing to look for")
77
+ missing = [s for s in all_of if fold(s) not in text]
78
+ any_ok = not any_of or any(fold(s) in text for s in any_of)
79
+ ok = not missing and any_ok
80
+ reason = None if ok else f"missing {missing}" if missing else "none of any_of found"
81
+ return _binary(ok, reason)
82
+
83
+
84
+ class Regex:
85
+ """``pattern``/``any_of_field`` must match; ``none_of``/``none_of_field`` must not."""
86
+
87
+ def __init__(
88
+ self,
89
+ name: str,
90
+ pattern: str | None = None,
91
+ any_of_field: str | None = None,
92
+ none_of: list[str] | None = None,
93
+ none_of_field: str | None = None,
94
+ flags: str = "i",
95
+ ) -> None:
96
+ self.name = name
97
+ self.patterns = [pattern] if pattern else []
98
+ self.any_of_field = any_of_field
99
+ self.none_of = none_of or []
100
+ self.none_of_field = none_of_field
101
+ self.flags = (re.IGNORECASE if "i" in flags else 0) | (re.MULTILINE if "m" in flags else 0)
102
+ self.flags |= re.DOTALL if "s" in flags else 0
103
+
104
+ def score(self, case: Case, out: SystemOutput) -> Score:
105
+ accept = self.patterns + _case_list(case, self.any_of_field)
106
+ reject = self.none_of + _case_list(case, self.none_of_field)
107
+ if not accept:
108
+ return Score(value=None, reason="no accept pattern")
109
+ hit = next((p for p in accept if re.search(p, out.output, self.flags)), None)
110
+ bad = next((p for p in reject if re.search(p, out.output, self.flags)), None)
111
+ ok = hit is not None and bad is None
112
+ reason = None if ok else f"rejected by {bad!r}" if bad else "no accept pattern matched"
113
+ return _binary(ok, reason)
114
+
115
+
116
+ def _check(instance: Any, schema: dict[str, Any], path: str = "$") -> list[str]:
117
+ """A deliberately small JSON Schema subset: type, enum, required, properties, items."""
118
+ errors: list[str] = []
119
+ types = {
120
+ "object": dict,
121
+ "array": list,
122
+ "string": str,
123
+ "boolean": bool,
124
+ "null": type(None),
125
+ }
126
+ expected = schema.get("type")
127
+ if expected == "number":
128
+ if not isinstance(instance, int | float) or isinstance(instance, bool):
129
+ return [f"{path}: expected number"]
130
+ elif expected == "integer":
131
+ if not isinstance(instance, int) or isinstance(instance, bool):
132
+ return [f"{path}: expected integer"]
133
+ elif expected in types and not isinstance(instance, types[expected]):
134
+ return [f"{path}: expected {expected}"]
135
+ if "enum" in schema and instance not in schema["enum"]:
136
+ errors.append(f"{path}: {instance!r} not in enum")
137
+ if isinstance(instance, dict):
138
+ for key in schema.get("required", []):
139
+ if key not in instance:
140
+ errors.append(f"{path}: missing required {key!r}")
141
+ for key, sub in schema.get("properties", {}).items():
142
+ if key in instance:
143
+ errors.extend(_check(instance[key], sub, f"{path}.{key}"))
144
+ if isinstance(instance, list) and "items" in schema:
145
+ for i, item in enumerate(instance):
146
+ errors.extend(_check(item, schema["items"], f"{path}[{i}]"))
147
+ return errors
148
+
149
+
150
+ def extract_json(text: str) -> Any:
151
+ """Parse JSON from a model reply that may wrap it in prose or a code fence."""
152
+ text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
153
+ fence = re.search(r"```(?:json)?\s*(.*?)```", text, re.DOTALL)
154
+ if fence:
155
+ text = fence.group(1).strip()
156
+ try:
157
+ return json.loads(text)
158
+ except json.JSONDecodeError:
159
+ pass
160
+ start = text.find("{")
161
+ while start != -1:
162
+ depth = 0
163
+ for i in range(start, len(text)):
164
+ if text[i] == "{":
165
+ depth += 1
166
+ elif text[i] == "}":
167
+ depth -= 1
168
+ if depth == 0:
169
+ try:
170
+ return json.loads(text[start : i + 1])
171
+ except json.JSONDecodeError:
172
+ break
173
+ start = text.find("{", start + 1)
174
+ raise ValueError("no JSON object found")
175
+
176
+
177
+ class JsonSchema:
178
+ def __init__(
179
+ self, name: str, schema: dict[str, Any] | None = None, schema_field: str | None = None
180
+ ) -> None:
181
+ self.name, self.schema, self.schema_field = name, schema, schema_field
182
+
183
+ def score(self, case: Case, out: SystemOutput) -> Score:
184
+ schema = self.schema or (case.field(self.schema_field) if self.schema_field else None)
185
+ if not schema:
186
+ return Score(value=None, reason="no schema")
187
+ try:
188
+ data = extract_json(out.output)
189
+ except ValueError:
190
+ return _binary(False, "output is not JSON")
191
+ errors = _check(data, schema)
192
+ return _binary(not errors, "; ".join(errors[:3]) or None)
193
+
194
+
195
+ class NumericTolerance:
196
+ def __init__(
197
+ self,
198
+ name: str,
199
+ field: str = "reference",
200
+ abs_tol: float = 0.0,
201
+ rel_tol: float = 0.0,
202
+ pick: str = "first",
203
+ ) -> None:
204
+ if pick not in {"first", "last", "any"}:
205
+ raise ValueError("pick must be first, last or any")
206
+ self.name, self.field, self.abs_tol, self.rel_tol, self.pick = (
207
+ name,
208
+ field,
209
+ abs_tol,
210
+ rel_tol,
211
+ pick,
212
+ )
213
+
214
+ def score(self, case: Case, out: SystemOutput) -> Score:
215
+ raw = case.field(self.field)
216
+ if raw is None:
217
+ return Score(value=None, reason=f"case has no {self.field!r}")
218
+ expected = float(str(raw).replace(",", ""))
219
+ found = [float(n.replace(",", "")) for n in NUMBER.findall(out.output)]
220
+ if not found:
221
+ return _binary(False, "no number in output")
222
+ candidates = {"first": found[:1], "last": found[-1:], "any": found}[self.pick]
223
+ ok = any(
224
+ math.isclose(x, expected, rel_tol=self.rel_tol, abs_tol=self.abs_tol)
225
+ for x in candidates
226
+ )
227
+ return _binary(ok, None if ok else f"expected {expected}, found {candidates}")
@@ -0,0 +1,187 @@
1
+ """LLM-as-judge scorers.
2
+
3
+ A judge is a classifier, so it is treated like one: a strict JSON verdict, a parse
4
+ retry instead of a guess, ``undecided`` instead of a silent pass, and — in
5
+ ``evalproof.calibration`` — measured error rates instead of trust.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Literal
12
+
13
+ from evalproof.config import GenerationParams
14
+ from evalproof.models import Case, Score, SystemOutput
15
+ from evalproof.providers import Message, Provider, ProviderError
16
+ from evalproof.scorers.deterministic import extract_json
17
+
18
+ DEFAULT_RUBRIC = (
19
+ "Pass if the response answers the question correctly and is consistent with the "
20
+ "reference answer. Fail if it is wrong, contradicts the reference, invents facts the "
21
+ "reference does not support, or does not answer. Wording and length do not matter."
22
+ )
23
+
24
+ JUDGE_SYSTEM = (
25
+ "You are a strict, consistent evaluator. You grade one response at a time against a "
26
+ "rubric and a reference answer. Reply with a single JSON object and nothing else."
27
+ )
28
+
29
+ JUDGE_TEMPLATE = """Rubric:
30
+ {rubric}
31
+
32
+ Question:
33
+ {question}
34
+
35
+ Reference answer:
36
+ {reference}
37
+
38
+ Response to grade:
39
+ {response}
40
+
41
+ Reply with exactly this JSON shape:
42
+ {{"reason": "<one sentence>", "pass": true or false, "score": <number from 0 to 1>}}"""
43
+
44
+ RETRY_NUDGE = (
45
+ "That was not a valid JSON object of the requested shape. Reply with only the JSON "
46
+ 'object: {"reason": "...", "pass": true or false, "score": 0 to 1}'
47
+ )
48
+
49
+
50
+ def _question(case: Case) -> str:
51
+ if isinstance(case.input, dict):
52
+ return str(case.input.get("question") or case.input)
53
+ return case.input
54
+
55
+
56
+ def parse_verdict(text: str) -> tuple[bool, float, str]:
57
+ data = extract_json(text)
58
+ if not isinstance(data, dict) or "pass" not in data:
59
+ raise ValueError("verdict needs a 'pass' field")
60
+ passed = data["pass"]
61
+ if isinstance(passed, str):
62
+ passed = passed.strip().lower() in {"true", "yes", "pass"}
63
+ if not isinstance(passed, bool):
64
+ raise ValueError("'pass' must be a boolean")
65
+ raw_score = data.get("score", 1.0 if passed else 0.0)
66
+ try:
67
+ score = min(1.0, max(0.0, float(raw_score)))
68
+ except (TypeError, ValueError) as exc:
69
+ raise ValueError("'score' must be a number") from exc
70
+ return passed, score, str(data.get("reason", ""))[:500]
71
+
72
+
73
+ class LLMJudge:
74
+ def __init__(
75
+ self,
76
+ name: str,
77
+ provider: Provider,
78
+ params: GenerationParams,
79
+ rubric: str = DEFAULT_RUBRIC,
80
+ max_retries: int = 1,
81
+ ) -> None:
82
+ self.name = name
83
+ self.provider = provider
84
+ self.params = params
85
+ self.rubric = rubric
86
+ self.max_retries = max_retries
87
+
88
+ def messages(self, case: Case, out: SystemOutput) -> list[Message]:
89
+ prompt = JUDGE_TEMPLATE.format(
90
+ rubric=self.rubric.strip(),
91
+ question=_question(case),
92
+ reference=case.reference or "(none provided)",
93
+ response=out.output or "(empty)",
94
+ )
95
+ return [Message(role="system", content=JUDGE_SYSTEM), Message(role="user", content=prompt)]
96
+
97
+ def score(self, case: Case, out: SystemOutput) -> Score:
98
+ if out.error:
99
+ return Score(value=0.0, passed=False, reason=f"system error: {out.error[:200]}")
100
+ messages = self.messages(case, out)
101
+ tokens = 0
102
+ last_text = ""
103
+ for _attempt in range(self.max_retries + 1):
104
+ try:
105
+ resp = self.provider.chat(messages, self.params)
106
+ except ProviderError as exc:
107
+ return Score(value=None, reason=f"judge error: {exc}", judge_tokens=tokens)
108
+ tokens += resp.tokens_in + resp.tokens_out
109
+ last_text = resp.text
110
+ try:
111
+ passed, value, reason = parse_verdict(resp.text)
112
+ except ValueError:
113
+ messages = [
114
+ *messages,
115
+ Message(role="assistant", content=resp.text),
116
+ Message(role="user", content=RETRY_NUDGE),
117
+ ]
118
+ continue
119
+ return Score(value=value, passed=passed, reason=reason, judge_tokens=tokens)
120
+ return Score(
121
+ value=None,
122
+ reason=f"unparseable judge reply: {last_text[:200]!r}",
123
+ judge_tokens=tokens,
124
+ )
125
+
126
+
127
+ PAIRWISE_TEMPLATE = """Question:
128
+ {question}
129
+
130
+ Reference answer:
131
+ {reference}
132
+
133
+ Response A:
134
+ {a}
135
+
136
+ Response B:
137
+ {b}
138
+
139
+ Which response answers the question better, judged against the reference? Reply with
140
+ exactly this JSON shape: {{"reason": "<one sentence>", "winner": "A" or "B" or "tie"}}"""
141
+
142
+ Winner = Literal["a", "b", "tie"]
143
+
144
+
145
+ @dataclass(frozen=True)
146
+ class PairwiseResult:
147
+ winner: Winner
148
+ consistent: bool
149
+ first: str # verdict with (a, b) order, in a/b terms
150
+ second: str # verdict with (b, a) order, mapped back to a/b terms
151
+
152
+
153
+ def _pairwise_once(
154
+ provider: Provider, params: GenerationParams, case: Case, first: str, second: str
155
+ ) -> str:
156
+ prompt = PAIRWISE_TEMPLATE.format(
157
+ question=_question(case), reference=case.reference or "(none provided)", a=first, b=second
158
+ )
159
+ resp = provider.chat(
160
+ [Message(role="system", content=JUDGE_SYSTEM), Message(role="user", content=prompt)],
161
+ params,
162
+ )
163
+ data = extract_json(resp.text)
164
+ winner = str(data.get("winner", "")).strip().lower() if isinstance(data, dict) else ""
165
+ if winner not in {"a", "b", "tie"}:
166
+ raise ValueError(f"bad pairwise verdict: {resp.text[:120]!r}")
167
+ return winner
168
+
169
+
170
+ def pairwise(
171
+ provider: Provider,
172
+ params: GenerationParams,
173
+ case: Case,
174
+ a: str,
175
+ b: str,
176
+ swap: bool = True,
177
+ ) -> PairwiseResult:
178
+ """Compare two outputs. With ``swap`` the judge sees both orders; a verdict that flips
179
+ with position is reported as a tie and flagged inconsistent (position bias)."""
180
+ first = _pairwise_once(provider, params, case, a, b)
181
+ if not swap:
182
+ return PairwiseResult(winner=first, consistent=True, first=first, second=first) # type: ignore[arg-type]
183
+ raw = _pairwise_once(provider, params, case, b, a)
184
+ second = {"a": "b", "b": "a", "tie": "tie"}[raw]
185
+ consistent = first == second
186
+ winner: Winner = first if consistent else "tie" # type: ignore[assignment]
187
+ return PairwiseResult(winner=winner, consistent=consistent, first=first, second=second)
@@ -0,0 +1,64 @@
1
+ """Retrieval metrics over ``SystemOutput.retrieved`` against a case's ``relevant`` ids."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ from evalproof.models import Case, Score, SystemOutput
8
+
9
+
10
+ def _relevance(case: Case, field: str) -> dict[str, float]:
11
+ value = case.field(field)
12
+ if value is None:
13
+ return {}
14
+ if isinstance(value, dict):
15
+ return {str(k): float(v) for k, v in value.items()}
16
+ return {str(v): 1.0 for v in value}
17
+
18
+
19
+ class _RetrievalScorer:
20
+ def __init__(
21
+ self, name: str, k: int = 10, field: str = "relevant", pass_threshold: float | None = None
22
+ ) -> None:
23
+ if k < 1:
24
+ raise ValueError("k must be >= 1")
25
+ self.name, self.k, self.field, self.pass_threshold = name, k, field, pass_threshold
26
+
27
+ def _finish(self, value: float) -> Score:
28
+ passed = None if self.pass_threshold is None else value >= self.pass_threshold
29
+ return Score(value=value, passed=passed)
30
+
31
+ def metric(self, rel: dict[str, float], ranked: list[str]) -> float:
32
+ raise NotImplementedError
33
+
34
+ def score(self, case: Case, out: SystemOutput) -> Score:
35
+ rel = _relevance(case, self.field)
36
+ if not rel:
37
+ return Score(value=None, reason=f"case has no {self.field!r}")
38
+ return self._finish(self.metric(rel, out.retrieved[: self.k]))
39
+
40
+
41
+ class RecallAtK(_RetrievalScorer):
42
+ def metric(self, rel: dict[str, float], ranked: list[str]) -> float:
43
+ relevant = {d for d, g in rel.items() if g > 0}
44
+ return len(relevant & set(ranked)) / len(relevant) if relevant else 0.0
45
+
46
+
47
+ class MRR(_RetrievalScorer):
48
+ def metric(self, rel: dict[str, float], ranked: list[str]) -> float:
49
+ for i, doc in enumerate(ranked, start=1):
50
+ if rel.get(doc, 0) > 0:
51
+ return 1.0 / i
52
+ return 0.0
53
+
54
+
55
+ class NDCG(_RetrievalScorer):
56
+ """nDCG@k with exponential gain ``2^rel - 1``."""
57
+
58
+ def metric(self, rel: dict[str, float], ranked: list[str]) -> float:
59
+ def dcg(gains: list[float]) -> float:
60
+ return sum((2**g - 1) / math.log2(i + 2) for i, g in enumerate(gains))
61
+
62
+ actual = dcg([rel.get(doc, 0.0) for doc in ranked])
63
+ ideal = dcg(sorted(rel.values(), reverse=True)[: self.k])
64
+ return actual / ideal if ideal > 0 else 0.0
evalproof/stats.py ADDED
@@ -0,0 +1,145 @@
1
+ """Small, readable statistics. numpy and the standard library only — no SciPy.
2
+
3
+ Every function here has a known-value test in ``tests/test_stats.py``.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import math
9
+ from collections.abc import Callable, Sequence
10
+ from dataclasses import dataclass
11
+ from fractions import Fraction
12
+ from statistics import NormalDist
13
+
14
+ import numpy as np
15
+ from numpy.typing import NDArray
16
+
17
+ FloatArray = NDArray[np.float64]
18
+
19
+
20
+ def z_for(alpha: float) -> float:
21
+ """Two-sided critical value: ``z_for(0.05)`` ≈ 1.959964."""
22
+ if not 0 < alpha < 1:
23
+ raise ValueError("alpha must be in (0, 1)")
24
+ return NormalDist().inv_cdf(1 - alpha / 2)
25
+
26
+
27
+ def wilson(k: int, n: int, alpha: float = 0.05) -> tuple[float, float]:
28
+ """Wilson score interval for a binomial proportion. Behaves at 0/n and n/n."""
29
+ if n <= 0:
30
+ return (0.0, 1.0)
31
+ if not 0 <= k <= n:
32
+ raise ValueError("need 0 <= k <= n")
33
+ z = z_for(alpha)
34
+ p = k / n
35
+ denom = 1 + z * z / n
36
+ centre = (p + z * z / (2 * n)) / denom
37
+ half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / denom
38
+ # At k=0 the lower bound is exactly 0 (centre == half), and at k=n the upper
39
+ # bound is exactly 1. Computing them by subtraction leaves platform-dependent
40
+ # rounding (2.8e-17 on x86_64 Linux, 0.0 on arm64 macOS), so pin them.
41
+ lo = 0.0 if k == 0 else max(0.0, centre - half)
42
+ hi = 1.0 if k == n else min(1.0, centre + half)
43
+ return (lo, hi)
44
+
45
+
46
+ def bootstrap_ci(
47
+ values: Sequence[float] | FloatArray,
48
+ stat: Callable[[FloatArray], float] = lambda x: float(np.mean(x)),
49
+ *,
50
+ n_boot: int = 5000,
51
+ alpha: float = 0.05,
52
+ seed: int = 0,
53
+ ) -> tuple[float, float]:
54
+ """Percentile bootstrap interval, seeded so a report is reproducible."""
55
+ arr = np.asarray(values, dtype=np.float64)
56
+ if arr.size == 0:
57
+ raise ValueError("no values")
58
+ rng = np.random.default_rng(seed)
59
+ idx = rng.integers(0, arr.size, size=(n_boot, arr.size))
60
+ stats = np.array([stat(arr[row]) for row in idx])
61
+ lo, hi = np.quantile(stats, [alpha / 2, 1 - alpha / 2])
62
+ return (float(lo), float(hi))
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class PairedDelta:
67
+ """``delta = mean(candidate) - mean(baseline)`` over paired cases."""
68
+
69
+ delta: float
70
+ ci_low: float
71
+ ci_high: float
72
+ p_value: float
73
+ n: int
74
+
75
+
76
+ def paired_bootstrap(
77
+ baseline: Sequence[float] | FloatArray,
78
+ candidate: Sequence[float] | FloatArray,
79
+ *,
80
+ n_boot: int = 5000,
81
+ alpha: float = 0.05,
82
+ seed: int = 0,
83
+ ) -> PairedDelta:
84
+ """Resample *case indices*, so each replicate keeps baseline and candidate paired.
85
+
86
+ The two-sided p-value is the percentile-bootstrap approximation
87
+ ``2 * min(P(d* <= 0), P(d* >= 0))`` — adequate for gating, and stated as such.
88
+ """
89
+ a = np.asarray(baseline, dtype=np.float64)
90
+ b = np.asarray(candidate, dtype=np.float64)
91
+ if a.shape != b.shape or a.size == 0:
92
+ raise ValueError("need two non-empty arrays of equal length")
93
+ diff = b - a
94
+ rng = np.random.default_rng(seed)
95
+ idx = rng.integers(0, diff.size, size=(n_boot, diff.size))
96
+ reps = diff[idx].mean(axis=1)
97
+ lo, hi = np.quantile(reps, [alpha / 2, 1 - alpha / 2])
98
+ if np.all(diff == 0):
99
+ p = 1.0
100
+ else:
101
+ p = float(min(1.0, 2 * min(np.mean(reps <= 0), np.mean(reps >= 0))))
102
+ return PairedDelta(float(diff.mean()), float(lo), float(hi), p, int(diff.size))
103
+
104
+
105
+ def binom_cdf(k: int, n: int, p: float = 0.5) -> float:
106
+ """``P(X <= k)`` for ``X ~ Binomial(n, p)``, summed exactly and rounded once.
107
+
108
+ In floating point, ``comb(n, i)`` alone passes the largest float from about
109
+ n = 1,030, so a comparison with that many discordant pairs raised OverflowError
110
+ instead of returning a p-value. Integers and fractions do not overflow.
111
+ """
112
+ if k < 0:
113
+ return 0.0
114
+ if k >= n:
115
+ return 1.0
116
+ q = Fraction(p)
117
+ if q == Fraction(1, 2):
118
+ # The McNemar case: every outcome has probability 2**-n, so sum the binomial
119
+ # coefficients as integers and divide once (int / int is correctly rounded).
120
+ term = total = 1
121
+ for i in range(1, k + 1):
122
+ term = term * (n - i + 1) // i
123
+ total += term
124
+ return total / (1 << n)
125
+ return float(sum(math.comb(n, i) * q**i * (1 - q) ** (n - i) for i in range(k + 1)))
126
+
127
+
128
+ def mcnemar_exact(b: int, c: int) -> float:
129
+ """Exact two-sided McNemar test on the discordant pairs.
130
+
131
+ ``b`` = baseline passed, candidate failed; ``c`` = baseline failed, candidate passed.
132
+ Only discordant pairs carry information about a difference between the two systems.
133
+ """
134
+ if b < 0 or c < 0:
135
+ raise ValueError("counts must be non-negative")
136
+ n = b + c
137
+ if n == 0:
138
+ return 1.0
139
+ return min(1.0, 2 * binom_cdf(min(b, c), n))
140
+
141
+
142
+ def percentile(values: Sequence[float], q: float) -> float:
143
+ if not values:
144
+ return 0.0
145
+ return float(np.percentile(np.asarray(values, dtype=np.float64), q))