ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
matrix_ratchet.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""matrix_ratchet.py -- ra8_emulator example-matrix ratchet (compare vs baseline).
5
6`scripts/emu/matrix.sh` boots EVERY example under `examples/ek_ra8d2/` on the
7board emulator and writes one `app<pad>VERDICT` row per app to
8`build/ra8_emulator_matrix.txt`. That sweep measures #67's own headline success
9criterion -- "every example runs in the emulator" -- and until #394 it was
10invoked by nothing: not ci.sh, not a workflow, not the justfile. The repo's
11dominant defect class (a gate wired to nothing) applied to the epic's own
12definition of done.
13
14This script turns that report into a one-way ratchet against a committed
15baseline:
16
17* NEW debt (an app in a failing state that the baseline does not record in
18 that state) FAILS the gate.
19* Shrinkage (debt burned down) PASSES with a notice to re-baseline via
20 `--update`, which locks the progress in so it cannot quietly grow back.
21
22WHY PER-APP BUCKETS AND NOT A BARE TOTAL. A single number is satisfied by a
23swap: fix one app, break another, net zero, gate green. The bucket key is
24`(app, verdict)`, so a swap trips -- the new pair is absent from the baseline.
25It is also what makes the burn-down actionable: the diff names the app.
26
27This is a RATCHET, not an allowlist. A baseline entry is recorded debt with an
28end state of zero, it is never a permanent exemption, and nothing in this file
29can mark an app "expected to fail forever".
30
31VERDICT CLASSES. Debt is FAULT / TRUNCATED / UNKNOWN / BUILD_FAIL / NO_ELF.
32OK and HALT reached their budget. SPECIAL (two-image TrustZone, needs a --ns
33recipe) and SKIPPED (_unsupported tier, needs external hardware) are not run
34at all, so they are neither credit nor debt.
35
36TRUNCATED is debt on purpose. It means a wall-clock bound cut the run short
37before the deterministic chunk budget -- the app produced NO verdict. Counting
38a non-verdict as a pass is the #168 mislabel; counting it as a fault invents a
39failure. It is its own bucket so the burn-down can see it.
40
41USAGE
42 python3 scripts/checks/matrix_ratchet.py --selftest # assert it fires
43 python3 scripts/checks/matrix_ratchet.py --check # the gate
44 python3 scripts/checks/matrix_ratchet.py --update # re-baseline
45
46`--check` and `--update` read `build/ra8_emulator_matrix.txt`; produce it first:
47 bash scripts/emu/matrix.sh
48
49Copyright (c) 2026 Brighton Sikarskie
50SPDX-License-Identifier: MIT
51"""
52
53from __future__ import annotations
54
55import argparse
56import sys
57from pathlib import Path
58
59REPO_ROOT = Path(__file__).resolve().parents[2]
60REPORT_FILE = REPO_ROOT / "build" / "ra8_emulator_matrix.txt"
61BASELINE_FILE = REPO_ROOT / ".github" / "emulator-matrix-baseline.txt"
62
63MAX_DETAIL_LINES = 250
64"""Cap on offending apps echoed before the report truncates.
65
66Deliberately above the example count. The first run of this gate on a new
67machine has an empty baseline, so EVERY failing app is "new" -- and that
68listing is what the baseline is then built from. A cap that truncated it
69would make the gate's own bootstrap output unusable.
70"""
71
72BASELINE_COLUMNS = 2
73"""Column count of one baseline row: app, verdict."""
74
75DEBT_VERDICTS = frozenset({"FAULT", "TRUNCATED", "UNKNOWN", "BUILD_FAIL", "NO_ELF"})
76"""Verdicts that count as debt -- the set this gate ratchets downward."""
77
78PASS_VERDICTS = frozenset({"OK", "HALT"})
79"""Verdicts where the app reached its run budget."""
80
81NOT_RUN_VERDICTS = frozenset({"SPECIAL", "SKIPPED"})
82"""Verdicts for apps the matrix never boots (neither credit nor debt)."""
83
84KNOWN_VERDICTS = DEBT_VERDICTS | PASS_VERDICTS | NOT_RUN_VERDICTS
85"""Every verdict matrix.sh can emit. An unknown one is a hard error."""
86
87
88def parse_report(text: str) -> dict[str, str]:
89 """Return an {app: verdict} map for a matrix.sh report.
90
91 Each row is `app` padded to a column then the verdict, so a plain split on
92 whitespace recovers both. A row that does not yield exactly two fields is a
93 malformed report and is fatal: silently skipping rows is how a gate ends up
94 ratcheting against a fraction of the sweep and calling it clean.
95
96 Args:
97 text: The full contents of `build/ra8_emulator_matrix.txt`.
98
99 Returns:
100 A mapping of app name to its verdict string.
101 """
102 verdicts: dict[str, str] = {}
103 for raw in text.splitlines():
104 if not raw.strip():
105 continue
106 cols = raw.split()
107 if len(cols) != BASELINE_COLUMNS:
108 sys.stderr.write(f"matrix_ratchet.py: ERROR -- malformed report row: {raw!r}\n")
109 sys.exit(1)
110 app, verdict = cols
111 if verdict not in KNOWN_VERDICTS:
112 sys.stderr.write(
113 f"matrix_ratchet.py: ERROR -- unknown verdict {verdict!r} for {app!r}.\n"
114 f" Known: {' '.join(sorted(KNOWN_VERDICTS))}\n"
115 " A new matrix.sh state must be classified here as debt or\n"
116 " not-debt; leaving it unclassified would let it slip the gate.\n"
117 )
118 sys.exit(1)
119 verdicts[app] = verdict
120 return verdicts
121
122
123def debt_of(verdicts: dict[str, str]) -> dict[str, str]:
124 """Return only the failing entries of a verdict map.
125
126 Args:
127 verdicts: An {app: verdict} map.
128
129 Returns:
130 The subset whose verdict is in `DEBT_VERDICTS`.
131 """
132 return {app: v for app, v in verdicts.items() if v in DEBT_VERDICTS}
133
134
135def load_baseline(path: Path) -> dict[str, str]:
136 """Parse the committed baseline into an {app: verdict} map.
137
138 Args:
139 path: The baseline file.
140
141 Returns:
142 The recorded debt, empty when the file does not exist.
143 """
144 return _parse_baseline(path)[0]
145
146
147def load_baseline_causes(path: Path) -> dict[str, str]:
148 """Return the {app: cause} notes recorded alongside the baseline verdicts.
149
150 Args:
151 path: The baseline file.
152
153 Returns:
154 The recorded causes; apps without one are absent.
155 """
156 return _parse_baseline(path)[1]
157
158
159def _parse_baseline(path: Path) -> tuple[dict[str, str], dict[str, str]]:
160 """Parse the baseline into ({app: verdict}, {app: cause}).
161
162 A row is `app<TAB>verdict` or `app<TAB>verdict<TAB>cause`. The cause is
163 optional so a machine-written baseline stays valid, but it is the whole
164 reason the third column exists -- see `write_baseline`.
165
166 Args:
167 path: The baseline file.
168
169 Returns:
170 The recorded verdicts and causes, both empty when the file is absent.
171 """
172 if not path.exists():
173 return {}, {}
174 verdicts: dict[str, str] = {}
175 causes: dict[str, str] = {}
176 for raw in path.read_text(encoding="utf-8").splitlines():
177 if not raw.strip() or raw.startswith("#"):
178 continue
179 cols = raw.split("\t")
180 if len(cols) not in (BASELINE_COLUMNS, BASELINE_COLUMNS + 1):
181 sys.stderr.write(f"matrix_ratchet.py: ERROR -- malformed baseline row: {raw!r}\n")
182 sys.exit(1)
183 verdicts[cols[0]] = cols[1]
184 if len(cols) == BASELINE_COLUMNS + 1 and cols[2].strip():
185 causes[cols[0]] = cols[2].strip()
186 return verdicts, causes
187
188
189def write_baseline(path: Path, debt: dict[str, str], causes: dict[str, str] | None = None) -> None:
190 """Rewrite the baseline from a measured debt map, keeping recorded causes.
191
192 Each row carries an optional third column naming WHY the app is failing.
193 Without it a future reader sees a bare count and cannot tell recorded debt
194 from an unexamined allowlist -- which is the failure mode this whole gate
195 was built against. `--update` therefore carries the existing cause forward
196 for any app still in debt rather than regenerating a comment-free file.
197
198 Args:
199 path: The baseline file to write.
200 debt: The {app: verdict} debt to record.
201 causes: Optional {app: cause} notes to preserve.
202 """
203 causes = causes or {}
204 lines = [
205 "# ra8_emulator example-matrix baseline -- see scripts/checks/matrix_ratchet.py",
206 "#",
207 "# Recorded debt from `bash scripts/emu/matrix.sh`, one",
208 "# `app<TAB>verdict<TAB>cause` row each. This is a RATCHET: growth fails,",
209 "# shrinking is free, and the end state is an EMPTY file. It is not an",
210 "# allowlist -- no row here is a permanent exemption, and every one is work",
211 "# still owed. The cause column is not decoration: a number nobody can",
212 "# explain is indistinguishable from one nobody has looked at.",
213 "#",
214 "# MEASURE THIS ON THE CI RUNNER, NEVER ON A DEVELOPER BOX -- see #400.",
215 "#",
216 "# Re-baseline after burning debt down (causes are carried forward):",
217 "# bash scripts/emu/matrix.sh; python3 scripts/checks/matrix_ratchet.py --update",
218 f"# total: {len(debt)}",
219 ]
220 for app, verdict in sorted(debt.items()):
221 cause = causes.get(app, "")
222 lines.append(f"{app}\t{verdict}\t{cause}" if cause else f"{app}\t{verdict}")
223 path.write_text("\n".join(lines) + "\n", encoding="utf-8")
224
225
226def summarise(verdicts: dict[str, str]) -> str:
227 """Return a one-line count of each verdict class, most-failing first.
228
229 Args:
230 verdicts: An {app: verdict} map.
231
232 Returns:
233 A printable summary such as `OK 155 FAULT 46 SPECIAL 3`.
234 """
235 counts: dict[str, int] = {}
236 for verdict in verdicts.values():
237 counts[verdict] = counts.get(verdict, 0) + 1
238 return " ".join(f"{v} {n}" for v, n in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])))
239
240
241def report_growth(grown: dict[str, str], baseline: dict[str, str]) -> None:
242 """Print the failure report for newly-appeared debt.
243
244 Args:
245 grown: The {app: verdict} debt absent from the baseline.
246 baseline: The recorded baseline, used to explain a changed verdict.
247 """
248 sys.stderr.write(
249 f"\nmatrix_ratchet.py: FAIL -- {len(grown)} example(s) newly failing in ra8_emulator.\n\n"
250 )
251 for app, verdict in sorted(grown.items())[:MAX_DETAIL_LINES]:
252 was = baseline.get(app)
253 prior = f"was {was}" if was else "not in the baseline"
254 sys.stderr.write(f" {app:<32} {verdict:<11} ({prior})\n")
255 if len(grown) > MAX_DETAIL_LINES:
256 sys.stderr.write(f" ... and {len(grown) - MAX_DETAIL_LINES} more\n")
257 sys.stderr.write(
258 "\n This gate ratchets the ra8_emulator example matrix DOWNWARD (#394): the\n"
259 " count may shrink freely and may never grow. Either fix the example /\n"
260 " the ra8_emulator model gap, or -- if this is a verdict CHANGE rather than\n"
261 " a regression -- explain it in the commit and re-baseline with:\n"
262 " bash scripts/emu/matrix.sh\n"
263 " python3 scripts/checks/matrix_ratchet.py --update\n"
264 )
265
266
267def check(report_file: Path = REPORT_FILE, baseline_file: Path = BASELINE_FILE) -> int:
268 """Run the ratchet against the committed baseline.
269
270 Args:
271 report_file: The matrix.sh report to read.
272 baseline_file: The committed baseline to compare against.
273
274 Returns:
275 A process exit status: 0 when debt did not grow, 1 when it did.
276 """
277 if not report_file.exists():
278 sys.stderr.write(
279 f"matrix_ratchet.py: FATAL -- no report at {report_file}.\n"
280 " Run `bash scripts/emu/matrix.sh` first. A missing report is a\n"
281 " hard failure, never a silent pass -- a gate that reports\n"
282 " nothing for work never done is the defect this closes.\n"
283 )
284 return 1
285 verdicts = parse_report(report_file.read_text(encoding="utf-8"))
286 if not verdicts:
287 sys.stderr.write(
288 "matrix_ratchet.py: FATAL -- the report is empty; the sweep did not run.\n"
289 )
290 return 1
291 debt = debt_of(verdicts)
292 baseline = load_baseline(baseline_file)
293
294 # The count is printed on EVERY run, pass or fail, so the burn-down is
295 # visible in the log rather than buried in a baseline diff.
296 print(f"ra8_emulator matrix: {len(verdicts)} example(s) -- {summarise(verdicts)}")
297 print(f"ra8_emulator matrix: failing {len(debt)}, baseline {len(baseline)}")
298
299 grown = {app: v for app, v in debt.items() if baseline.get(app) != v}
300 if grown:
301 report_growth(grown, baseline)
302 return 1
303 fixed = {app: v for app, v in baseline.items() if debt.get(app) != v}
304 if fixed:
305 print(f"ra8_emulator matrix: {len(fixed)} example(s) improved since the baseline:")
306 for app, verdict in sorted(fixed.items())[:MAX_DETAIL_LINES]:
307 print(f" {app:<32} {verdict} -> {verdicts.get(app, 'gone')}")
308 print(
309 " Lock the progress in: python3 scripts/checks/matrix_ratchet.py --update\n"
310 " (until then the gate still passes, but the debt can grow back to the\n"
311 " old, larger baseline without failing.)"
312 )
313 print("ra8_emulator matrix: OK -- the failing-example count did not grow.")
314 return 0
315
316
317def update(report_file: Path = REPORT_FILE, baseline_file: Path = BASELINE_FILE) -> int:
318 """Rewrite the baseline from the current report.
319
320 Args:
321 report_file: The matrix.sh report to read.
322 baseline_file: The baseline file to rewrite.
323
324 Returns:
325 A process exit status: 0 on success, 1 when the report is missing.
326 """
327 if not report_file.exists():
328 sys.stderr.write(f"matrix_ratchet.py: FATAL -- no report at {report_file}.\n")
329 return 1
330 debt = debt_of(parse_report(report_file.read_text(encoding="utf-8")))
331 # Carry every recorded cause forward, so re-baselining after a burn-down
332 # cannot silently strip the explanations off the apps that remain.
333 causes = load_baseline_causes(baseline_file)
334 write_baseline(baseline_file, debt, causes)
335 missing = sorted(app for app in debt if app not in causes)
336 print(f"matrix_ratchet.py: baseline rewritten -- {len(debt)} failing example(s).")
337 if missing:
338 print(
339 f"matrix_ratchet.py: {len(missing)} entr(y/ies) have no recorded cause: "
340 f"{', '.join(missing)}\n"
341 " Add one as a third TAB-separated column. Debt nobody can explain\n"
342 " reads as an allowlist the first time someone else looks at it."
343 )
344 return 0
345
346
347def _selftest_classification() -> list[str]:
348 """Assert every verdict is classified, and classified the right way.
349
350 Returns:
351 A list of failure descriptions, empty when the classification holds.
352 """
353 failures: list[str] = []
354 # An unclassified verdict is the silent-hole direction: it would be neither
355 # debt nor pass, so an app in that state could never fail the gate.
356 unclassified = KNOWN_VERDICTS - (DEBT_VERDICTS | PASS_VERDICTS | NOT_RUN_VERDICTS)
357 if unclassified:
358 failures.append(f"verdict(s) in no class: {sorted(unclassified)}")
359 for overlap, names in (
360 (DEBT_VERDICTS & PASS_VERDICTS, "debt/pass"),
361 (DEBT_VERDICTS & NOT_RUN_VERDICTS, "debt/not-run"),
362 ):
363 if overlap:
364 failures.append(f"{names} classes overlap on {sorted(overlap)}")
365 # The specific mislabels this gate exists to prevent.
366 if "TRUNCATED" not in DEBT_VERDICTS:
367 failures.append("TRUNCATED is not debt -- a non-verdict would read as a pass (#168)")
368 if "OK" in DEBT_VERDICTS:
369 failures.append("OK is classified as debt")
370 sample = parse_report("blink OK\nusb_x FAULT\n")
371 if debt_of(sample) != {"usb_x": "FAULT"}:
372 failures.append(f"debt_of() picked the wrong rows: {debt_of(sample)}")
373 return failures
374
375
376def _selftest_ratchet(tmp: Path) -> list[str]:
377 """Assert the ratchet fires on growth and stays quiet on shrinkage.
378
379 Both directions are asserted against real files. A ratchet that only ever
380 returns 0 looks exactly like a clean tree, which is the failure mode this
381 whole gate exists to close -- so "it must FAIL on a broken input" is the
382 load-bearing half.
383
384 Args:
385 tmp: A scratch directory for the fixture report and baseline.
386
387 Returns:
388 A list of failure descriptions, empty when both directions hold.
389 """
390 failures: list[str] = []
391 baseline = tmp / "baseline.txt"
392 report = tmp / "report.txt"
393 write_baseline(baseline, {"broken_app": "FAULT"})
394
395 # Direction 1 -- MUST PASS: the report matches the baseline exactly.
396 report.write_text("good_app OK\nbroken_app FAULT\n")
397 if check(report, baseline) != 0:
398 failures.append("a report matching the baseline did not pass")
399
400 # Direction 2 -- MUST FAIL: a second app has started faulting.
401 report.write_text(
402 "good_app FAULT\nbroken_app FAULT\n",
403 )
404 if check(report, baseline) == 0:
405 failures.append("a NEWLY-FAULTING example did not fail the ratchet")
406
407 # Direction 2b -- MUST FAIL: a truncated run is a non-verdict, not a pass.
408 report.write_text("good_app TRUNCATED\nbroken_app FAULT\n")
409 if check(report, baseline) == 0:
410 failures.append("a TRUNCATED example did not fail the ratchet")
411
412 # Direction 3 -- MUST PASS: debt burned down (shrinking is free).
413 report.write_text("good_app OK\nbroken_app OK\n")
414 if check(report, baseline) != 0:
415 failures.append("burning debt down did not pass")
416
417 # Direction 4 -- MUST FAIL: a missing report is never a silent pass.
418 if check(tmp / "absent.txt", baseline) == 0:
419 failures.append("a MISSING report passed instead of failing loudly")
420
421 # Causes must SURVIVE a re-baseline. If --update strips them, the file
422 # decays into a bare list of app names on the next burn-down and stops
423 # being distinguishable from an allowlist.
424 write_baseline(baseline, {"broken_app": "FAULT"}, {"broken_app": "a recorded reason"})
425 if load_baseline_causes(baseline).get("broken_app") != "a recorded reason":
426 failures.append("write_baseline() did not persist the cause column")
427 report.write_text("broken_app FAULT\nother_app FAULT\n")
428 update(report, baseline)
429 kept = load_baseline_causes(baseline)
430 if kept.get("broken_app") != "a recorded reason":
431 failures.append("--update dropped the recorded cause of a still-failing app")
432 if load_baseline(baseline).get("other_app") != "FAULT":
433 failures.append("--update did not record a newly-failing app")
434 return failures
435
436
437def selftest() -> int:
438 """Assert the gate fires in both directions before it is trusted.
439
440 Returns:
441 A process exit status: 0 when every assertion holds, 1 otherwise.
442 """
443 import tempfile # noqa: PLC0415 # selftest-only; not a runtime dependency
444
445 failures = _selftest_classification()
446 with tempfile.TemporaryDirectory() as td:
447 failures.extend(_selftest_ratchet(Path(td)))
448 if failures:
449 sys.stderr.write("matrix_ratchet.py --selftest: FAILED\n")
450 for line in failures:
451 sys.stderr.write(f" {line}\n")
452 return 1
453 print("matrix_ratchet.py --selftest: OK (fires on growth, quiet on shrinkage)")
454 return 0
455
456
457def main() -> int:
458 """Parse the mode argument and dispatch.
459
460 Returns:
461 A process exit status.
462 """
463 parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
464 mode = parser.add_mutually_exclusive_group()
465 mode.add_argument("--check", action="store_true", help="gate against the baseline (default)")
466 mode.add_argument("--update", action="store_true", help="rewrite the baseline")
467 mode.add_argument("--selftest", action="store_true", help="assert the gate itself fires")
468 args = parser.parse_args()
469 if args.selftest:
470 return selftest()
471 if args.update:
472 return update()
473 return check()
474
475
476if __name__ == "__main__":
477 sys.exit(main())
-copyright
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298