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.
- agent_regress/__init__.py +63 -0
- agent_regress/benchmarks/__init__.py +15 -0
- agent_regress/benchmarks/gaia.py +80 -0
- agent_regress/benchmarks/swebench.py +65 -0
- agent_regress/benchmarks/tau_bench.py +86 -0
- agent_regress/ci/__init__.py +5 -0
- agent_regress/ci/gate.py +62 -0
- agent_regress/cli.py +298 -0
- agent_regress/core/__init__.py +17 -0
- agent_regress/core/compare.py +239 -0
- agent_regress/core/report.py +81 -0
- agent_regress/core/runner.py +487 -0
- agent_regress/core/scorer.py +254 -0
- agent_regress/core/state_diff.py +48 -0
- agent_regress/integrations/__init__.py +1 -0
- agent_regress/integrations/crewai.py +121 -0
- agent_regress/integrations/langchain.py +39 -0
- agent_regress/integrations/langgraph.py +453 -0
- agent_regress/integrations/openai_agents.py +471 -0
- agent_regress/py.typed +0 -0
- agent_regress/stats/__init__.py +14 -0
- agent_regress/stats/bootstrap.py +59 -0
- agent_regress/stats/effect_size.py +87 -0
- agent_regress/stats/mann_whitney.py +69 -0
- agent_regress_cli-0.1.0.dist-info/METADATA +405 -0
- agent_regress_cli-0.1.0.dist-info/RECORD +29 -0
- agent_regress_cli-0.1.0.dist-info/WHEEL +4 -0
- agent_regress_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agent_regress_cli-0.1.0.dist-info/licenses/LICENSE +178 -0
|
@@ -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
|
agent_regress/ci/gate.py
ADDED
|
@@ -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
|
+
]
|