test_benchmark_reporting.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972
  1. import importlib
  2. import importlib.util
  3. import json
  4. import multiprocessing
  5. import threading
  6. from concurrent.futures import ThreadPoolExecutor
  7. from datetime import datetime, timezone
  8. from pathlib import Path
  9. import pytest
  10. from pydantic import ValidationError
  11. from agent_lab.application.benchmark import (
  12. BenchmarkCaseId,
  13. BenchmarkConfig,
  14. BenchmarkModelCallTiming,
  15. BenchmarkMode,
  16. BenchmarkRunResult,
  17. )
  18. def _reporting_module():
  19. spec = importlib.util.find_spec(
  20. "agent_lab.infrastructure.benchmark_reporting"
  21. )
  22. assert spec is not None, "benchmark_reporting module must be implemented"
  23. return importlib.import_module(
  24. "agent_lab.infrastructure.benchmark_reporting"
  25. )
  26. def _config(**overrides: object) -> BenchmarkConfig:
  27. payload = {
  28. "schema_version": 1,
  29. "base_url": "https://provider.example/v1",
  30. "model": "benchmark-model",
  31. "runs_per_case": 1,
  32. "cases": [BenchmarkCaseId.ORDINARY_CHAT],
  33. "modes": [BenchmarkMode.DUAL_AGENT],
  34. }
  35. payload.update(overrides)
  36. return BenchmarkConfig.model_validate(payload)
  37. def _run(
  38. *,
  39. case_id: BenchmarkCaseId = BenchmarkCaseId.ORDINARY_CHAT,
  40. mode: BenchmarkMode = BenchmarkMode.DUAL_AGENT,
  41. iteration: int = 1,
  42. status: str = "passed",
  43. initial_provider_ttft_ms: int | None = 10,
  44. visible_ttft_ms: int | None = 20,
  45. turn_wall_time_ms: int | None = 30,
  46. prompt_tokens: int | None = 2,
  47. completion_tokens: int | None = 3,
  48. total_tokens: int | None = 5,
  49. cached_tokens: int | None = 1,
  50. model_call_count: int | None = 1,
  51. fallback_count: int | None = 0,
  52. tool_count: int | None = 0,
  53. tool_latencies_ms: list[int | None] | None = None,
  54. semantic_failures: list[str] | None = None,
  55. error: str | None = None,
  56. model_calls: list[BenchmarkModelCallTiming] | None = None,
  57. ) -> BenchmarkRunResult:
  58. return BenchmarkRunResult(
  59. case_id=case_id,
  60. mode=mode,
  61. iteration=iteration,
  62. status=status,
  63. initial_provider_ttft_ms=initial_provider_ttft_ms,
  64. visible_ttft_ms=visible_ttft_ms,
  65. turn_wall_time_ms=turn_wall_time_ms,
  66. prompt_tokens=prompt_tokens,
  67. completion_tokens=completion_tokens,
  68. total_tokens=total_tokens,
  69. cached_tokens=cached_tokens,
  70. model_call_count=model_call_count,
  71. fallback_count=fallback_count,
  72. tool_count=tool_count,
  73. event_names=[],
  74. batch_event_names=[],
  75. tool_event_names=[],
  76. event_sources=[],
  77. tool_statuses=[],
  78. tool_latencies_ms=tool_latencies_ms or [],
  79. semantic_failures=semantic_failures or [],
  80. error=error,
  81. model_calls=model_calls or [],
  82. )
  83. def _model_call(
  84. call_index: int,
  85. elapsed_ms: int | None,
  86. ) -> BenchmarkModelCallTiming:
  87. return BenchmarkModelCallTiming(
  88. call_index=call_index,
  89. call_kind="chat_completion",
  90. first_item_kind="raw_chunk",
  91. provider_ttft_ms=1,
  92. visible_ttft_ms=2,
  93. elapsed_ms=elapsed_ms,
  94. usage=None,
  95. )
  96. def _marker_report(marker: str):
  97. reporting = _reporting_module()
  98. return reporting.build_benchmark_report(
  99. _config(model=marker),
  100. [_run()],
  101. mock=True,
  102. )
  103. def _process_report_writer(
  104. output_dir: str,
  105. marker: str,
  106. timestamp: str,
  107. first_temp_barrier,
  108. result_queue,
  109. ) -> None:
  110. reporting = _reporting_module()
  111. original_write_text = reporting._write_text
  112. first_write = True
  113. def synchronized_write(path: Path, content: str) -> tuple[int, int]:
  114. nonlocal first_write
  115. identity = original_write_text(path, content)
  116. if first_write:
  117. first_write = False
  118. first_temp_barrier.wait()
  119. return identity
  120. reporting._write_text = synchronized_write
  121. try:
  122. json_path, markdown_path = reporting.write_benchmark_report(
  123. _marker_report(marker),
  124. output_dir,
  125. timestamp=timestamp,
  126. )
  127. except BaseException as exc:
  128. result_queue.put(("error", marker, repr(exc)))
  129. return
  130. result_queue.put(
  131. ("ok", marker, json_path.name, markdown_path.name)
  132. )
  133. def _assert_concurrent_report_pairs(
  134. output_dir: Path,
  135. markers: list[str],
  136. ) -> None:
  137. json_paths = sorted(output_dir.glob("*.json"))
  138. markdown_paths = sorted(output_dir.glob("*.md"))
  139. assert len(json_paths) == len(markers)
  140. assert len(markdown_paths) == len(markers)
  141. assert {path.stem for path in json_paths} == {
  142. path.stem for path in markdown_paths
  143. }
  144. actual_markers = set()
  145. for json_path in json_paths:
  146. marker = json.loads(json_path.read_text())["target"]["model"]
  147. markdown = json_path.with_suffix(".md").read_text()
  148. actual_markers.add(marker)
  149. assert f"- Model: `{marker}`" in markdown
  150. assert actual_markers == set(markers)
  151. _assert_no_publication_artifacts(output_dir)
  152. def _assert_no_publication_artifacts(output_dir: Path) -> None:
  153. leftovers = [
  154. path.name
  155. for path in output_dir.iterdir()
  156. if path.name.endswith((".tmp", ".lock"))
  157. or "placeholder" in path.name
  158. ]
  159. assert leftovers == []
  160. report_paths = [
  161. path
  162. for path in output_dir.iterdir()
  163. if path.suffix in {".json", ".md"}
  164. ]
  165. assert all(path.stat().st_size > 0 for path in report_paths)
  166. @pytest.mark.parametrize(
  167. ("values", "quantile", "expected"),
  168. [
  169. ([], 0.50, None),
  170. ([7], 0.50, 7.0),
  171. ([7], 0.95, 7.0),
  172. ([1, 3, 5, 7], 0.50, 4.0),
  173. ([1, 3, 5, 7], 0.95, 6.7),
  174. ],
  175. )
  176. def test_percentile_uses_linear_interpolation(
  177. values: list[int], quantile: float, expected: float | None
  178. ):
  179. reporting = _reporting_module()
  180. assert reporting.percentile(values, quantile) == pytest.approx(expected)
  181. def test_report_models_are_strict_and_include_required_summary_fields():
  182. reporting = _reporting_module()
  183. generated_at = datetime(2026, 7, 14, 1, 2, 3, tzinfo=timezone.utc)
  184. report = reporting.build_benchmark_report(
  185. _config(),
  186. [_run()],
  187. mock=True,
  188. generated_at=generated_at,
  189. )
  190. assert isinstance(report, reporting.BenchmarkReport)
  191. assert report.schema_version == 1
  192. assert report.generated_at == generated_at
  193. assert report.target.model_dump() == {
  194. "base_url": "https://provider.example/v1",
  195. "model": "benchmark-model",
  196. "mock": True,
  197. }
  198. assert report.config.model_dump(mode="json") == {
  199. "runs_per_case": 1,
  200. "cases": ["ordinary_chat"],
  201. "modes": ["dual_agent"],
  202. }
  203. assert report.overall.model_dump() == {
  204. "attempted": 1,
  205. "passed": 1,
  206. "failed": 0,
  207. "warnings": report.overall.warnings,
  208. "system_errors": [],
  209. }
  210. with pytest.raises(ValidationError):
  211. reporting.BenchmarkReport.model_validate(
  212. report.model_dump() | {"schema_version": "1"}
  213. )
  214. with pytest.raises(ValidationError):
  215. reporting.BenchmarkAggregate.model_validate(
  216. report.aggregates[0].model_dump() | {"unexpected": True}
  217. )
  218. def test_aggregate_excludes_failed_and_null_samples_from_metrics():
  219. reporting = _reporting_module()
  220. runs = [
  221. _run(
  222. iteration=1,
  223. initial_provider_ttft_ms=10,
  224. visible_ttft_ms=20,
  225. turn_wall_time_ms=30,
  226. prompt_tokens=2,
  227. completion_tokens=3,
  228. total_tokens=5,
  229. cached_tokens=1,
  230. model_call_count=1,
  231. fallback_count=0,
  232. tool_count=2,
  233. ),
  234. _run(
  235. iteration=2,
  236. initial_provider_ttft_ms=None,
  237. visible_ttft_ms=40,
  238. turn_wall_time_ms=None,
  239. prompt_tokens=None,
  240. completion_tokens=7,
  241. total_tokens=7,
  242. cached_tokens=None,
  243. model_call_count=3,
  244. fallback_count=1,
  245. tool_count=None,
  246. ),
  247. _run(
  248. iteration=3,
  249. status="failed",
  250. initial_provider_ttft_ms=999,
  251. visible_ttft_ms=999,
  252. turn_wall_time_ms=999,
  253. prompt_tokens=999,
  254. completion_tokens=999,
  255. total_tokens=999,
  256. cached_tokens=999,
  257. model_call_count=999,
  258. fallback_count=999,
  259. tool_count=999,
  260. error="failed",
  261. ),
  262. ]
  263. aggregate = reporting.build_benchmark_report(
  264. _config(runs_per_case=3), runs, mock=True
  265. ).aggregates[0]
  266. assert (aggregate.attempted, aggregate.passed, aggregate.failed) == (3, 2, 1)
  267. assert aggregate.initial_provider_ttft_ms.model_dump() == {
  268. "count": 1,
  269. "p50": 10.0,
  270. "p95": 10.0,
  271. }
  272. assert aggregate.visible_ttft_ms.model_dump() == {
  273. "count": 2,
  274. "p50": 30.0,
  275. "p95": 39.0,
  276. }
  277. assert aggregate.turn_wall_time_ms.count == 1
  278. assert aggregate.prompt_tokens.model_dump() == {
  279. "count": 1,
  280. "total": 2,
  281. "mean": 2.0,
  282. }
  283. assert aggregate.completion_tokens.model_dump() == {
  284. "count": 2,
  285. "total": 10,
  286. "mean": 5.0,
  287. }
  288. assert aggregate.total_tokens.total == 12
  289. assert aggregate.cached_tokens.total == 1
  290. assert aggregate.model_call_count.model_dump() == {
  291. "count": 2,
  292. "total": 4,
  293. "mean": 2.0,
  294. }
  295. assert aggregate.fallback_count.total == 1
  296. assert aggregate.tool_count.model_dump() == {
  297. "count": 1,
  298. "total": 2,
  299. "mean": 2.0,
  300. }
  301. def test_aggregate_flattens_passed_model_and_tool_latency_samples():
  302. reporting = _reporting_module()
  303. runs = [
  304. _run(
  305. iteration=1,
  306. model_calls=[
  307. _model_call(1, 10),
  308. _model_call(2, None),
  309. _model_call(3, 30),
  310. ],
  311. tool_latencies_ms=[5, None, 15],
  312. ),
  313. _run(
  314. iteration=2,
  315. model_calls=[_model_call(1, 20)],
  316. tool_latencies_ms=[25],
  317. ),
  318. _run(
  319. iteration=3,
  320. status="failed",
  321. model_calls=[_model_call(1, 999)],
  322. tool_latencies_ms=[999],
  323. ),
  324. ]
  325. report = reporting.build_benchmark_report(
  326. _config(runs_per_case=3), runs, mock=True
  327. )
  328. aggregate = report.aggregates[0]
  329. markdown = reporting.render_markdown(report)
  330. assert aggregate.model_elapsed_ms.model_dump() == {
  331. "count": 3,
  332. "p50": 20.0,
  333. "p95": 29.0,
  334. }
  335. assert aggregate.tool_latency_ms.model_dump() == {
  336. "count": 3,
  337. "p50": 15.0,
  338. "p95": 24.0,
  339. }
  340. assert "Model elapsed p50/p95 ms" in markdown
  341. assert "Tool latency p50/p95 ms" in markdown
  342. assert "20.0/29.0" in markdown
  343. assert "15.0/24.0" in markdown
  344. def test_zero_latency_samples_emit_low_confidence_warnings():
  345. reporting = _reporting_module()
  346. aggregate = reporting.build_benchmark_report(
  347. _config(),
  348. [
  349. _run(
  350. initial_provider_ttft_ms=None,
  351. visible_ttft_ms=None,
  352. turn_wall_time_ms=None,
  353. model_calls=[],
  354. tool_latencies_ms=[],
  355. )
  356. ],
  357. mock=True,
  358. ).aggregates[0]
  359. assert {
  360. warning.split(" p95 low confidence:", 1)[0]
  361. for warning in aggregate.warnings
  362. } == {
  363. "ordinary_chat/dual_agent initial_provider_ttft_ms",
  364. "ordinary_chat/dual_agent visible_ttft_ms",
  365. "ordinary_chat/dual_agent turn_wall_time_ms",
  366. "ordinary_chat/dual_agent model_elapsed_ms",
  367. "ordinary_chat/dual_agent tool_latency_ms",
  368. }
  369. assert all("0 samples" in warning for warning in aggregate.warnings)
  370. def test_aggregates_are_stably_sorted_and_warn_for_low_confidence_p95():
  371. reporting = _reporting_module()
  372. runs = [
  373. _run(
  374. case_id=BenchmarkCaseId.WEB_SEARCH_TWO_ANSWERS,
  375. mode=BenchmarkMode.DUAL_AGENT,
  376. ),
  377. _run(
  378. case_id=BenchmarkCaseId.ORDINARY_CHAT,
  379. mode=BenchmarkMode.CHAT_AGENT_TOOLS,
  380. ),
  381. _run(
  382. case_id=BenchmarkCaseId.ORDINARY_CHAT,
  383. mode=BenchmarkMode.DUAL_AGENT,
  384. ),
  385. ]
  386. report = reporting.build_benchmark_report(
  387. _config(
  388. cases=[
  389. BenchmarkCaseId.WEB_SEARCH_TWO_ANSWERS,
  390. BenchmarkCaseId.ORDINARY_CHAT,
  391. ],
  392. modes=[
  393. BenchmarkMode.CHAT_AGENT_TOOLS,
  394. BenchmarkMode.DUAL_AGENT,
  395. ],
  396. ),
  397. runs,
  398. mock=True,
  399. )
  400. assert [(item.case_id.value, item.mode.value) for item in report.aggregates] == [
  401. ("ordinary_chat", "chat_agent_tools"),
  402. ("ordinary_chat", "dual_agent"),
  403. ("web_search_two_answers", "dual_agent"),
  404. ]
  405. assert all(
  406. any("p95 low confidence" in warning for warning in item.warnings)
  407. for item in report.aggregates
  408. )
  409. assert report.overall.warnings == [
  410. warning
  411. for aggregate in report.aggregates
  412. for warning in aggregate.warnings
  413. ]
  414. def test_errors_are_redacted_before_truncation_in_json_and_markdown():
  415. reporting = _reporting_module()
  416. real_key = "sk-live-secret-value"
  417. leaked_error = (
  418. f"Authorization: Bearer {real_key}; "
  419. f"API-Key={real_key}; "
  420. f"https://example.test/?api_key={real_key}&token={real_key}; "
  421. f'{{"access_token":"{real_key}","key":"{real_key}"}}; '
  422. + "x" * 3000
  423. )
  424. report = reporting.build_benchmark_report(
  425. _config(),
  426. [
  427. _run(
  428. status="failed",
  429. semantic_failures=[f"token={real_key}"],
  430. error=leaked_error,
  431. )
  432. ],
  433. mock=False,
  434. secrets=[real_key],
  435. )
  436. payload = report.model_dump(mode="json")
  437. json_text = reporting.report_to_json(report)
  438. markdown = reporting.render_markdown(report)
  439. assert len(report.runs[0].error) == 2048
  440. assert real_key not in json.dumps(payload)
  441. assert real_key not in json_text
  442. assert real_key not in markdown
  443. assert "[REDACTED]" in json_text
  444. assert "[REDACTED]" in markdown
  445. def test_error_truncation_is_utf8_safe_and_bounded_after_redaction():
  446. reporting = _reporting_module()
  447. real_key = "sk-byte-bound-secret"
  448. error = f"Bearer {real_key} " + "密" * 1000
  449. sanitized = reporting.sanitize_error(error, secrets=[real_key])
  450. redacted = reporting.redact_text(error, secrets=[real_key])
  451. expected = redacted.encode("utf-8")[:2048].decode("utf-8", errors="ignore")
  452. assert sanitized == expected
  453. assert real_key not in sanitized
  454. assert len(sanitized.encode("utf-8")) <= 2048
  455. assert "\ufffd" not in sanitized
  456. def test_configured_secret_is_redacted_from_all_report_string_surfaces():
  457. reporting = _reporting_module()
  458. real_key = "sk-everywhere-secret"
  459. run = _run().model_copy(
  460. update={
  461. "event_names": [real_key],
  462. "event_sources": [f"Bearer {real_key}"],
  463. "tool_statuses": [f"token={real_key}"],
  464. }
  465. )
  466. report = reporting.build_benchmark_report(
  467. _config(
  468. base_url=f"https://provider.example/{real_key}",
  469. model=f"model-{real_key}",
  470. ),
  471. [run],
  472. mock=False,
  473. secrets=[real_key],
  474. )
  475. rendered = reporting.report_to_json(report) + reporting.render_markdown(report)
  476. assert real_key not in rendered
  477. assert "[REDACTED]" in rendered
  478. def test_system_errors_are_typed_redacted_counted_and_rendered():
  479. reporting = _reporting_module()
  480. real_key = "sk-runner-system-secret"
  481. report = reporting.build_benchmark_report(
  482. _config(),
  483. [],
  484. mock=False,
  485. secrets=[real_key],
  486. system_errors=[f"Authorization: Bearer {real_key}"],
  487. )
  488. payload = json.loads(reporting.report_to_json(report))
  489. markdown = reporting.render_markdown(report)
  490. assert report.overall.model_dump() == {
  491. "attempted": 1,
  492. "passed": 0,
  493. "failed": 1,
  494. "warnings": [],
  495. "system_errors": ["Authorization: [REDACTED] [REDACTED]"],
  496. }
  497. assert payload["overall"]["system_errors"] == report.overall.system_errors
  498. assert "## System Errors" in markdown
  499. assert "Authorization: [REDACTED] [REDACTED]" in markdown
  500. assert real_key not in reporting.report_to_json(report)
  501. assert real_key not in markdown
  502. def test_markdown_is_rendered_from_the_same_json_facts():
  503. reporting = _reporting_module()
  504. report = reporting.build_benchmark_report(
  505. _config(runs_per_case=2),
  506. [
  507. _run(iteration=1, visible_ttft_ms=10, total_tokens=5),
  508. _run(iteration=2, visible_ttft_ms=30, total_tokens=9),
  509. ],
  510. mock=True,
  511. generated_at=datetime(2026, 7, 14, tzinfo=timezone.utc),
  512. )
  513. payload = json.loads(reporting.report_to_json(report))
  514. markdown = reporting.render_markdown(report)
  515. aggregate = payload["aggregates"][0]
  516. assert str(aggregate["visible_ttft_ms"]["p50"]) in markdown
  517. assert str(aggregate["visible_ttft_ms"]["p95"]) in markdown
  518. assert str(aggregate["total_tokens"]["total"]) in markdown
  519. assert str(aggregate["total_tokens"]["mean"]) in markdown
  520. assert payload == report.model_dump(mode="json")
  521. def test_atomic_write_creates_matching_unique_json_and_markdown_paths(tmp_path: Path):
  522. reporting = _reporting_module()
  523. report = reporting.build_benchmark_report(
  524. _config(), [_run()], mock=True
  525. )
  526. timestamp = "20260714T010203Z"
  527. (tmp_path / f"{timestamp}.json").write_text("existing")
  528. (tmp_path / f"{timestamp}.md").write_text("existing")
  529. json_path, markdown_path = reporting.write_benchmark_report(
  530. report,
  531. tmp_path,
  532. timestamp=timestamp,
  533. )
  534. assert json_path.name == f"{timestamp}-1.json"
  535. assert markdown_path.name == f"{timestamp}-1.md"
  536. assert json.loads(json_path.read_text()) == report.model_dump(mode="json")
  537. assert markdown_path.read_text() == reporting.render_markdown(report)
  538. _assert_no_publication_artifacts(tmp_path)
  539. def test_json_created_immediately_before_link_wins_and_writer_retries(
  540. tmp_path: Path,
  541. monkeypatch: pytest.MonkeyPatch,
  542. ):
  543. reporting = _reporting_module()
  544. timestamp = "20260714T011223Z"
  545. external_json = tmp_path / f"{timestamp}.json"
  546. real_link = reporting.os.link
  547. link_calls = 0
  548. def create_before_link(source: Path, destination: Path) -> None:
  549. nonlocal link_calls
  550. link_calls += 1
  551. destination = Path(destination)
  552. if link_calls == 1:
  553. destination.write_text("external json")
  554. real_link(source, destination)
  555. monkeypatch.setattr(reporting.os, "link", create_before_link)
  556. json_path, markdown_path = reporting.write_benchmark_report(
  557. _marker_report("json-link-collision"),
  558. tmp_path,
  559. timestamp=timestamp,
  560. )
  561. assert link_calls == 3
  562. assert external_json.read_text() == "external json"
  563. assert not (tmp_path / f"{timestamp}.md").exists()
  564. assert json_path.name == f"{timestamp}-1.json"
  565. assert markdown_path.name == f"{timestamp}-1.md"
  566. _assert_no_publication_artifacts(tmp_path)
  567. def test_json_replaced_immediately_before_link_wins_and_writer_retries(
  568. tmp_path: Path, monkeypatch: pytest.MonkeyPatch
  569. ):
  570. reporting = _reporting_module()
  571. timestamp = "20260714T011224Z"
  572. external_json = tmp_path / f"{timestamp}.json"
  573. external_json.write_text("old external json")
  574. replacement = tmp_path / "competitor-replacement.json"
  575. real_link = reporting.os.link
  576. real_replace = reporting.os.replace
  577. link_calls = 0
  578. def replace_before_link(source: Path, destination: Path) -> None:
  579. nonlocal link_calls
  580. link_calls += 1
  581. destination = Path(destination)
  582. if link_calls == 1:
  583. replacement.write_text("new external json")
  584. real_replace(replacement, destination)
  585. real_link(source, destination)
  586. monkeypatch.setattr(reporting.os, "link", replace_before_link)
  587. json_path, markdown_path = reporting.write_benchmark_report(
  588. _marker_report("json-link-replacement"),
  589. tmp_path,
  590. timestamp=timestamp,
  591. )
  592. assert link_calls == 3
  593. assert external_json.read_text() == "new external json"
  594. assert json_path.name == f"{timestamp}-1.json"
  595. assert markdown_path.name == f"{timestamp}-1.md"
  596. _assert_no_publication_artifacts(tmp_path)
  597. @pytest.mark.parametrize("collision_kind", ["create", "replace"])
  598. def test_markdown_link_conflict_removes_owned_json_link_and_retries(
  599. tmp_path: Path,
  600. monkeypatch: pytest.MonkeyPatch,
  601. collision_kind: str,
  602. ):
  603. reporting = _reporting_module()
  604. timestamp = "20260714T011225Z"
  605. base_json = tmp_path / f"{timestamp}.json"
  606. external_markdown = tmp_path / f"{timestamp}.md"
  607. replacement = tmp_path / "competitor-replacement.md"
  608. real_link = reporting.os.link
  609. real_replace = reporting.os.replace
  610. link_calls = 0
  611. if collision_kind == "replace":
  612. external_markdown.write_text("old external markdown")
  613. def conflict_on_markdown(source: Path, destination: Path) -> None:
  614. nonlocal link_calls
  615. link_calls += 1
  616. destination = Path(destination)
  617. if destination == external_markdown:
  618. if collision_kind == "replace":
  619. replacement.write_text("external markdown")
  620. real_replace(replacement, destination)
  621. else:
  622. destination.write_text("external markdown")
  623. real_link(source, destination)
  624. monkeypatch.setattr(reporting.os, "link", conflict_on_markdown)
  625. json_path, markdown_path = reporting.write_benchmark_report(
  626. _marker_report("markdown-link-collision"),
  627. tmp_path,
  628. timestamp=timestamp,
  629. )
  630. assert link_calls == 4
  631. assert not base_json.exists()
  632. assert external_markdown.read_text() == "external markdown"
  633. assert json_path.name == f"{timestamp}-1.json"
  634. assert markdown_path.name == f"{timestamp}-1.md"
  635. _assert_no_publication_artifacts(tmp_path)
  636. def test_markdown_conflict_does_not_remove_replaced_json_target(
  637. tmp_path: Path,
  638. monkeypatch: pytest.MonkeyPatch,
  639. ):
  640. reporting = _reporting_module()
  641. timestamp = "20260714T011226Z"
  642. base_json = tmp_path / f"{timestamp}.json"
  643. base_markdown = tmp_path / f"{timestamp}.md"
  644. competitor_json = tmp_path / "competitor.json"
  645. real_link = reporting.os.link
  646. real_replace = reporting.os.replace
  647. def replace_json_before_markdown_conflict(
  648. source: Path,
  649. destination: Path,
  650. ) -> None:
  651. destination = Path(destination)
  652. if destination == base_markdown:
  653. competitor_json.write_text("external json")
  654. real_replace(competitor_json, base_json)
  655. base_markdown.write_text("external markdown")
  656. real_link(source, destination)
  657. monkeypatch.setattr(
  658. reporting.os,
  659. "link",
  660. replace_json_before_markdown_conflict,
  661. )
  662. json_path, markdown_path = reporting.write_benchmark_report(
  663. _marker_report("replaced-json-before-cleanup"),
  664. tmp_path,
  665. timestamp=timestamp,
  666. )
  667. assert base_json.read_text() == "external json"
  668. assert base_markdown.read_text() == "external markdown"
  669. assert json_path.name == f"{timestamp}-1.json"
  670. assert markdown_path.name == f"{timestamp}-1.md"
  671. _assert_no_publication_artifacts(tmp_path)
  672. def test_link_exception_cleans_only_owned_links_and_temps(
  673. tmp_path: Path,
  674. monkeypatch: pytest.MonkeyPatch,
  675. ):
  676. reporting = _reporting_module()
  677. timestamp = "20260714T011227Z"
  678. base_json = tmp_path / f"{timestamp}.json"
  679. base_markdown = tmp_path / f"{timestamp}.md"
  680. competitor_json = tmp_path / "competitor.json"
  681. real_link = reporting.os.link
  682. real_replace = reporting.os.replace
  683. def fail_markdown_link(source: Path, destination: Path) -> None:
  684. destination = Path(destination)
  685. if destination == base_markdown:
  686. competitor_json.write_text("external json")
  687. real_replace(competitor_json, base_json)
  688. base_markdown.write_text("external markdown")
  689. raise OSError("simulated markdown link failure")
  690. real_link(source, destination)
  691. monkeypatch.setattr(reporting.os, "link", fail_markdown_link)
  692. with pytest.raises(
  693. reporting.BenchmarkReportWriteError,
  694. match="failed to write benchmark reports",
  695. ):
  696. reporting.write_benchmark_report(
  697. _marker_report("link-exception"),
  698. tmp_path,
  699. timestamp=timestamp,
  700. )
  701. assert base_json.read_text() == "external json"
  702. assert base_markdown.read_text() == "external markdown"
  703. _assert_no_publication_artifacts(tmp_path)
  704. def test_link_interrupt_cleans_owned_links_and_temps_before_propagating(
  705. tmp_path: Path,
  706. monkeypatch: pytest.MonkeyPatch,
  707. ):
  708. reporting = _reporting_module()
  709. real_link = reporting.os.link
  710. link_calls = 0
  711. def interrupt_markdown_link(source: Path, destination: Path) -> None:
  712. nonlocal link_calls
  713. link_calls += 1
  714. if link_calls == 2:
  715. raise KeyboardInterrupt
  716. real_link(source, destination)
  717. monkeypatch.setattr(reporting.os, "link", interrupt_markdown_link)
  718. with pytest.raises(KeyboardInterrupt):
  719. reporting.write_benchmark_report(
  720. _marker_report("link-interrupt"),
  721. tmp_path,
  722. timestamp="20260714T011228Z",
  723. )
  724. assert list(tmp_path.iterdir()) == []
  725. def test_temp_fsync_failure_removes_owned_temps(
  726. tmp_path: Path,
  727. monkeypatch: pytest.MonkeyPatch,
  728. ):
  729. reporting = _reporting_module()
  730. def failing_fsync(file_descriptor: int) -> None:
  731. del file_descriptor
  732. raise OSError("simulated temp fsync failure")
  733. monkeypatch.setattr(reporting.os, "fsync", failing_fsync)
  734. with pytest.raises(
  735. reporting.BenchmarkReportWriteError,
  736. match="failed to write benchmark reports",
  737. ):
  738. reporting.write_benchmark_report(
  739. _marker_report("temp-fsync-failure"),
  740. tmp_path,
  741. timestamp="20260714T012334Z",
  742. )
  743. assert list(tmp_path.iterdir()) == []
  744. def test_both_temps_are_complete_and_fsynced_before_first_link(
  745. tmp_path: Path,
  746. monkeypatch: pytest.MonkeyPatch,
  747. ):
  748. reporting = _reporting_module()
  749. timestamp = "20260714T040506Z"
  750. real_fsync = reporting.os.fsync
  751. real_link = reporting.os.link
  752. fsync_calls = 0
  753. link_calls = 0
  754. def record_fsync(file_descriptor: int) -> None:
  755. nonlocal fsync_calls
  756. fsync_calls += 1
  757. real_fsync(file_descriptor)
  758. def inspect_before_link(source: Path, destination: Path) -> None:
  759. nonlocal link_calls
  760. link_calls += 1
  761. temp_paths = sorted(tmp_path.glob("*.tmp"))
  762. assert len(temp_paths) == 2
  763. assert all(path.stat().st_size > 0 for path in temp_paths)
  764. assert fsync_calls == 2
  765. real_link(source, destination)
  766. monkeypatch.setattr(reporting.os, "fsync", record_fsync)
  767. monkeypatch.setattr(reporting.os, "link", inspect_before_link)
  768. json_path, markdown_path = reporting.write_benchmark_report(
  769. _marker_report("complete-temps"),
  770. tmp_path,
  771. timestamp=timestamp,
  772. )
  773. assert link_calls == 2
  774. assert json.loads(json_path.read_text())["target"]["model"] == "complete-temps"
  775. assert "- Model: `complete-temps`" in markdown_path.read_text()
  776. _assert_no_publication_artifacts(tmp_path)
  777. @pytest.mark.parametrize("stress_round", range(3))
  778. def test_concurrent_thread_writers_reserve_distinct_matching_pairs(
  779. tmp_path: Path,
  780. monkeypatch: pytest.MonkeyPatch,
  781. stress_round: int,
  782. ):
  783. reporting = _reporting_module()
  784. writer_count = 8
  785. markers = [
  786. f"thread-{stress_round}-writer-{index}"
  787. for index in range(writer_count)
  788. ]
  789. first_temp_barrier = threading.Barrier(writer_count, timeout=15)
  790. original_write_text = reporting._write_text
  791. thread_state = threading.local()
  792. def synchronized_write(path: Path, content: str) -> tuple[int, int]:
  793. identity = original_write_text(path, content)
  794. if not getattr(thread_state, "first_write_done", False):
  795. thread_state.first_write_done = True
  796. first_temp_barrier.wait()
  797. return identity
  798. monkeypatch.setattr(reporting, "_write_text", synchronized_write)
  799. with ThreadPoolExecutor(max_workers=writer_count) as executor:
  800. results = list(
  801. executor.map(
  802. lambda marker: reporting.write_benchmark_report(
  803. _marker_report(marker),
  804. tmp_path,
  805. timestamp=f"20260714T02030{stress_round}Z",
  806. ),
  807. markers,
  808. )
  809. )
  810. assert len({json_path.stem for json_path, _ in results}) == writer_count
  811. _assert_concurrent_report_pairs(tmp_path, markers)
  812. @pytest.mark.parametrize("stress_round", range(2))
  813. def test_concurrent_process_writers_reserve_distinct_matching_pairs(
  814. tmp_path: Path,
  815. stress_round: int,
  816. ):
  817. context = multiprocessing.get_context("spawn")
  818. writer_count = 4
  819. markers = [
  820. f"process-{stress_round}-writer-{index}"
  821. for index in range(writer_count)
  822. ]
  823. first_temp_barrier = context.Barrier(writer_count, timeout=20)
  824. result_queue = context.Queue()
  825. processes = [
  826. context.Process(
  827. target=_process_report_writer,
  828. args=(
  829. str(tmp_path),
  830. marker,
  831. f"20260714T03040{stress_round}Z",
  832. first_temp_barrier,
  833. result_queue,
  834. ),
  835. )
  836. for marker in markers
  837. ]
  838. for process in processes:
  839. process.start()
  840. results = [result_queue.get(timeout=30) for _ in processes]
  841. for process in processes:
  842. process.join(timeout=30)
  843. assert all(process.exitcode == 0 for process in processes)
  844. assert all(result[0] == "ok" for result in results), results
  845. assert len({result[2][:-5] for result in results}) == writer_count
  846. _assert_concurrent_report_pairs(tmp_path, markers)