2020from __future__ import annotations
2121
2222import argparse
23+ import sys
2324from pathlib import Path
2425
2526import pandas as pd
2627
2728from bench_parse import simple_parser
2829
30+ # Exit code used by ``main`` when the report contains at least one regression.
31+ # Distinct from 1 (used by argparse/uncaught errors) so callers can tell a
32+ # detected regression apart from the script failing to run at all.
33+ EXIT_REGRESSION = 3
34+
2935# Measurement/derived columns produced by the parser. These vary run-to-run
3036# and must never be part of a row's identity key — only the benchmark's input
3137# parameters (the ``key=value`` sub-tests) identify a row.
@@ -110,26 +116,38 @@ def _ranges_overlap(base_samples: list, pr_samples: list) -> bool:
110116 return min (base_samples ) <= max (pr_samples ) and min (pr_samples ) <= max (base_samples )
111117
112118
113- def _emoji (metric : str , pct : float , base_samples : list , pr_samples : list , delta : float = _DEFAULT_DELTA ) -> str :
114- """Pick an indicator for a change, given whether lower is better.
119+ # Classification of a single metric change, independent of how it is rendered.
120+ _NEUTRAL , _IMPROVED , _REGRESSED = "neutral" , "improved" , "regressed"
121+
122+ _STATUS_EMOJI = {_NEUTRAL : "➖" , _IMPROVED : "🟢" , _REGRESSED : "🔴" }
123+
124+
125+ def _classify (metric : str , pct : float , base_samples : list , pr_samples : list , delta : float = _DEFAULT_DELTA ) -> str :
126+ """Classify a metric change as neutral, improved, or regressed.
115127
116- Returns the neutral marker when the change is within the ±1% noise band or
117- when the base/PR sample ranges overlap (i.e. the delta is not statistically
118- distinguishable from run-to-run jitter).
128+ Returns ``_NEUTRAL`` when the change is within the ±delta% noise band or when
129+ the base/PR sample ranges overlap (i.e. the delta is not statistically
130+ distinguishable from run-to-run jitter). This is the single source of truth
131+ for whether a row is a regression — the emoji is derived from it, never the
132+ other way around.
119133 """
120- if abs (pct ) < delta : # treat sub-1% as noise
121- return "➖"
134+ if abs (pct ) < delta : # treat sub-delta as noise
135+ return _NEUTRAL
122136 if _ranges_overlap (base_samples , pr_samples ):
123- return "➖"
137+ return _NEUTRAL
124138 improved = (pct < 0 ) == _LOWER_IS_BETTER [metric ]
125- return "🟢" if improved else "🔴"
139+ return _IMPROVED if improved else _REGRESSED
126140
127141
128- def build_report (base : pd .DataFrame , pr : pd .DataFrame , delta : float = _DEFAULT_DELTA ) -> str :
129- """Render the Markdown comparison table for two parsed benchmark groups."""
142+ def build_report (base : pd .DataFrame , pr : pd .DataFrame , delta : float = _DEFAULT_DELTA ) -> tuple [str , bool ]:
143+ """Render the Markdown comparison table for two parsed benchmark groups.
144+
145+ Returns ``(report, regressed)`` where ``regressed`` is ``True`` if any row was
146+ classified as a regression. Missing results are not treated as a regression.
147+ """
130148 if base .empty or pr .empty :
131149 missing = "base" if base .empty else "PR"
132- return f"⚠️ No benchmark results found for the **{ missing } ** branch."
150+ return f"⚠️ No benchmark results found for the **{ missing } ** branch." , False
133151
134152 # Parameter columns identify a row: everything except variant/bench/workers,
135153 # the measurements, and any derived latency column (``... (ms)``).
@@ -154,7 +172,7 @@ def by_key(df: pd.DataFrame) -> dict:
154172 for r in rows if not pd .isna (r .get (metric ))]
155173 agg [metric ] = sum (vals ) / len (vals ) if vals else float ("nan" )
156174 # Retain the raw per-count samples so the significance guard in
157- # ``_emoji `` can compare the base/PR ranges, not just the means.
175+ # ``_classify `` can compare the base/PR ranges, not just the means.
158176 # A plain string key avoids pandas treating a tuple as a
159177 # multi-index label on the Series.
160178 agg [f"_samples_{ metric } " ] = vals
@@ -164,6 +182,8 @@ def by_key(df: pd.DataFrame) -> dict:
164182 base_by_key = by_key (base )
165183 pr_by_key = by_key (pr )
166184
185+ regressed = False
186+
167187 lines = [
168188 "## 📊 Token Validation Benchmark" ,
169189 "" ,
@@ -192,9 +212,11 @@ def by_key(df: pd.DataFrame) -> dict:
192212 pct = _pct (bv , pv )
193213 base_samples = b .get (f"_samples_{ metric } " , [])
194214 pr_samples = p .get (f"_samples_{ metric } " , [])
215+ status = _classify (metric , pct , base_samples , pr_samples , delta )
216+ if status == _REGRESSED :
217+ regressed = True
195218 cells .append (f"{ bv :,.0f} → { pv :,.0f} " )
196- cells .append (
197- f"{ _emoji (metric , pct , base_samples , pr_samples )} { pct :+.1f} %" )
219+ cells .append (f"{ _STATUS_EMOJI [status ]} { pct :+.1f} %" )
198220 lines .append ("| " + " | " .join (cells ) + " |" )
199221
200222 only_pr = pr_by_key .keys () - base_by_key .keys ()
@@ -206,7 +228,7 @@ def by_key(df: pd.DataFrame) -> dict:
206228 lines += ["" ,
207229 f"> ℹ️ { len (only_base )} benchmark(s) present only on the base branch (removed/renamed)." ]
208230
209- return "\n " .join (lines ) + "\n "
231+ return "\n " .join (lines ) + "\n " , regressed
210232
211233
212234def main () -> None :
@@ -230,11 +252,17 @@ def main() -> None:
230252
231253 base = _load_group (args .input_dir , args .base_tag )
232254 pr = _load_group (args .input_dir , args .pr_tag )
233- report = build_report (base , pr , delta = args .delta )
255+ report , regressed = build_report (base , pr , delta = args .delta )
234256
257+ # Always write and print the report so it is visible regardless of outcome.
235258 args .output .write_text (report )
236259 print (report )
237260
261+ # Signal a detected regression via a distinct exit code so callers (e.g. CI)
262+ # can gate on it without parsing the rendered Markdown/emoji.
263+ if regressed :
264+ sys .exit (EXIT_REGRESSION )
265+
238266
239267if __name__ == "__main__" :
240268 main ()
0 commit comments