Skip to content

Commit bf5e6a3

Browse files
committed
Report incomplete coverage on the PR comment, not just the commit status
The previous commit taught _verdict_outputs about missing buckets but not _build_summary_markdown, which is what produces verdict_summary.md -- and that one file is both the job summary and the sticky PR comment. The two surfaces are computed by different code paths, so a run where 8 of 9 buckets reported put a red status reading "only 8 of 9 buckets reported a result" directly above a comment headlined "No meaningful performance regressions detected ... 0 benchmark failures". A reviewer reading the comment would conclude the red check was gate noise and merge a change whose one regressing task was never measured. Reproduced end to end against the real nine-bucket matrix. Coverage is now computed once, in _coverage(), and passed to both surfaces, so they cannot disagree by construction. The comment headline ranks a shortfall above skew, BLOCK and WARN -- the rows that did arrive may all be clean, but the change is not covered, so no all-clear may be printed -- and the count line names the buckets that never reported rather than only counting them. Counts are over distinct buckets, so a duplicated artifact cannot inflate the total, and an unreadable tasks.json yields an empty result that can never invent a failure. The advisory banner claimed a red status always shows up "in this table", which was untrue for exactly this case; it now points at the overall result instead. The new tests drive the real aggregate.main() over the real matrix and compare both surfaces, which is the check that was missing: the previous tests exercised _verdict_outputs in isolation and could not have caught a divergence.
1 parent 76ec05c commit bf5e6a3

2 files changed

Lines changed: 162 additions & 36 deletions

File tree

tools/perf_smoke_test/aggregate.py

Lines changed: 72 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -329,8 +329,40 @@ def _build_stale_image_section(skewed: list[tuple[str, str, DependencySkew]]) ->
329329
)
330330

331331

332-
def _build_summary_markdown(rows: list[tuple], *, blocking: bool) -> str:
333-
"""Build reviewer-first Markdown for the sticky PR comment."""
332+
def _coverage(rows: list[tuple]) -> tuple[list[str], int]:
333+
"""Return ``(missing_labels, expected_total)`` for the configured matrix.
334+
335+
A bucket whose job died before ``build_bench_result`` uploads nothing, so it
336+
contributes no row. Grading only the rows that arrived would report an
337+
all-clear over a bucket that was never measured, which is exactly the class
338+
of silent green this gate exists to catch. Returns ``([], 0)`` when the
339+
matrix cannot be read, so this can never invent a failure.
340+
341+
Counts are over distinct buckets, not rows, so a duplicated artifact cannot
342+
inflate the total.
343+
"""
344+
try:
345+
expected = {(task.task_id, task.backend_key) for task in load_tasks()}
346+
except Exception:
347+
return [], 0
348+
reported = {(result.task_id, result.backend) for result, _ in rows}
349+
missing = sorted(f"{task_id}/{backend}" for task_id, backend in expected - reported)
350+
return missing, len(expected)
351+
352+
353+
def _build_summary_markdown(
354+
rows: list[tuple], *, blocking: bool, missing: list[str] | None = None, expected_total: int = 0
355+
) -> str:
356+
"""Build reviewer-first Markdown for the sticky PR comment.
357+
358+
Args:
359+
rows: ``(OracleResult, BenchResult)`` pairs for every scored bucket.
360+
blocking: Whether the gate is in blocking mode, for the mode banner.
361+
missing: ``task/backend`` labels that produced no result at all. Kept in
362+
the headline so the comment cannot disagree with the commit status.
363+
expected_total: Size of the configured matrix, for the "N of M" phrasing.
364+
"""
365+
missing = missing or []
334366
counts = {verdict: 0 for verdict in OracleVerdict}
335367
for result, _ in rows:
336368
counts[result.verdict] += 1
@@ -347,6 +379,11 @@ def _build_summary_markdown(rows: list[tuple], *, blocking: bool) -> str:
347379
overall = "❌ No benchmark results were produced"
348380
elif unexplained_failures:
349381
overall = "❌ One or more benchmarks failed before producing usable performance data"
382+
elif missing:
383+
# Ranked above skew/BLOCK/WARN: the rows that did arrive may all be clean,
384+
# but the change is not covered, so no all-clear may be printed.
385+
reported_n = expected_total - len(missing)
386+
overall = f"❌ Only {reported_n} of {expected_total} benchmark buckets reported — coverage is incomplete"
350387
elif skewed:
351388
overall = "⚠️ The CI image is stale for this PR, so some tasks could not be measured"
352389
elif counts[OracleVerdict.BLOCK]:
@@ -359,20 +396,25 @@ def _build_summary_markdown(rows: list[tuple], *, blocking: bool) -> str:
359396
warnings_label = "warning" if counts[OracleVerdict.WARN] == 1 else "warnings"
360397
blocks_label = "blocking signal" if counts[OracleVerdict.BLOCK] == 1 else "blocking signals"
361398
failures_label = "benchmark failure" if counts[OracleVerdict.HARD_FAILURE] == 1 else "benchmark failures"
362-
count_summary = " · ".join(
363-
(
364-
f"✅ {counts[OracleVerdict.PASS]} passed",
365-
f"⚠️ {counts[OracleVerdict.WARN]} {warnings_label}",
366-
f"🚫 {counts[OracleVerdict.BLOCK]} {blocks_label}",
367-
f"❌ {counts[OracleVerdict.HARD_FAILURE]} {failures_label}",
368-
)
369-
)
399+
count_parts = [
400+
f"✅ {counts[OracleVerdict.PASS]} passed",
401+
f"⚠️ {counts[OracleVerdict.WARN]} {warnings_label}",
402+
f"🚫 {counts[OracleVerdict.BLOCK]} {blocks_label}",
403+
f"❌ {counts[OracleVerdict.HARD_FAILURE]} {failures_label}",
404+
]
405+
if missing:
406+
# Name them: "8 of 9" alone leaves the reviewer guessing which task is
407+
# unmeasured, and a missing bucket is the one a regression can hide in.
408+
count_parts.append(f"🚫 {len(missing)} did not report ({', '.join(missing)})")
409+
count_summary = " · ".join(count_parts)
370410
mode = (
371411
"**Blocking:** BLOCK and HARD FAILURE results fail the check."
372412
if blocking
373413
else (
374-
"**Advisory:** this job stays green whatever the verdict. A BLOCK or HARD FAILURE still shows up"
375-
" as a red `perf-smoke-test` commit status and in this table -- it just does not fail the PR."
414+
"**Advisory:** this job stays green whatever the verdict. Anything that is not a clean pass --"
415+
" a BLOCK, a HARD FAILURE, or a bucket that never reported -- still shows up as a red"
416+
" `perf-smoke-test` commit status, and the reason is in the overall result above."
417+
" It just does not fail the PR."
376418
)
377419
)
378420

