4"""matrix_ratchet.py -- ra8_emulator example-matrix ratchet (compare vs baseline).
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
14This script turns that report into a one-way ratchet against a committed
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.
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.
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".
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.
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.
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
46`--check` and `--update` read `build/ra8_emulator_matrix.txt`; produce it first:
47 bash scripts/emu/matrix.sh
49Copyright (c) 2026 Brighton Sikarskie
50SPDX-License-Identifier: MIT
53from __future__
import annotations
57from pathlib
import Path
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"
64"""Cap on offending apps echoed before the report truncates.
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.
73"""Column count of one baseline row: app, verdict."""
75DEBT_VERDICTS = frozenset({
"FAULT",
"TRUNCATED",
"UNKNOWN",
"BUILD_FAIL",
"NO_ELF"})
76"""Verdicts that count as debt -- the set this gate ratchets downward."""
78PASS_VERDICTS = frozenset({
"OK",
"HALT"})
79"""Verdicts where the app reached its run budget."""
81NOT_RUN_VERDICTS = frozenset({
"SPECIAL",
"SKIPPED"})
82"""Verdicts for apps the matrix never boots (neither credit nor debt)."""
84KNOWN_VERDICTS = DEBT_VERDICTS | PASS_VERDICTS | NOT_RUN_VERDICTS
85"""Every verdict matrix.sh can emit. An unknown one is a hard error."""
88def parse_report(text: str) -> dict[str, str]:
89 """Return an {app: verdict} map for a matrix.sh report.
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.
97 text: The full contents of `build/ra8_emulator_matrix.txt`.
100 A mapping of app name to its verdict string.
102 verdicts: dict[str, str] = {}
103 for raw
in text.splitlines():
107 if len(cols) != BASELINE_COLUMNS:
108 sys.stderr.write(f
"matrix_ratchet.py: ERROR -- malformed report row: {raw!r}\n")
111 if verdict
not in KNOWN_VERDICTS:
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"
119 verdicts[app] = verdict
123def debt_of(verdicts: dict[str, str]) -> dict[str, str]:
124 """Return only the failing entries of a verdict map.
127 verdicts: An {app: verdict} map.
130 The subset whose verdict is in `DEBT_VERDICTS`.
132 return {app: v
for app, v
in verdicts.items()
if v
in DEBT_VERDICTS}
135def load_baseline(path: Path) -> dict[str, str]:
136 """Parse the committed baseline into an {app: verdict} map.
139 path: The baseline file.
142 The recorded debt, empty when the file does not exist.
144 return _parse_baseline(path)[0]
147def load_baseline_causes(path: Path) -> dict[str, str]:
148 """Return the {app: cause} notes recorded alongside the baseline verdicts.
151 path: The baseline file.
154 The recorded causes; apps without one are absent.
156 return _parse_baseline(path)[1]
159def _parse_baseline(path: Path) -> tuple[dict[str, str], dict[str, str]]:
160 """Parse the baseline into ({app: verdict}, {app: cause}).
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`.
167 path: The baseline file.
170 The recorded verdicts and causes, both empty when the file is absent.
172 if not path.exists():
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(
"#"):
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")
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
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.
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.
199 path: The baseline file to write.
200 debt: The {app: verdict} debt to record.
201 causes: Optional {app: cause} notes to preserve.
203 causes = causes
or {}
205 "# ra8_emulator example-matrix baseline -- see scripts/checks/matrix_ratchet.py",
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.",
214 "# MEASURE THIS ON THE CI RUNNER, NEVER ON A DEVELOPER BOX -- see #400.",
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)}",
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")
226def summarise(verdicts: dict[str, str]) -> str:
227 """Return a one-line count of each verdict class, most-failing first.
230 verdicts: An {app: verdict} map.
233 A printable summary such as `OK 155 FAULT 46 SPECIAL 3`.
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])))
241def report_growth(grown: dict[str, str], baseline: dict[str, str]) ->
None:
242 """Print the failure report for newly-appeared debt.
245 grown: The {app: verdict} debt absent from the baseline.
246 baseline: The recorded baseline, used to explain a changed verdict.
249 f
"\nmatrix_ratchet.py: FAIL -- {len(grown)} example(s) newly failing in ra8_emulator.\n\n"
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")
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"
267def check(report_file: Path = REPORT_FILE, baseline_file: Path = BASELINE_FILE) -> int:
268 """Run the ratchet against the committed baseline.
271 report_file: The matrix.sh report to read.
272 baseline_file: The committed baseline to compare against.
275 A process exit status: 0 when debt did not grow, 1 when it did.
277 if not report_file.exists():
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"
285 verdicts = parse_report(report_file.read_text(encoding=
"utf-8"))
288 "matrix_ratchet.py: FATAL -- the report is empty; the sweep did not run.\n"
291 debt = debt_of(verdicts)
292 baseline = load_baseline(baseline_file)
296 print(f
"ra8_emulator matrix: {len(verdicts)} example(s) -- {summarise(verdicts)}")
297 print(f
"ra8_emulator matrix: failing {len(debt)}, baseline {len(baseline)}")
299 grown = {app: v
for app, v
in debt.items()
if baseline.get(app) != v}
301 report_growth(grown, baseline)
303 fixed = {app: v
for app, v
in baseline.items()
if debt.get(app) != v}
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')}")
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.)"
313 print(
"ra8_emulator matrix: OK -- the failing-example count did not grow.")
317def update(report_file: Path = REPORT_FILE, baseline_file: Path = BASELINE_FILE) -> int:
318 """Rewrite the baseline from the current report.
321 report_file: The matrix.sh report to read.
322 baseline_file: The baseline file to rewrite.
325 A process exit status: 0 on success, 1 when the report is missing.
327 if not report_file.exists():
328 sys.stderr.write(f
"matrix_ratchet.py: FATAL -- no report at {report_file}.\n")
330 debt = debt_of(parse_report(report_file.read_text(encoding=
"utf-8")))
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).")
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."
347def _selftest_classification() -> list[str]:
348 """Assert every verdict is classified, and classified the right way.
351 A list of failure descriptions, empty when the classification holds.
353 failures: list[str] = []
356 unclassified = KNOWN_VERDICTS - (DEBT_VERDICTS | PASS_VERDICTS | NOT_RUN_VERDICTS)
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"),
364 failures.append(f
"{names} classes overlap on {sorted(overlap)}")
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)}")
376def _selftest_ratchet(tmp: Path) -> list[str]:
377 """Assert the ratchet fires on growth and stays quiet on shrinkage.
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
385 tmp: A scratch directory for the fixture report and baseline.
388 A list of failure descriptions, empty when both directions hold.
390 failures: list[str] = []
391 baseline = tmp /
"baseline.txt"
392 report = tmp /
"report.txt"
393 write_baseline(baseline, {
"broken_app":
"FAULT"})
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")
402 "good_app FAULT\nbroken_app FAULT\n",
404 if check(report, baseline) == 0:
405 failures.append(
"a NEWLY-FAULTING example did not fail the ratchet")
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")
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")
418 if check(tmp /
"absent.txt", baseline) == 0:
419 failures.append(
"a MISSING report passed instead of failing loudly")
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")
437def selftest() -> int:
438 """Assert the gate fires in both directions before it is trusted.
441 A process exit status: 0 when every assertion holds, 1 otherwise.
445 failures = _selftest_classification()
446 with tempfile.TemporaryDirectory()
as td:
447 failures.extend(_selftest_ratchet(Path(td)))
449 sys.stderr.write(
"matrix_ratchet.py --selftest: FAILED\n")
450 for line
in failures:
451 sys.stderr.write(f
" {line}\n")
453 print(
"matrix_ratchet.py --selftest: OK (fires on growth, quiet on shrinkage)")
458 """Parse the mode argument and dispatch.
461 A process exit status.
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()
476if __name__ ==
"__main__":
void main(void)
The application entry point Reset_Handler hands control to.