test_benchmark_reporting.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. import importlib
  2. import importlib.util
  3. import json
  4. from datetime import datetime, timezone
  5. from pathlib import Path
  6. import pytest
  7. from pydantic import ValidationError
  8. from agent_lab.application.benchmark import (
  9. BenchmarkCaseId,
  10. BenchmarkConfig,
  11. BenchmarkMode,
  12. BenchmarkRunResult,
  13. )
  14. def _reporting_module():
  15. spec = importlib.util.find_spec(
  16. "agent_lab.infrastructure.benchmark_reporting"
  17. )
  18. assert spec is not None, "benchmark_reporting module must be implemented"
  19. return importlib.import_module(
  20. "agent_lab.infrastructure.benchmark_reporting"
  21. )
  22. def _config(**overrides: object) -> BenchmarkConfig:
  23. payload = {
  24. "schema_version": 1,
  25. "base_url": "https://provider.example/v1",
  26. "model": "benchmark-model",
  27. "runs_per_case": 1,
  28. "cases": [BenchmarkCaseId.ORDINARY_CHAT],
  29. "modes": [BenchmarkMode.DUAL_AGENT],
  30. }
  31. payload.update(overrides)
  32. return BenchmarkConfig.model_validate(payload)
  33. def _run(
  34. *,
  35. case_id: BenchmarkCaseId = BenchmarkCaseId.ORDINARY_CHAT,
  36. mode: BenchmarkMode = BenchmarkMode.DUAL_AGENT,
  37. iteration: int = 1,
  38. status: str = "passed",
  39. initial_provider_ttft_ms: int | None = 10,
  40. visible_ttft_ms: int | None = 20,
  41. turn_wall_time_ms: int | None = 30,
  42. prompt_tokens: int | None = 2,
  43. completion_tokens: int | None = 3,
  44. total_tokens: int | None = 5,
  45. cached_tokens: int | None = 1,
  46. model_call_count: int | None = 1,
  47. fallback_count: int | None = 0,
  48. tool_count: int | None = 0,
  49. semantic_failures: list[str] | None = None,
  50. error: str | None = None,
  51. ) -> BenchmarkRunResult:
  52. return BenchmarkRunResult(
  53. case_id=case_id,
  54. mode=mode,
  55. iteration=iteration,
  56. status=status,
  57. initial_provider_ttft_ms=initial_provider_ttft_ms,
  58. visible_ttft_ms=visible_ttft_ms,
  59. turn_wall_time_ms=turn_wall_time_ms,
  60. prompt_tokens=prompt_tokens,
  61. completion_tokens=completion_tokens,
  62. total_tokens=total_tokens,
  63. cached_tokens=cached_tokens,
  64. model_call_count=model_call_count,
  65. fallback_count=fallback_count,
  66. tool_count=tool_count,
  67. event_names=[],
  68. batch_event_names=[],
  69. tool_event_names=[],
  70. event_sources=[],
  71. tool_statuses=[],
  72. tool_latencies_ms=[],
  73. semantic_failures=semantic_failures or [],
  74. error=error,
  75. model_calls=[],
  76. )
  77. @pytest.mark.parametrize(
  78. ("values", "quantile", "expected"),
  79. [
  80. ([], 0.50, None),
  81. ([7], 0.50, 7.0),
  82. ([7], 0.95, 7.0),
  83. ([1, 3, 5, 7], 0.50, 4.0),
  84. ([1, 3, 5, 7], 0.95, 6.7),
  85. ],
  86. )
  87. def test_percentile_uses_linear_interpolation(
  88. values: list[int], quantile: float, expected: float | None
  89. ):
  90. reporting = _reporting_module()
  91. assert reporting.percentile(values, quantile) == pytest.approx(expected)
  92. def test_report_models_are_strict_and_include_required_summary_fields():
  93. reporting = _reporting_module()
  94. generated_at = datetime(2026, 7, 14, 1, 2, 3, tzinfo=timezone.utc)
  95. report = reporting.build_benchmark_report(
  96. _config(),
  97. [_run()],
  98. mock=True,
  99. generated_at=generated_at,
  100. )
  101. assert isinstance(report, reporting.BenchmarkReport)
  102. assert report.schema_version == 1
  103. assert report.generated_at == generated_at
  104. assert report.target.model_dump() == {
  105. "base_url": "https://provider.example/v1",
  106. "model": "benchmark-model",
  107. "mock": True,
  108. }
  109. assert report.config.model_dump(mode="json") == {
  110. "runs_per_case": 1,
  111. "cases": ["ordinary_chat"],
  112. "modes": ["dual_agent"],
  113. }
  114. assert report.overall.model_dump() == {
  115. "attempted": 1,
  116. "passed": 1,
  117. "failed": 0,
  118. "warnings": report.overall.warnings,
  119. }
  120. with pytest.raises(ValidationError):
  121. reporting.BenchmarkReport.model_validate(
  122. report.model_dump() | {"schema_version": "1"}
  123. )
  124. with pytest.raises(ValidationError):
  125. reporting.BenchmarkAggregate.model_validate(
  126. report.aggregates[0].model_dump() | {"unexpected": True}
  127. )
  128. def test_aggregate_excludes_failed_and_null_samples_from_metrics():
  129. reporting = _reporting_module()
  130. runs = [
  131. _run(
  132. iteration=1,
  133. initial_provider_ttft_ms=10,
  134. visible_ttft_ms=20,
  135. turn_wall_time_ms=30,
  136. prompt_tokens=2,
  137. completion_tokens=3,
  138. total_tokens=5,
  139. cached_tokens=1,
  140. model_call_count=1,
  141. fallback_count=0,
  142. tool_count=2,
  143. ),
  144. _run(
  145. iteration=2,
  146. initial_provider_ttft_ms=None,
  147. visible_ttft_ms=40,
  148. turn_wall_time_ms=None,
  149. prompt_tokens=None,
  150. completion_tokens=7,
  151. total_tokens=7,
  152. cached_tokens=None,
  153. model_call_count=3,
  154. fallback_count=1,
  155. tool_count=None,
  156. ),
  157. _run(
  158. iteration=3,
  159. status="failed",
  160. initial_provider_ttft_ms=999,
  161. visible_ttft_ms=999,
  162. turn_wall_time_ms=999,
  163. prompt_tokens=999,
  164. completion_tokens=999,
  165. total_tokens=999,
  166. cached_tokens=999,
  167. model_call_count=999,
  168. fallback_count=999,
  169. tool_count=999,
  170. error="failed",
  171. ),
  172. ]
  173. aggregate = reporting.build_benchmark_report(
  174. _config(runs_per_case=3), runs, mock=True
  175. ).aggregates[0]
  176. assert (aggregate.attempted, aggregate.passed, aggregate.failed) == (3, 2, 1)
  177. assert aggregate.initial_provider_ttft_ms.model_dump() == {
  178. "count": 1,
  179. "p50": 10.0,
  180. "p95": 10.0,
  181. }
  182. assert aggregate.visible_ttft_ms.model_dump() == {
  183. "count": 2,
  184. "p50": 30.0,
  185. "p95": 39.0,
  186. }
  187. assert aggregate.turn_wall_time_ms.count == 1
  188. assert aggregate.prompt_tokens.model_dump() == {
  189. "count": 1,
  190. "total": 2,
  191. "mean": 2.0,
  192. }
  193. assert aggregate.completion_tokens.model_dump() == {
  194. "count": 2,
  195. "total": 10,
  196. "mean": 5.0,
  197. }
  198. assert aggregate.total_tokens.total == 12
  199. assert aggregate.cached_tokens.total == 1
  200. assert aggregate.model_call_count.model_dump() == {
  201. "count": 2,
  202. "total": 4,
  203. "mean": 2.0,
  204. }
  205. assert aggregate.fallback_count.total == 1
  206. assert aggregate.tool_count.model_dump() == {
  207. "count": 1,
  208. "total": 2,
  209. "mean": 2.0,
  210. }
  211. def test_aggregates_are_stably_sorted_and_warn_for_low_confidence_p95():
  212. reporting = _reporting_module()
  213. runs = [
  214. _run(
  215. case_id=BenchmarkCaseId.WEB_SEARCH_TWO_ANSWERS,
  216. mode=BenchmarkMode.DUAL_AGENT,
  217. ),
  218. _run(
  219. case_id=BenchmarkCaseId.ORDINARY_CHAT,
  220. mode=BenchmarkMode.CHAT_AGENT_TOOLS,
  221. ),
  222. _run(
  223. case_id=BenchmarkCaseId.ORDINARY_CHAT,
  224. mode=BenchmarkMode.DUAL_AGENT,
  225. ),
  226. ]
  227. report = reporting.build_benchmark_report(
  228. _config(
  229. cases=[
  230. BenchmarkCaseId.WEB_SEARCH_TWO_ANSWERS,
  231. BenchmarkCaseId.ORDINARY_CHAT,
  232. ],
  233. modes=[
  234. BenchmarkMode.CHAT_AGENT_TOOLS,
  235. BenchmarkMode.DUAL_AGENT,
  236. ],
  237. ),
  238. runs,
  239. mock=True,
  240. )
  241. assert [(item.case_id.value, item.mode.value) for item in report.aggregates] == [
  242. ("ordinary_chat", "chat_agent_tools"),
  243. ("ordinary_chat", "dual_agent"),
  244. ("web_search_two_answers", "dual_agent"),
  245. ]
  246. assert all(
  247. any("p95 low confidence" in warning for warning in item.warnings)
  248. for item in report.aggregates
  249. )
  250. assert report.overall.warnings == [
  251. warning
  252. for aggregate in report.aggregates
  253. for warning in aggregate.warnings
  254. ]
  255. def test_errors_are_redacted_before_truncation_in_json_and_markdown():
  256. reporting = _reporting_module()
  257. real_key = "sk-live-secret-value"
  258. leaked_error = (
  259. f"Authorization: Bearer {real_key}; "
  260. f"API-Key={real_key}; "
  261. f"https://example.test/?api_key={real_key}&token={real_key}; "
  262. f'{{"access_token":"{real_key}","key":"{real_key}"}}; '
  263. + "x" * 3000
  264. )
  265. report = reporting.build_benchmark_report(
  266. _config(),
  267. [
  268. _run(
  269. status="failed",
  270. semantic_failures=[f"token={real_key}"],
  271. error=leaked_error,
  272. )
  273. ],
  274. mock=False,
  275. secrets=[real_key],
  276. )
  277. payload = report.model_dump(mode="json")
  278. json_text = reporting.report_to_json(report)
  279. markdown = reporting.render_markdown(report)
  280. assert len(report.runs[0].error) == 2048
  281. assert real_key not in json.dumps(payload)
  282. assert real_key not in json_text
  283. assert real_key not in markdown
  284. assert "[REDACTED]" in json_text
  285. assert "[REDACTED]" in markdown
  286. def test_configured_secret_is_redacted_from_all_report_string_surfaces():
  287. reporting = _reporting_module()
  288. real_key = "sk-everywhere-secret"
  289. run = _run().model_copy(
  290. update={
  291. "event_names": [real_key],
  292. "event_sources": [f"Bearer {real_key}"],
  293. "tool_statuses": [f"token={real_key}"],
  294. }
  295. )
  296. report = reporting.build_benchmark_report(
  297. _config(
  298. base_url=f"https://provider.example/{real_key}",
  299. model=f"model-{real_key}",
  300. ),
  301. [run],
  302. mock=False,
  303. secrets=[real_key],
  304. )
  305. rendered = reporting.report_to_json(report) + reporting.render_markdown(report)
  306. assert real_key not in rendered
  307. assert "[REDACTED]" in rendered
  308. def test_markdown_is_rendered_from_the_same_json_facts():
  309. reporting = _reporting_module()
  310. report = reporting.build_benchmark_report(
  311. _config(runs_per_case=2),
  312. [
  313. _run(iteration=1, visible_ttft_ms=10, total_tokens=5),
  314. _run(iteration=2, visible_ttft_ms=30, total_tokens=9),
  315. ],
  316. mock=True,
  317. generated_at=datetime(2026, 7, 14, tzinfo=timezone.utc),
  318. )
  319. payload = json.loads(reporting.report_to_json(report))
  320. markdown = reporting.render_markdown(report)
  321. aggregate = payload["aggregates"][0]
  322. assert str(aggregate["visible_ttft_ms"]["p50"]) in markdown
  323. assert str(aggregate["visible_ttft_ms"]["p95"]) in markdown
  324. assert str(aggregate["total_tokens"]["total"]) in markdown
  325. assert str(aggregate["total_tokens"]["mean"]) in markdown
  326. assert payload == report.model_dump(mode="json")
  327. def test_atomic_write_creates_matching_unique_json_and_markdown_paths(tmp_path: Path):
  328. reporting = _reporting_module()
  329. report = reporting.build_benchmark_report(
  330. _config(), [_run()], mock=True
  331. )
  332. timestamp = "20260714T010203Z"
  333. (tmp_path / f"{timestamp}.json").write_text("existing")
  334. (tmp_path / f"{timestamp}.md").write_text("existing")
  335. json_path, markdown_path = reporting.write_benchmark_report(
  336. report,
  337. tmp_path,
  338. timestamp=timestamp,
  339. )
  340. assert json_path.name == f"{timestamp}-1.json"
  341. assert markdown_path.name == f"{timestamp}-1.md"
  342. assert json.loads(json_path.read_text()) == report.model_dump(mode="json")
  343. assert markdown_path.read_text() == reporting.render_markdown(report)
  344. assert list(tmp_path.glob("*.tmp")) == []
  345. def test_atomic_write_removes_temps_and_published_half_on_replace_failure(
  346. tmp_path: Path, monkeypatch: pytest.MonkeyPatch
  347. ):
  348. reporting = _reporting_module()
  349. report = reporting.build_benchmark_report(
  350. _config(), [_run()], mock=True
  351. )
  352. real_replace = reporting.os.replace
  353. calls = 0
  354. def fail_second_replace(source: Path, destination: Path) -> None:
  355. nonlocal calls
  356. calls += 1
  357. if calls == 2:
  358. raise OSError("simulated markdown publish failure")
  359. real_replace(source, destination)
  360. monkeypatch.setattr(reporting.os, "replace", fail_second_replace)
  361. with pytest.raises(
  362. reporting.BenchmarkReportWriteError,
  363. match="failed to write benchmark reports",
  364. ):
  365. reporting.write_benchmark_report(
  366. report,
  367. tmp_path,
  368. timestamp="20260714T010203Z",
  369. )
  370. assert list(tmp_path.iterdir()) == []