@@ -425,7 +467,13 @@ def _write_github_output(**values) -> None:
425467

426468

427469
def _verdict_outputs(
428-
rows: list[tuple], *, has_block: bool, has_hard_failure: bool, blocking: bool, expected_buckets: int | None = None
470+
rows: list[tuple],
471+
*,
472+
has_block: bool,
473+
has_hard_failure: bool,
474+
blocking: bool,
475+
missing: list[str] | None = None,
476+
expected_total: int = 0,
429477
) -> dict[str, str]:
430478
"""Derive the reported verdict and the `perf-smoke-test` commit status from the rows.
431479
@@ -440,8 +488,9 @@ def _verdict_outputs(
440488
has_hard_failure: Whether any bucket failed to produce a usable measurement,
441489
excluding failures already excused as a stale CI image.
442490
blocking: The gate's ``blocking`` setting, used only to label the status.
443-
expected_buckets: How many buckets the matrix should have produced. When
444-
fewer reported, the gate says so instead of grading the survivors.
491+
missing: ``task/backend`` labels that produced no result. Shared with
492+
:func:`_build_summary_markdown` so the commit status and the PR
493+
comment cannot disagree about coverage.
445494
446495
Returns:
447496
The ``overall_verdict`` / ``status_state`` / ``status_description`` /
@@ -453,7 +502,7 @@ def _verdict_outputs(
453502
# most bench jobs never uploaded, would otherwise fall through to an
454503
# affirmative "no regression detected" over measurements that never happened.
455504
unmeasured = [result for result, _ in rows if result.verdict == OracleVerdict.HARD_FAILURE]
456-
missing = max(0, (expected_buckets or 0) - len(rows))
505+
missing = missing or []
457506

458507
if has_hard_failure:
459508
verdict = OracleVerdict.HARD_FAILURE
@@ -465,7 +514,7 @@ def _verdict_outputs(
465514
description = f"perf-smoke: no usable measurement for {len(unmeasured)} bucket(s); CI image looks stale"
466515
elif missing or not rows:
467516
verdict = OracleVerdict.HARD_FAILURE
468-
description = f"perf-smoke: only {len(rows)} of {expected_buckets or '?'} buckets reported a result"
517+
description = f"perf-smoke: only {expected_total - len(missing)} of {expected_total} buckets reported a result"
469518
elif has_block:
470519
verdict = OracleVerdict.BLOCK
471520
description = "perf-smoke: blocking-level performance regression detected"
@@ -703,7 +752,10 @@ def main() -> int:
703752
baseline_update_failed = True
704753
print(f"::error::Baseline push failed: {exc}")
705754

706-
summary = _build_summary_markdown(rows, blocking=blocking)
755+
# One source of truth for coverage, shared by the summary/comment and the
756+
# commit status so the two surfaces cannot contradict each other.
757+
missing_buckets, expected_total = _coverage(rows)
758+
summary = _build_summary_markdown(rows, blocking=blocking, missing=missing_buckets, expected_total=expected_total)
707759
print("\n## Performance Smoke Results\n")
708760
print(summary)
709761
print()
@@ -729,25 +781,15 @@ def main() -> int:
729781
app_config=args.omni_app_config,
730782
)
731783

732-
# How many buckets the matrix should have produced. The gate always runs the
733-
# full matrix, so a shortfall means bench jobs died without uploading -- which
734-
# must not be graded as a pass over the survivors. Left as None (check
735-
# disabled) if tasks.json cannot be read, so this can never fail a run by
736-
# itself.
737-
try:
738-
expected_buckets = len(load_tasks())
739-
except Exception as exc: # pragma: no cover - defensive
740-
print(f"[aggregate] Warning: could not determine expected bucket count: {exc}")
741-
expected_buckets = None
742-
743784
output_values = {
744785
"baseline_read_sha": baseline_read_sha,
745786
**_verdict_outputs(
746787
rows,
747788
has_block=has_block,
748789
has_hard_failure=has_hard_failure,
749790
blocking=blocking,
750-
expected_buckets=expected_buckets,
791+
missing=missing_buckets,
792+
expected_total=expected_total,
751793
),
752794
}
753795
if baseline_push_result:

tools/perf_smoke_test/test/test_advisory_exit.py

Lines changed: 90 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,8 @@ def test_skew_excused_crashes_do_not_report_pass() -> None:
237237
has_block=False,
238238
has_hard_failure=False, # cleared by detect_dependency_skew
239239
blocking=False,
240-
expected_buckets=9,
240+
missing=[],
241+
expected_total=9,
241242
)
242243

243244
assert out["overall_verdict"] == "HARD_FAILURE"
@@ -249,7 +250,12 @@ def test_skew_excused_crashes_do_not_report_pass() -> None:
249250
def test_missing_buckets_are_not_graded_on_the_survivors() -> None:
250251
"""One passing bucket out of nine is not a pass."""
251252
out = aggregate._verdict_outputs(
252-
_rows(OracleVerdict.PASS), has_block=False, has_hard_failure=False, blocking=False, expected_buckets=9
253+
_rows(OracleVerdict.PASS),
254+
has_block=False,
255+
has_hard_failure=False,
256+
blocking=False,
257+
missing=[f"Task-{i}/physx" for i in range(8)],
258+
expected_total=9,
253259
)
254260

255261
assert out["overall_verdict"] == "HARD_FAILURE"
@@ -264,22 +270,100 @@ def test_a_complete_clean_run_still_passes() -> None:
264270
has_block=False,
265271
has_hard_failure=False,
266272
blocking=False,
267-
expected_buckets=9,
273+
missing=[],
274+
expected_total=9,
268275
)
269276

270277
assert out["overall_verdict"] == "PASS"
271278
assert out["status_state"] == "success"
272279
assert "9 buckets" in out["status_description"]
273280

274281

275-
def test_expected_bucket_count_is_optional() -> None:
276-
"""An unreadable tasks.json disables the completeness check, never fails a run."""
282+
def test_unreadable_matrix_disables_the_completeness_check() -> None:
283+
"""An unreadable tasks.json must never invent a failure."""
277284
out = aggregate._verdict_outputs(
278285
_rows(*([OracleVerdict.PASS] * 3)),
279286
has_block=False,
280287
has_hard_failure=False,
281288
blocking=False,
282-
expected_buckets=None,
289+
missing=[],
290+
expected_total=0,
283291
)
284292

285293
assert out["overall_verdict"] == "PASS"
294+
295+
296+
# --- the comment and the commit status must never disagree ----------------
297+
#
298+
# They are produced by different code paths: the sticky comment and job summary
299+
# come from _build_summary_markdown, the status from _verdict_outputs. An
300+
# earlier fix taught only the latter about missing buckets, so a run with 8 of 9
301+
# buckets reported showed a red status reading "only 8 of 9 buckets reported"
302+
# directly above a comment headlined "No meaningful performance regressions
303+
# detected -- 0 benchmark failures". These drive the real aggregate.main() over
304+
# the real tasks.json and compare both surfaces.
305+
306+
import dataclasses # noqa: E402
307+
308+
from task_config import load_tasks # noqa: E402
309+
310+
311+
def _run_real_matrix(tmp_path: Path, monkeypatch, *, reported: int):
312+
"""Run aggregate over `reported` of the 9 real matrix buckets."""
313+
buckets = [(t.task_id, t.backend_key) for t in load_tasks()]
314+
artifacts_dir = tmp_path / "artifacts"
315+
artifacts_dir.mkdir(parents=True, exist_ok=True)
316+
(tmp_path / "baselines").mkdir(parents=True, exist_ok=True)
317+
summary_file = tmp_path / "verdict_summary.md"
318+
output_file = tmp_path / "gh_output.txt"
319+
gate_config = tmp_path / "gate_config.json"
320+
gate_config.write_text(json.dumps({"blocking": False}), encoding="utf-8")
321+
322+
for i, (task_id, backend_key) in enumerate(buckets[:reported]):
323+
bench = dataclasses.replace(
324+
_bench_result(fps=100.0, info_present=True), task_id=task_id, backend=backend_key, backend_key=backend_key
325+
)
326+
task_dir = artifacts_dir / f"bench-{i}"
327+
task_dir.mkdir(parents=True, exist_ok=True)
328+
(task_dir / "perf_smoke_test_result.json").write_text(json.dumps(bench.to_dict()))
329+
330+
monkeypatch.setenv("GITHUB_OUTPUT", str(output_file))
331+
monkeypatch.setattr(
332+
sys,
333+
"argv",
334+
[
335+
"aggregate.py",
336+
"--artifacts_dir", str(artifacts_dir),
337+
"--gpu_model", "L40S",
338+
"--baselines_dir", str(tmp_path / "baselines"),
339+
"--allow_baseline_update", "false",
340+
"--gate_config", str(gate_config),
341+
"--summary_file", str(summary_file),
342+
],
343+
) # fmt: skip
344+
aggregate.main()
345+
outputs = dict(line.split("=", 1) for line in output_file.read_text().splitlines() if "=" in line)
346+
return outputs, summary_file.read_text(encoding="utf-8"), len(buckets)
347+
348+
349+
def test_partial_matrix_is_flagged_on_both_surfaces(tmp_path, monkeypatch) -> None:
350+
"""A red status must never sit above an all-clear comment."""
351+
total_reported = 8
352+
outputs, summary, total = _run_real_matrix(tmp_path, monkeypatch, reported=total_reported)
353+
354+
assert outputs["status_state"] == "failure"
355+
assert f"only {total_reported} of {total}" in outputs["status_description"]
356+
# The comment is what a reviewer reads, so it must carry the same message.
357+
assert f"Only {total_reported} of {total}" in summary
358+
assert "No meaningful performance regressions detected" not in summary
359+
# And it must name the bucket that vanished, not just count it.
360+
assert "did not report" in summary
361+
362+
363+
def test_complete_matrix_is_clean_on_both_surfaces(tmp_path, monkeypatch) -> None:
364+
"""The coverage guard must not fire when every bucket reported."""
365+
outputs, summary, total = _run_real_matrix(tmp_path, monkeypatch, reported=9)
366+
367+
assert outputs["status_state"] == "success"
368+
assert "did not report" not in summary
369+
assert "coverage is incomplete" not in summary

0 commit comments

Comments
 (0)