agent-regress-cli 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.
@@ -0,0 +1,63 @@
1
+ """agent-regress: statistical regression testing for LLM agents.
2
+
3
+ Find out if your latest agent deploy made things worse -- statistically.
4
+
5
+ from agent_regress import compare
6
+
7
+ report = compare(
8
+ version_a=my_agent_v1,
9
+ version_b=my_agent_v2,
10
+ test_suite=test_cases,
11
+ n_runs=50,
12
+ )
13
+ print(report)
14
+ report.assert_stable()
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from agent_regress.ci.gate import RegressionGate, assert_no_regression
20
+ from agent_regress.core.compare import compare
21
+ from agent_regress.core.report import Report, Verdict
22
+ from agent_regress.core.runner import (
23
+ AgentCallable,
24
+ AsyncAgentCallable,
25
+ ScorerCallable,
26
+ arun_suite,
27
+ concurrent_cancellation_probe,
28
+ run_suite,
29
+ subprocess_runner,
30
+ )
31
+ from agent_regress.core.scorer import (
32
+ exact_match_scorer,
33
+ f1_scorer,
34
+ no_path_leak_scorer,
35
+ schema_conformance_scorer,
36
+ state_diff_scorer,
37
+ structured_content_scorer,
38
+ tool_call_trace_scorer,
39
+ )
40
+
41
+ __version__ = "0.1.0"
42
+ __all__ = [
43
+ "AgentCallable",
44
+ "AsyncAgentCallable",
45
+ "RegressionGate",
46
+ "Report",
47
+ "ScorerCallable",
48
+ "Verdict",
49
+ "__version__",
50
+ "arun_suite",
51
+ "assert_no_regression",
52
+ "compare",
53
+ "concurrent_cancellation_probe",
54
+ "exact_match_scorer",
55
+ "f1_scorer",
56
+ "no_path_leak_scorer",
57
+ "run_suite",
58
+ "schema_conformance_scorer",
59
+ "state_diff_scorer",
60
+ "structured_content_scorer",
61
+ "subprocess_runner",
62
+ "tool_call_trace_scorer",
63
+ ]
@@ -0,0 +1,15 @@
1
+ """Standard benchmark harnesses: Tau-bench, GAIA, SWE-bench."""
2
+
3
+ from agent_regress.benchmarks.gaia import GAIAHarness, GAIALevel, GAIALevelResult
4
+ from agent_regress.benchmarks.swebench import SWEBenchHarness, SWEBenchResult
5
+ from agent_regress.benchmarks.tau_bench import TauBenchHarness, TauBenchResult
6
+
7
+ __all__ = [
8
+ "GAIAHarness",
9
+ "GAIALevel",
10
+ "GAIALevelResult",
11
+ "SWEBenchHarness",
12
+ "SWEBenchResult",
13
+ "TauBenchHarness",
14
+ "TauBenchResult",
15
+ ]
@@ -0,0 +1,80 @@
1
+ """GAIA Level 1-3 split harness.
2
+
3
+ GAIA (General AI Assistants benchmark) has three difficulty levels.
4
+ The Level 1-3 split reveals capability boundaries that aggregate scores hide.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import warnings
10
+ from dataclasses import dataclass
11
+ from enum import IntEnum
12
+ from typing import Any
13
+
14
+
15
+ class GAIALevel(IntEnum):
16
+ LEVEL_1 = 1
17
+ LEVEL_2 = 2
18
+ LEVEL_3 = 3
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class GAIALevelResult:
23
+ level: GAIALevel
24
+ accuracy: float
25
+ n_questions: int
26
+ n_correct: int
27
+
28
+
29
+ @dataclass
30
+ class GAIAHarness:
31
+ agent: Any
32
+ dataset: list[dict[str, Any]]
33
+
34
+ def _is_correct(self, output: Any, task: dict[str, Any]) -> bool:
35
+ if output is None: # sentinel returned by _safe_run on exception
36
+ return False
37
+ expected = task.get("expected_answer", "")
38
+ return str(output).strip().lower() == str(expected).strip().lower()
39
+
40
+ def evaluate(self) -> list[GAIALevelResult]:
41
+ if not self.dataset:
42
+ raise ValueError("dataset must not be empty")
43
+
44
+ by_level: dict[GAIALevel, list[dict[str, Any]]] = {lv: [] for lv in GAIALevel}
45
+ for task in self.dataset:
46
+ raw_level = task.get("level", 1)
47
+ try:
48
+ level = GAIALevel(int(raw_level))
49
+ except (ValueError, TypeError):
50
+ level = GAIALevel.LEVEL_1
51
+ by_level[level].append(task)
52
+
53
+ results: list[GAIALevelResult] = []
54
+ for level, tasks in by_level.items():
55
+ if not tasks:
56
+ continue
57
+ correct = sum(
58
+ 1 for task in tasks if self._is_correct(self._safe_run(task), task)
59
+ )
60
+ results.append(
61
+ GAIALevelResult(
62
+ level=level,
63
+ accuracy=correct / len(tasks),
64
+ n_questions=len(tasks),
65
+ n_correct=correct,
66
+ )
67
+ )
68
+ return sorted(results, key=lambda r: r.level)
69
+
70
+ def _safe_run(self, task: dict[str, Any]) -> Any:
71
+ try:
72
+ return self.agent(task)
73
+ except Exception as exc:
74
+ task_id = task.get("task_id", task.get("question_id", "unknown"))
75
+ warnings.warn(
76
+ f"Agent raised exception on task {task_id}: {exc}",
77
+ UserWarning,
78
+ stacklevel=2,
79
+ )
80
+ return None
@@ -0,0 +1,65 @@
1
+ """SWE-bench Verified scaffold score harness.
2
+
3
+ Measures the framework contribution to SWE-bench pass rate independent
4
+ of the underlying model. A better scaffold (test runner, output parser,
5
+ verdict logic) can lift pass rates 5-15pp on the same model.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import warnings
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+ _RESOLVED_STRINGS: frozenset[str] = frozenset({"resolved", "true", "pass", "1"})
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class SWEBenchResult:
19
+ scaffold_pass_rate: float
20
+ n_instances: int
21
+ n_resolved: int
22
+ instance_ids: list[str]
23
+ resolved_ids: list[str]
24
+
25
+
26
+ @dataclass
27
+ class SWEBenchHarness:
28
+ agent: Any
29
+ dataset: list[dict[str, Any]]
30
+
31
+ def _is_resolved(self, output: Any, instance: dict[str, Any]) -> bool:
32
+ if isinstance(output, bool):
33
+ return output
34
+ if isinstance(output, (int, float)):
35
+ return float(output) >= 0.5
36
+ return str(output).strip().lower() in _RESOLVED_STRINGS
37
+
38
+ def evaluate(self) -> SWEBenchResult:
39
+ if not self.dataset:
40
+ raise ValueError("dataset must not be empty")
41
+
42
+ resolved_ids: list[str] = []
43
+ all_ids: list[str] = []
44
+
45
+ for idx, instance in enumerate(self.dataset):
46
+ instance_id = str(instance.get("instance_id", f"instance_{idx}"))
47
+ all_ids.append(instance_id)
48
+ try:
49
+ output = self.agent(instance)
50
+ if self._is_resolved(output, instance):
51
+ resolved_ids.append(instance_id)
52
+ except Exception as exc:
53
+ warnings.warn(
54
+ f"Agent raised exception on {instance_id}: {exc}",
55
+ UserWarning,
56
+ stacklevel=2,
57
+ )
58
+
59
+ return SWEBenchResult(
60
+ scaffold_pass_rate=len(resolved_ids) / len(self.dataset),
61
+ n_instances=len(self.dataset),
62
+ n_resolved=len(resolved_ids),
63
+ instance_ids=all_ids,
64
+ resolved_ids=resolved_ids,
65
+ )
@@ -0,0 +1,86 @@
1
+ """Tau-bench pass^k harness.
2
+
3
+ pass^k measures the probability that an agent succeeds on at least one
4
+ attempt out of k independent runs. This captures reliability degradation
5
+ that single-run benchmarks hide: an agent that succeeds 60% of the time
6
+ will have pass^1=0.60, pass^4=0.974, pass^8=0.9993.
7
+
8
+ Usage:
9
+ harness = TauBenchHarness(agent=my_agent, dataset=load_tau_bench())
10
+ result = harness.evaluate(k_values=[1, 4, 8])
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import warnings
16
+ from dataclasses import dataclass
17
+ from typing import Any
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class TauBenchResult:
22
+ k: int
23
+ pass_at_k: float
24
+ n_tasks: int
25
+ n_successes: int
26
+ task_scores: list[float]
27
+
28
+
29
+ @dataclass
30
+ class TauBenchHarness:
31
+ agent: Any
32
+ dataset: list[dict[str, Any]]
33
+ success_threshold: float = 0.5
34
+
35
+ def _attempt(self, task: dict[str, Any]) -> bool:
36
+ try:
37
+ result = self.agent(task)
38
+ score = float(result) if isinstance(result, (int, float)) else 0.0
39
+ return score >= self.success_threshold
40
+ except Exception as exc:
41
+ warnings.warn(
42
+ f"Agent raised exception on task: {exc}",
43
+ UserWarning,
44
+ stacklevel=2,
45
+ )
46
+ return False
47
+
48
+ def _run_task_attempts(self, task: dict[str, Any], max_k: int) -> list[bool]:
49
+ results: list[bool] = []
50
+ for _ in range(max_k):
51
+ success = self._attempt(task)
52
+ results.append(success)
53
+ if success:
54
+ # any(results[:k]) == True for all k >= len(results) because
55
+ # Python slicing past the end returns all elements.
56
+ break
57
+ return results
58
+
59
+ def evaluate(self, k_values: list[int] | None = None) -> list[TauBenchResult]:
60
+ if k_values is None:
61
+ k_values = [1, 4, 8]
62
+ if not k_values:
63
+ raise ValueError("k_values must not be empty")
64
+ if not self.dataset:
65
+ raise ValueError("dataset must not be empty")
66
+
67
+ max_k = max(k_values)
68
+ # Run each task max_k times once; derive pass^k by checking first k attempts.
69
+ per_task_attempts = [
70
+ self._run_task_attempts(task, max_k) for task in self.dataset
71
+ ]
72
+
73
+ results: list[TauBenchResult] = []
74
+ for k in sorted(k_values):
75
+ task_passed = [any(attempts[:k]) for attempts in per_task_attempts]
76
+ n_success = sum(task_passed)
77
+ results.append(
78
+ TauBenchResult(
79
+ k=k,
80
+ pass_at_k=n_success / len(self.dataset),
81
+ n_tasks=len(self.dataset),
82
+ n_successes=n_success,
83
+ task_scores=[1.0 if p else 0.0 for p in task_passed],
84
+ )
85
+ )
86
+ return results
@@ -0,0 +1,5 @@
1
+ """CI gate for blocking deploys on statistically significant regressions."""
2
+
3
+ from agent_regress.ci.gate import RegressionGate, assert_no_regression
4
+
5
+ __all__ = ["RegressionGate", "assert_no_regression"]
@@ -0,0 +1,62 @@
1
+ """CI gate: raises AssertionError on statistically significant regression.
2
+
3
+ Designed for use as a pytest test step. Import and call assert_no_regression()
4
+ or use RegressionGate.check() inside a test function.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import warnings
10
+
11
+ from agent_regress.core.report import Report
12
+
13
+ _MIN_N_WARNING = 30
14
+
15
+
16
+ def assert_no_regression(
17
+ report: Report,
18
+ p_threshold: float = 0.05,
19
+ min_effect: float = 0.2,
20
+ ) -> None:
21
+ insufficient_n = report.n_a < _MIN_N_WARNING or report.n_b < _MIN_N_WARNING
22
+ if insufficient_n:
23
+ warnings.warn(
24
+ f"n_a={report.n_a}, n_b={report.n_b}: insufficient sample size for "
25
+ "reliable regression detection. Not failing the build due to "
26
+ "insufficient data. Use at least 30 runs per version.",
27
+ UserWarning,
28
+ stacklevel=2,
29
+ )
30
+ return
31
+
32
+ # Evaluate with the gate's own thresholds, not the verdict stored in the
33
+ # report (which was computed with compare()'s thresholds and may differ).
34
+ # report.assert_stable() also guards against INSUFFICIENT_DATA (n < 10).
35
+ report.assert_stable(p_threshold=p_threshold, min_effect=min_effect)
36
+
37
+
38
+ class RegressionGate:
39
+ def __init__(
40
+ self,
41
+ p_threshold: float = 0.05,
42
+ min_effect: float = 0.2,
43
+ ) -> None:
44
+ if not (0.0 < p_threshold < 1.0):
45
+ raise ValueError(f"p_threshold must be in (0, 1), got {p_threshold}")
46
+ if min_effect < 0.0:
47
+ raise ValueError(f"min_effect must be >= 0, got {min_effect}")
48
+ self.p_threshold = p_threshold
49
+ self.min_effect = min_effect
50
+
51
+ def check(self, report: Report) -> None:
52
+ assert_no_regression(
53
+ report,
54
+ p_threshold=self.p_threshold,
55
+ min_effect=self.min_effect,
56
+ )
57
+
58
+ def __repr__(self) -> str:
59
+ return (
60
+ f"RegressionGate(p_threshold={self.p_threshold}, "
61
+ f"min_effect={self.min_effect})"
62
+ )
agent_regress/cli.py ADDED
@@ -0,0 +1,298 @@
1
+ """Command-line entry point for agent-regress.
2
+
3
+ The public Python API (`agent_regress.compare()`) takes two *callables* --
4
+ `version_a` and `version_b` -- and runs them itself via `run_suite()`. That
5
+ shape has no clean shell equivalent: a CLI can't accept "a callable" as a
6
+ flag value. So this CLI exposes a different, file-based contract that fits
7
+ what a shell/agent workflow can realistically produce: two JSON files, each
8
+ holding a flat array of per-run scores already computed by whatever harness
9
+ ran that agent version (`[0.82, 0.79, 0.91, ...]`). Those two score arrays
10
+ are fed through the exact same Mann-Whitney U / bootstrap CI / Cohen's d /
11
+ Verdict pipeline `compare()` uses internally, so the statistical output is
12
+ identical for the same underlying scores -- only how the scores get here
13
+ differs.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import sys
21
+ import warnings
22
+ from pathlib import Path
23
+ from typing import Any, NoReturn
24
+
25
+ import numpy as np
26
+
27
+ from agent_regress import __version__
28
+ from agent_regress.core.report import Report, Verdict
29
+ from agent_regress.stats.bootstrap import bootstrap_mean_ci
30
+ from agent_regress.stats.effect_size import cohens_d
31
+ from agent_regress.stats.mann_whitney import mann_whitney_u
32
+
33
+ _MIN_N_WARN = 50
34
+ _PROG = "agent-regress"
35
+
36
+
37
+ def _fail(message: str) -> NoReturn:
38
+ """Print a usage/data error to stderr and exit non-zero.
39
+
40
+ Never writes to stdout, so `--json` invocations keep stdout clean even
41
+ on failure -- callers only need to check the exit code and stderr.
42
+ """
43
+ print(f"{_PROG}: error: {message}", file=sys.stderr)
44
+ raise SystemExit(2)
45
+
46
+
47
+ def _load_scores(path: str, flag: str) -> list[float]:
48
+ """Load a JSON array of per-run scores from `path`.
49
+
50
+ Args:
51
+ path: Filesystem path to a JSON file containing a flat, non-empty
52
+ array of numbers, e.g. `[0.8, 0.9, 0.75]`.
53
+ flag: The CLI flag this path came from (e.g. "--version-a-results"),
54
+ used to make error messages actionable.
55
+
56
+ Returns:
57
+ The parsed scores as a list of floats.
58
+ """
59
+ file_path = Path(path)
60
+ if not file_path.is_file():
61
+ _fail(f"{flag}: no such file: {path}")
62
+ try:
63
+ raw = json.loads(file_path.read_text())
64
+ except json.JSONDecodeError as exc:
65
+ _fail(f"{flag}: {path} is not valid JSON ({exc})")
66
+ if not isinstance(raw, list) or not raw:
67
+ _fail(f"{flag}: {path} must contain a non-empty JSON array of scores")
68
+ try:
69
+ return [float(x) for x in raw]
70
+ except (TypeError, ValueError):
71
+ _fail(f"{flag}: {path} must contain only numbers, got: {raw!r}")
72
+
73
+
74
+ def _report_from_scores( # noqa: PLR0913
75
+ metric: str,
76
+ scores_a: list[float],
77
+ scores_b: list[float],
78
+ p_threshold: float,
79
+ min_effect: float,
80
+ n_resamples: int,
81
+ ) -> Report:
82
+ """Build a `Report` from two pre-computed score lists.
83
+
84
+ Mirrors the pipeline in `agent_regress.core.compare.compare()`
85
+ (Mann-Whitney U -> bootstrap CI -> Cohen's d -> Verdict thresholding),
86
+ minus the `run_suite()` step, since the CLI receives scores an external
87
+ harness already computed rather than callables it needs to invoke.
88
+ """
89
+ n_a, n_b = len(scores_a), len(scores_b)
90
+ warn_msgs: list[str] = []
91
+ if n_a < _MIN_N_WARN or n_b < _MIN_N_WARN:
92
+ msg = (
93
+ f"n_a={n_a}, n_b={n_b}: insufficient for 80% power at small effect "
94
+ f"(d=0.2). Run at least {_MIN_N_WARN} per version for reliable results."
95
+ )
96
+ warn_msgs.append(msg)
97
+ warnings.warn(msg, UserWarning, stacklevel=2)
98
+
99
+ mw = mann_whitney_u(scores_a, scores_b, _warn=False)
100
+ ci = bootstrap_mean_ci(scores_a, scores_b, n_resamples=n_resamples)
101
+ d = cohens_d(scores_a, scores_b)
102
+
103
+ arr_a = np.asarray(scores_a, dtype=np.float64)
104
+ arr_b = np.asarray(scores_b, dtype=np.float64)
105
+ std_a = float(arr_a.std(ddof=1)) if n_a > 1 else 0.0
106
+ std_b = float(arr_b.std(ddof=1)) if n_b > 1 else 0.0
107
+
108
+ if n_a < 10 or n_b < 10:
109
+ verdict = Verdict.INSUFFICIENT_DATA
110
+ elif mw.p_value < p_threshold and abs(d) >= min_effect:
111
+ verdict = Verdict.REGRESSED if d < 0.0 else Verdict.IMPROVED
112
+ else:
113
+ verdict = Verdict.STABLE
114
+
115
+ return Report(
116
+ metric=metric,
117
+ verdict=verdict,
118
+ p_value=mw.p_value,
119
+ effect_size=d,
120
+ ci_lower=ci.lower,
121
+ ci_upper=ci.upper,
122
+ n_a=n_a,
123
+ n_b=n_b,
124
+ mean_a=float(arr_a.mean()),
125
+ mean_b=float(arr_b.mean()),
126
+ std_a=std_a,
127
+ std_b=std_b,
128
+ mean_delta=ci.mean_delta,
129
+ p_threshold=p_threshold,
130
+ min_effect=min_effect,
131
+ warnings=warn_msgs,
132
+ )
133
+
134
+
135
+ def _report_to_json(report: Report) -> dict[str, Any]:
136
+ """Flatten a `Report` into the same fields `str(report)` shows, as a
137
+ JSON-serializable dict -- the "agent-native" machine-readable surface.
138
+ """
139
+ return {
140
+ "metric": report.metric,
141
+ "verdict": report.verdict.value,
142
+ "p_value": report.p_value,
143
+ "effect_size": report.effect_size,
144
+ "ci_lower": report.ci_lower,
145
+ "ci_upper": report.ci_upper,
146
+ "n_a": report.n_a,
147
+ "n_b": report.n_b,
148
+ "mean_a": report.mean_a,
149
+ "mean_b": report.mean_b,
150
+ "std_a": report.std_a,
151
+ "std_b": report.std_b,
152
+ "mean_delta": report.mean_delta,
153
+ "p_threshold": report.p_threshold,
154
+ "min_effect": report.min_effect,
155
+ "warnings": report.warnings,
156
+ }
157
+
158
+
159
+ def _cmd_compare(args: argparse.Namespace) -> int:
160
+ scores_a = _load_scores(args.version_a_results, "--version-a-results")
161
+ scores_b = _load_scores(args.version_b_results, "--version-b-results")
162
+
163
+ if args.n_resamples < 100:
164
+ _fail(f"--n-resamples must be >= 100, got {args.n_resamples}")
165
+ if not (0.0 < args.p_threshold < 1.0):
166
+ _fail(f"--p-threshold must be in (0, 1), got {args.p_threshold}")
167
+ if args.min_effect < 0.0:
168
+ _fail(f"--min-effect must be >= 0, got {args.min_effect}")
169
+
170
+ with warnings.catch_warnings(record=True) as caught:
171
+ warnings.simplefilter("always")
172
+ report = _report_from_scores(
173
+ metric=args.metric,
174
+ scores_a=scores_a,
175
+ scores_b=scores_b,
176
+ p_threshold=args.p_threshold,
177
+ min_effect=args.min_effect,
178
+ n_resamples=args.n_resamples,
179
+ )
180
+ # Warnings go to stderr only -- stdout stays clean JSON in --json mode.
181
+ for w in caught:
182
+ print(f"{_PROG}: warning: {w.message}", file=sys.stderr)
183
+
184
+ if args.json:
185
+ sys.stdout.write(json.dumps(_report_to_json(report)) + "\n")
186
+ else:
187
+ print(str(report))
188
+
189
+ if args.fail_on_regression and report.verdict == Verdict.REGRESSED:
190
+ return 1
191
+ return 0
192
+
193
+
194
+ def _build_parser() -> argparse.ArgumentParser:
195
+ parser = argparse.ArgumentParser(
196
+ prog=_PROG,
197
+ description=(
198
+ "Statistical regression testing for LLM agents: compare two "
199
+ "sets of per-run scores and get a verdict (REGRESSED / STABLE / "
200
+ "IMPROVED / INSUFFICIENT_DATA) backed by a Mann-Whitney U test, "
201
+ "a bootstrap confidence interval, and Cohen's d effect size."
202
+ ),
203
+ )
204
+ parser.add_argument(
205
+ "--version",
206
+ action="version",
207
+ version=f"{_PROG} {__version__}",
208
+ )
209
+ subparsers = parser.add_subparsers(dest="command")
210
+
211
+ compare_parser = subparsers.add_parser(
212
+ "compare",
213
+ help="Compare two versions' pre-computed per-run scores.",
214
+ description=(
215
+ "Compare two agent versions from pre-computed per-run scores. "
216
+ "Each --version-*-results file must be a JSON array of numbers, "
217
+ "one score per run, e.g. [0.82, 0.79, 0.91]. Produce these with "
218
+ "whatever harness ran each agent version -- the Python API's "
219
+ "compare() runs the harness for you via callables, but a CLI "
220
+ "invocation can't accept a callable, so this command starts "
221
+ "one step later, from the resulting scores."
222
+ ),
223
+ )
224
+ compare_parser.add_argument(
225
+ "--version-a-results",
226
+ required=True,
227
+ metavar="PATH",
228
+ help="Path to a JSON array of per-run scores for version A (baseline).",
229
+ )
230
+ compare_parser.add_argument(
231
+ "--version-b-results",
232
+ required=True,
233
+ metavar="PATH",
234
+ help="Path to a JSON array of per-run scores for version B (candidate).",
235
+ )
236
+ compare_parser.add_argument(
237
+ "--metric",
238
+ default="accuracy",
239
+ metavar="NAME",
240
+ help="Name of the metric being compared, shown in the report "
241
+ "(default: accuracy).",
242
+ )
243
+ compare_parser.add_argument(
244
+ "--p-threshold",
245
+ type=float,
246
+ default=0.05,
247
+ metavar="P",
248
+ help="Significance threshold for the Mann-Whitney U p-value (default: 0.05).",
249
+ )
250
+ compare_parser.add_argument(
251
+ "--min-effect",
252
+ type=float,
253
+ default=0.2,
254
+ metavar="D",
255
+ help="Minimum |Cohen's d| to call a statistically significant "
256
+ "difference a REGRESSED/IMPROVED verdict rather than STABLE "
257
+ "(default: 0.2).",
258
+ )
259
+ compare_parser.add_argument(
260
+ "--n-resamples",
261
+ type=int,
262
+ default=1000,
263
+ metavar="N",
264
+ help="Number of bootstrap resamples used for the confidence "
265
+ "interval (default: 1000, minimum: 100).",
266
+ )
267
+ compare_parser.add_argument(
268
+ "--json",
269
+ action="store_true",
270
+ help="Print the report as a single JSON object to stdout instead "
271
+ "of the human-readable format. Warnings go to stderr, so stdout "
272
+ "is clean, parseable JSON.",
273
+ )
274
+ compare_parser.add_argument(
275
+ "--fail-on-regression",
276
+ action="store_true",
277
+ help="Exit with status 1 if the verdict is REGRESSED (useful for "
278
+ "CI). Without this flag, the command exits 0 regardless of verdict, "
279
+ "matching how the Python compare() function never raises on its "
280
+ "own -- only Report.assert_stable() / the ci.gate module do.",
281
+ )
282
+ compare_parser.set_defaults(func=_cmd_compare)
283
+
284
+ return parser
285
+
286
+
287
+ def main(argv: list[str] | None = None) -> int:
288
+ parser = _build_parser()
289
+ args = parser.parse_args(argv)
290
+ if not getattr(args, "command", None):
291
+ parser.print_help(sys.stderr)
292
+ return 2
293
+ result: int = args.func(args)
294
+ return result
295
+
296
+
297
+ if __name__ == "__main__":
298
+ raise SystemExit(main())
@@ -0,0 +1,17 @@
1
+ """Core runner, scorer, and report types."""
2
+
3
+ from agent_regress.core.compare import compare
4
+ from agent_regress.core.report import Report, Verdict
5
+ from agent_regress.core.runner import AgentCallable, ScorerCallable, run_suite
6
+ from agent_regress.core.scorer import exact_match_scorer, f1_scorer
7
+
8
+ __all__ = [
9
+ "AgentCallable",
10
+ "Report",
11
+ "ScorerCallable",
12
+ "Verdict",
13
+ "compare",
14
+ "exact_match_scorer",
15
+ "f1_scorer",
16
+ "run_suite",
17
+ ]