Skip to content

Commit 2482951

Browse files
fix: harden benchmark gate scripts with input validation
Raise BenchmarkDataError for malformed JSON or missing keys; skip zero baselines safely; reject non-positive --slack values.
1 parent b9d0707 commit 2482951

3 files changed

Lines changed: 90 additions & 8 deletions

File tree

scripts/check_benchmark_regression.py

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,62 @@
99
THRESHOLD = 1.20
1010

1111

12+
class BenchmarkDataError(ValueError):
13+
"""Raised when benchmark JSON input is malformed or missing required fields."""
14+
15+
1216
def load_results(results_path: str | Path) -> dict[str, float]:
13-
data = json.loads(Path(results_path).read_text(encoding="utf-8"))
14-
return {entry["name"]: float(entry["stats"]["mean"]) for entry in data["benchmarks"]}
17+
path = Path(results_path)
18+
try:
19+
data = json.loads(path.read_text(encoding="utf-8"))
20+
except json.JSONDecodeError as exc:
21+
raise BenchmarkDataError(f"invalid JSON in {path}: {exc}") from exc
22+
try:
23+
benchmarks = data["benchmarks"]
24+
except (KeyError, TypeError) as exc:
25+
raise BenchmarkDataError(f"{path} missing top-level 'benchmarks' array") from exc
26+
if not isinstance(benchmarks, list):
27+
raise BenchmarkDataError(f"{path} 'benchmarks' must be an array")
28+
29+
results: dict[str, float] = {}
30+
for index, entry in enumerate(benchmarks):
31+
if not isinstance(entry, dict):
32+
raise BenchmarkDataError(f"{path} benchmarks[{index}] must be an object")
33+
try:
34+
name = entry["name"]
35+
mean = entry["stats"]["mean"]
36+
except (KeyError, TypeError) as exc:
37+
raise BenchmarkDataError(
38+
f"{path} benchmarks[{index}] missing 'name' or 'stats.mean'"
39+
) from exc
40+
results[str(name)] = float(mean)
41+
return results
1542

1643

1744
def load_baseline_means(baselines_path: str | Path) -> dict[str, float]:
18-
data = json.loads(Path(baselines_path).read_text(encoding="utf-8"))
45+
path = Path(baselines_path)
46+
try:
47+
data = json.loads(path.read_text(encoding="utf-8"))
48+
except json.JSONDecodeError as exc:
49+
raise BenchmarkDataError(f"invalid JSON in {path}: {exc}") from exc
50+
if not isinstance(data, dict):
51+
raise BenchmarkDataError(f"{path} root value must be an object")
52+
1953
groups = data.get("groups", data)
54+
if not isinstance(groups, dict):
55+
raise BenchmarkDataError(f"{path} missing 'groups' object")
56+
2057
means: dict[str, float] = {}
21-
for key, value in groups.items():
58+
for group_name, value in groups.items():
2259
if not isinstance(value, dict):
2360
continue
2461
for name, mean in value.items():
25-
means[name] = float(mean)
62+
try:
63+
means[str(name)] = float(mean)
64+
except (TypeError, ValueError) as exc:
65+
raise BenchmarkDataError(
66+
f"{path} groups[{group_name!r}][{name!r}] is not a numeric mean"
67+
) from exc
2668
return means
2769

2870

@@ -42,6 +84,9 @@ def check_regression(
4284
if cur is None:
4385
print(f"WARN: no current result for baseline {name!r}; skipping")
4486
continue
87+
if base == 0:
88+
print(f"WARN: baseline for {name!r} is zero; skipping ratio check")
89+
continue
4590
ratio = cur / base
4691
tag = "FAIL" if ratio > threshold else "ok"
4792
print(f"[{tag}] {name}: {cur:.6f}s vs {base:.6f}s ({ratio:.2f}x)")

scripts/reduce_baselines.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@
1111
GATED_GROUPS = ("parse", "export", "search")
1212

1313

14+
def _positive_float(value: str) -> float:
15+
parsed = float(value)
16+
if parsed <= 0:
17+
raise argparse.ArgumentTypeError("slack must be greater than zero")
18+
return parsed
19+
20+
1421
def reduce_baselines(
1522
raw_path: str | Path,
1623
out_path: str | Path,
@@ -43,9 +50,9 @@ def main(argv: list[str] | None = None) -> int:
4350
parser.add_argument("out_path", help="destination baselines.json path")
4451
parser.add_argument(
4552
"--slack",
46-
type=float,
53+
type=_positive_float,
4754
default=1.0,
48-
help="multiply means by this factor (e.g. 1.25 when capturing on a faster host)",
55+
help="multiply means by this factor (must be > 0)",
4956
)
5057
args = parser.parse_args(argv)
5158
reduce_baselines(args.raw_path, args.out_path, slack=args.slack)

tests/test_check_benchmark_regression.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import pytest
88

9-
from scripts.check_benchmark_regression import check_regression
9+
from scripts.check_benchmark_regression import BenchmarkDataError, check_regression, load_results
1010

1111

1212
def _write_results(path, benchmarks: list[dict]) -> None:
@@ -75,3 +75,33 @@ def test_within_threshold_passes(tmp_path) -> None:
7575
)
7676

7777
assert check_regression(results, baselines) == 0
78+
79+
80+
def test_load_results_rejects_malformed_json(tmp_path) -> None:
81+
path = tmp_path / "bad.json"
82+
path.write_text("{not json", encoding="utf-8")
83+
with pytest.raises(BenchmarkDataError, match="invalid JSON"):
84+
load_results(path)
85+
86+
87+
def test_load_results_requires_benchmarks_array(tmp_path) -> None:
88+
path = tmp_path / "results.json"
89+
path.write_text("{}", encoding="utf-8")
90+
with pytest.raises(BenchmarkDataError, match="'benchmarks' array"):
91+
load_results(path)
92+
93+
94+
def test_zero_baseline_skips_ratio_check(tmp_path, capsys: pytest.CaptureFixture[str]) -> None:
95+
results = tmp_path / "results.json"
96+
baselines = tmp_path / "baselines.json"
97+
_write_results(
98+
results,
99+
[{"name": "test_parse_session_small", "stats": {"mean": 0.0002}}],
100+
)
101+
_write_baselines(
102+
baselines,
103+
{"parse": {"test_parse_session_small": 0.0}},
104+
)
105+
106+
assert check_regression(results, baselines) == 0
107+
assert "baseline for 'test_parse_session_small' is zero" in capsys.readouterr().out

0 commit comments

Comments
 (0)