Release readiness¶
This page owns one question: may the current release be cut?
CLAUDE.mdnames it the owner of release exit criteria, so it carries the release being worked toward at the top and the closed record below. Do not start a second readiness page — one question, one owner.
Current: v0.4.0 — scope decided, criteria in draft¶
Status 2026-08-22. Nothing in the tree defined what completes
v0.4.0—docs/roadmap.md:122calls !443 "first work toward v0.4.0" and never says what finishes it, and every row in the Closed section below isv0.3.0's. A release with no written definition of done is how the two amber W1 rows nearly shipped as green.The scope is now decided:
v0.4.0ships the fixes for all twelve items of the 2026-08-22 project critique, plus the release-mechanics rows below. What is still draft is the wording and thresholds of the individual criteria — states read❓ PROPOSEDuntil the maintainer agrees each row, so nothing here can be misread as met. Four rows read🟡 PART DONEagainst work that has landed and one reads✅ MET(EC-C7); the count said three and was already stale beforev0.3.3.Expected size: ~3 months, dominated by two multi-week refactors that can run in parallel. See the cost note under the critique table.
v0.3.3was cut 2026-08-24 at19e02c6eand is NOT YET PUBLISHED —release-pypiis a manual click, and this page has been wrong in that direction before. It is not a separate release page: it is the step that closes EC-C11 / EC-Q2 / EC-Q3 inside this one, and what it unblocks is EC-C9 / EC-Q4. Those three rows flip to met when the index confirms the upload, not when the job goes green.
Two naming decisions must be taken before the tag, not after.
v0.4.0is already a milestone heading.docs/changelog.md:2308carries## v0.4.0 (pre-release) — flow-physics closures, shipped long ago; milestones there now run to v0.14 while package versions run to 0.3.2, and that file's own preamble states milestone numbers are not package versions. Cutting packagev0.4.0puts two## v0.4.0headings in one changelog meaning different things. Either the release heading takes a distinguishing form (e.g.## v0.4.0 (release, tagged …)), or the package version skips to0.5.0. Renaming after the tag is worse than deciding now.- The support matrix moves here or not at all. A minor bump is the only
place a
requires-pythonchange belongs; see EC-Q4.
| # | Criterion | State | Evidence / what would close it |
|---|---|---|---|
| EC-Q1 | The changelog-heading collision above is resolved, in writing, before the tag | ✅ MET 2026-10-09 | Decided by the maintainer 2026-10-09: keep 0.4.0. The release is told apart by its heading, v0.4.0 (minor, tagged 2026-10-10) — …, against the milestone's v0.4.0 (pre-release) — …; the decision is written in docs/changelog.md's preamble, and the root CHANGELOG.md link points at the release heading's anchor. Original text: Nothing yet. A decision recorded in this file and in docs/changelog.md's preamble. Cheap now, irreversible after the tag |
| EC-Q2 | The shipped wheel is not behind main on user-visible behaviour |
✅ MET | Closed again for v0.4.0 2026-10-10: published 07:32 UTC from tag pipeline 2932837691 (release-pypi job 17076975201, release-pypi-test 17076975202), verified against the index rather than the job: https://pypi.org/pypi/aeolus1d/json reports info.version 0.4.0, requires_python >=3.10, Development Status :: 4 - Beta, wheel and sdist, and the gui extra as pyside6<6.12,>=6.5; TestPyPI reports the same. The tag pipeline was green on all 17 automated jobs. Earlier: Closed by v0.3.3, published 2026-08-24 11:11 UTC and verified against the index, not the job: info.version is 0.3.3 on PyPI and TestPyPI. The wheel was then opened and confirmed to carry the four user-visible changes the gap was about — qt_platform.py (!457 Wayland), the icon set's four modules (!461), shell/form_factory.py (!463) and cli/doctor.py — 624 files, 85 doc pages, 15 tutorials. This row re-opens as main moves; it is restated for v0.4.0 rather than inherited, which is the nature of the criterion, not a defect in it |
| EC-Q3 | The Python 3.9 removal was announced in a release that precedes it | ✅ MET | The notice shipped in v0.3.3 (published 2026-08-24): docs/changelog.md's entry, README.md's install section, and a warning admonition in docs/installation.md, each naming v0.4.0 as the removal release and asking 3.9-bound users to say so first. EC-C9 / EC-Q4 are now unblocked — a release announcing the removal precedes it, which is what review_1.md B5 demanded. Supersedes plan §4.9 step 3(a)-(b), which had v0.3.3 ship unchanged and the announcement land in a release after it; that reading needs a v0.3.4 between here and the floor move. .loop-review/ is historical and not edited — the supersession is recorded here, which owns release exit criteria. The runtime DeprecationWarning of plan 3(b) is still not shipped: this row's bar is the release note, and a patch is the wrong place to start emitting warnings into users' runs |
| EC-Q4 | requires-python moved to >=3.10, and everything it was blocking is cleaned up in the same commit |
✅ MET | Landed 2026-08-30. All three named surfaces went together, as the row required: the unit-py39 job (9.5 min off every pipeline, measured on pipeline 2781797137), requirements-ci-py39.lock, and UP007/UP045 — io/schema.py is now written X | None. The row's list was not complete, which is the finding worth carrying: it named three surfaces and there were seven. Also removed were the lock-verify-py39 job, the :py39 Docker image row (Dockerfile, ci/build-image.sh), the --py39 arm of ci/verify_locks.sh, and the 3.9 row of ci/refresh_ci_locks.sh. A criterion written as a checklist of known surfaces is a lower bar than the same criterion written as a property — the property here is grep -rn py39 returns only past-tense prose, and that is what was actually verified. Two side effects recorded rather than absorbed: ruff's target-version = "py310" woke B905 at 135 pre-existing call sites (ignored with a reason, filed in BACKLOG.md, not auto-fixed to strict=False), and the deleted ruff-ignore comment's claim that "every module carries from __future__ import annotations" was false for three of the four files it governed |
| EC-Q5 | Development Status classifier reflects reality |
✅ MET 2026-10-09 — maintainer decision | pyproject.toml now says 4 - Beta, and docs/scope-and-limitations.md:12 moved with it; the grep below finds no other live claim (.loop-review/ is historical and not edited). The annotation below ties the move to EC-Q12, which is MET with four experimental references, so the move does not rest on the case count this row first offered. Original text: pyproject.toml:31 still says 3 - Alpha beside 229 shipped example cases and five PyPI releases. 4 - Beta belongs in a minor bump, not a patch — and this row therefore supersedes plan §4.11 step 4, which put the classifier change inside v0.3.3. It was deliberately left out of that release. When it moves, the prose that asserts it moves too (docs/scope-and-limitations.md:12), after grep -rn "3 - Alpha\|Development Status" --include='*.md' --include='*.toml' --include='*.cff' . . Annotated 2026-08-29 (maturity sweep, below). The evidence this row offers for 4 - Beta — 229 example cases and five PyPI releases — measures throughput, not fitness; a tool can ship a great many cases and remain alpha on the only axis a user cares about. The sweep proposes the classifier move on EC-Q12, not on case count: 4 - Beta beside one experiment-referenced workflow out of five would be the same overclaim as the two amber W1 rows that nearly shipped green, and this page exists because of those. If EC-Q12 is cut, this row should be cut with it rather than closed on the weaker evidence |
| EC-Q6 | The two amber W1 rows are re-waived explicitly, not inherited | ✅ MET — waiver written 2026-08-30 | docs/release.md carries a dated v0.4.0 waiver section beside the v0.3.0 one, and the two rows are re-argued rather than carried: the KTM's terms changed since the v0.3.0 waiver and the waiver text now says how. It is 7/10, not the 6/10 that waiver quotes (re-measured 2026-08-30), and its reference is Ricardo WAVE's own OEM baseline, not the chassis dyno — so it is code-to-code and the phrase "the quantitative miss" was measuring against another simulator all along. Neither change makes the waiver weaker: the blocker is still unpublished OEM cam geometry, waiting still does not converge, and the row would be amber at v0.5.0 too. What must not happen — the waiver carrying forward silently — is what this row exists to prevent, and re-deriving it is what turned up both corrections |
| EC-Q7 | Every v0.3.0 criterion below still holds against the v0.4.0 tree |
✅ MET 2026-10-10 — re-run, not inherited | Re-run on the release tree 472afaef (host precision, local .venv: Python 3.10.21, numba 0.67.0, llvmlite 0.49.0, numpy 2.2.6; flags anchor_bcs, fastmath, per_cell_gamma; n_cycles_max 40, settle_tol 0.002). Solver behaviour did move, so all four were re-run. EC-2 holds: the golden epw_diesel_engine row is in tests/regression/, and regression and regression-bitid are green in main's push pipeline 2931490628 at 472afaef. EC-3 re-measured: run_validation.py --anchor-bcs --ops 1 --mesh-scale {1.0,1.5,2.25} gives T_EXH 427.311 / 427.264 / 427.246 °C, monotone; observed order p = 2.28, Richardson value 427.233 °C, u_num = ±0.015 °C (GCI_fine × f, Fs 1.25), asymptotic ratio 0.99996 (in range). The old p 1.286 / 458.312 °C / ±0.035 °C belong to the old model. EC-5 re-measured: run_validation.py --anchor-bcs --network-audit (3 ops) closes CV-B at 0.001 / 0.001 / 0.001 % of fuel against 3 %. That is far below the old 1.302 / 0.808 / 0.701 %, and the fall has not been bisected to one commit. What rules out a closure by cancellation is the audit's own split: cylinders / rest-of-network are +0.650 / −0.649, −0.713 / +0.713 and −0.180 / +0.181 %, each half under the 3 % threshold on its own. EC-8 checked by hand against these runs: the documents carrying the superseded numbers (EC-1b, EC-3, EC-5 and the GCI section on this page, and docs/validation-dossier.md) are annotated with the new ones. The anchored T_EXH errors moved too, from −15.18 / −47.09 / −43.09 K (2026-09-07) to −10.53 / −35.95 / −29.29 K, so the anchored gate (40 K) now passes at all three anchors; that move is not bisected either. The blind-op T_EXH row (BACKLOG.md, predictive mode) was not re-measured. Logs: /mnt/data/aeolus-scratch/release/ on precision (untracked). Original text: EC-1…EC-12 were verified against a tree with no solver change since. v0.4.0 may move solver behaviour; if it does, EC-2 (golden pin), EC-3 (u_num), EC-5 (CV-B) and EC-8 (no document contradicts the code) must be re-run, not inherited. State which |
| EC-Q8 | The perf gates ran on a verified-idle host before the tag | ✅ MET for v0.4.0 (pre-tag), and for v0.3.3 |
v0.4.0, 2026-10-09: the nightly schedule was played by hand on main at 472afaef (pipeline 2931803152), so both gates ran on the bench host's own runner (aeolus-wsl2-docker, host RD0207315) through the CI image rather than a local .venv. perf-budget (job 17069931820, 91 s) and wallclock-budget (job 17069931821, 1279 s, AEOLUS_BENCH_HOST set) both green. From the jobs' own perf-measurements.jsonl: profile_total 50.46 s of an 80 s ceiling (ref 44.25 s), fullcase_wall 230.3 s of 384 s; loadavg 0.69 at the first record, PSI cpu 0, steal 0, Python 3.10.20, numba 0.67.0. Green, but not inside the band: perf_nightly_history.py --compare puts the run at 1.087× the pinned nightly median, with c02006's wall above its 194.8–217.6 s band. The nightly series shows a step, not drift: ~203 s through 2026-10-02 (63ca76df), then 224.2 / 224.7 / 230.3 s from 2026-10-08 (eb6f1c10) after five days with no nightly. Every micro-kernel stepped the same night by 8–15 % (U_to_W 1089 → 1251 ns, the 3×3 Thomas solve 18.1 → 20.8 µs) though git diff 63ca76df..eb6f1c10 changes only comments in their files, and the CI image digest (sha256:6f4d3c20…, built 2026-05-28), Python and numba are the same on both sides. That points at the host, not the code; it is not confirmed by a same-host A/B. The band is not rebuilt to absorb it. The tag pipeline measured again (2932837691, 2026-10-10): profile_total 49.04 s of 80 s (loadavg 0.74), fullcase_wall 231.96 s of 384 s (loadavg 1.02), 1.097× the band — the same level as the nightly, so the step held and did not grow. v0.3.3: Pre-flight, by hand 2026-08-24 06:01–06:22 UTC, zero containers, -n 0: profile_total 43.28 s, fullcase_wall 195.08 s/rep, six per-function gates green — local .venv, not the CI image (Python 3.10.12 / numba 0.67.0 / llvmlite 0.49.0 / numpy 2.2.6, host RD0207315, 16 cores, AEOLUS_BENCH_HOST=1 for the wallclock row, loadavg 0.78 → 2.12). Then the tag pipeline measured it for real — pipeline 2784430467, all 19 automated jobs green: perf-budget 61.70 s of an 80.0 s ceiling (ref 44.25 s) and wallclock-budget green at 1589 s, read from the job's own perf-measurements.jsonl artifact rather than inferred from a green job. Harness in the record: CI image, x86_64, 16 cores, Python 3.10.20, numba 0.67.0, loadavg 2.53. 18.3 s of margin where v0.3.1 had 2.2 s. ANNOTATED 2026-08-31 — that margin was measured under load, and the loadavg was in this row all along. The first nightly (pipeline 2803954760, same gate, same calibrated host, loadavg 0.74) reads 44.144 s against the 44.25 s reference — 0.24 % under it. This row's 61.70 s was taken at loadavg 2.53, 40 % above the quiet-host value, so the ceiling's real headroom is about 36 s, not 18.3. The conclusion was drawn from a number whose own harness line said it was contended. Nothing here is retracted — the run was green and the gate held — but the margin figure should not be quoted as the code's cost. n = 1 on the nightly side; see docs/perf-history.md. Retraction, same commit. An earlier draft of this row said the run "does not validate perf-budget on aeolus-x86, where the ceilings have never been measured". perf-budget is not on aeolus-x86: .gitlab-ci.yml:766 tags it aeolus-wsl-linux, the calibrated host, after a brief pin to the new box was measured wrong (:741-753); only gui-tests (:880) carries aeolus-x86. The claim came from STATUS.md prose, which nothing checks, and is corrected there too. What was still open was the recipe, not the host: CLAUDE.md's pre-tag command set no AEOLUS_PERF_LOG, so the first two pre-flight runs were green and emitted nothing — !456 put the number on the passing path and the recipe threw it away. That was EC-C10 step 1, closed 2026-08-30. Annotated rather than rewritten, because this sentence had gone stale in the interim: the AEOLUS_PERF_LOG half was fixed shortly after this row was written and nothing here said so, so a reader in between would have gone to fix a recipe that already set it. The drift that remained was different and larger — the recipe ran one of the three invocations the tag-gated jobs make. See EC-C10. |
| EC-Q9 | The coverage gate measures more of the suite than tests/unit/ |
✅ MET 2026-10-09 — maintainer decision | Decided by the maintainer 2026-10-09: gate C meets this row. EC-C4 closed 2026-10-08 with gate C floored at 86 (ci/combine_coverage.py --fail-under=86), so the reported figure covers unit, integration-tests and regression. The floor is about 10 points slack; its ratchet is open in BACKLOG.md. Annotated 2026-10-07 — the text below is the 2026-08-29 position and is partly overtaken. The gate it asks for has landed as gate C (see the EC-C4 row): a schedule-only coverage-combined job (ci/combine_coverage.py) merges the unit, integration-tests and regression data, the unit floor is --cov-fail-under=96 (.gitlab-ci.yml:855, not 62 at :630), and the GUI job has its own floor of 68. What EC-C4 says is left (gate C's own floor, due 2026-10-09) is what keeps this row from MET. Whether gate C meets EC-Q9 as worded is the maintainer's call; this row is left PROPOSED rather than closed on that reading. Original text: .gitlab-ci.yml:630 runs the gate as pytest tests/unit/ --cov=aeolus1d --cov-branch --cov-fail-under=62. The integration, validation and GUI tiers contribute nothing to it, so the 62 % floor is computed from one tier of four while ~143 k lines of test sit outside the number. This is not a claim that coverage is low — it is a claim that nobody can say what it is, which is the same defect EC-C4 found in the 5.8 pt dead band, one level up. Closes when the reported figure covers the tiers a pipeline actually runs, with the floor re-seeded from three consecutive main measurements as EC-C4 did — never refreshed to turn a red gate green. Review M2's constraint still binds: every contributor needs --cov-branch or coverage combine aborts |
| EC-Q10 | A perf regression merged to main is caught before the release tag |
✅ MET 2026-09-18 (was ❓ PROPOSED, and worse than the row stated — see below) | perf-budget (.gitlab-ci.yml) and wallclock-budget run on schedule and tag only, so the tag pipeline is the first that executes them against merged code — and it also carries release-pypi. Twice the failure there was host contention rather than regression (2026-08-08, 2026-08-12), so the cost of telling those apart lands at the worst possible moment. Distinct from EC-C10 and EC-Q8, which ask whether the signal is trustworthy when it runs; this row asks when it runs. CORRECTION: this row proposed to close itself by pinning "the nightly schedule [which] already exists" to a named baseline. There is no nightly schedule. The project's pipeline_schedules endpoint returns [], and across the last 100 pipelines (back to 2026-08-21) every one came from push or merge_request_event and none from schedule. So the if: $CI_PIPELINE_SOURCE == "schedule" rules are rules for a source nothing produces, and these two jobs have only ever run on tags — the tag pipeline is not the first thing to run them, it is the only thing. Closing it needs a schedule created first, which is GitLab project configuration and cannot be added from this repository; that is external state of the same class as the runner tags, except that an unmatched runner tag at least hangs a job while an absent schedule is silent. tests/unit/test_schedule_gated_jobs_are_external.py keeps the dependency stated and fails if the schedule-only job set changes in either direction. Sequence, now that the premise is known: create the nightly, let it accumulate green runs, then pin a baseline — the third step was the only one this row had. Step 1 landed 2026-08-30: schedule 4413025, 0 2 * * * Europe/Stockholm, ref main, first fire 2026-08-31. The runner split makes this safe for the measurement — the laptop (aeolus-wsl2-docker) has run_untagged: false, so it takes only the three aeolus-wsl-linux jobs (perf-budget, wallclock-budget, regression-bitid) while the other 14 of the 17 a scheduled pipeline starts go to aeolus-docker. regression-bitid is in test and the two perf gates in integration, so it cannot overlap them. One thing is unverified and should be checked on the first run: both perf gates share the integration stage and the same runner, so whether they serialise depends on that runner's concurrent setting in its config.toml — not visible from the API, and if they run together each measures the other's load, which is precisely the contention-read-as-regression failure this criterion is about. Step 2 began 2026-08-31: the first nightly fired and is green — 17 jobs, 72 min, and the two things that could not be verified when the schedule was created both came back favourable. The laptop was awake, and the perf gates serialised (wallclock-budget started 2 s after perf-budget finished), so neither measured the other's load; the runner split behaved exactly as predicted, with the laptop taking only its three tagged jobs. Its first data point already re-reads EC-Q8 — see that row and docs/perf-history.md. Step 2 is not done: one nightly is a data point, not a distribution, and the dead-JIT budget is the standing example of what an n = 1 baseline costs. Step 3 pins it. MET 2026-09-18 — steps 2 and 3 closed together. 19 scheduled pipelines, 2026-08-31 to 2026-09-18, one per night with no gaps; all 38 perf-job runs in them green (the single red nightly, 2845301149 on 2026-09-14, failed integration-slow, not a perf gate); all 19 from one harness. The caveat this row left open after n = 1 now has 19 answers: the two perf gates serialised on every single night, wallclock-budget starting exactly 2 s after perf-budget finished, zero overlap — so neither ever measured the other's load, and the runner's invisible concurrent setting is not a hazard here. Step 3 is pinned as perf/nightly-baseline.json, and as the band rather than the last green run: pinning one night would have rebuilt the n = 1 that step 2 exists to prevent, so what is pinned is min/median/max per series with the harness beside each number. scripts/perf_nightly_history.py builds and rebuilds it; tests/unit/test_perf_nightly_baseline.py holds it to being a distribution, over one harness, covering all three marker tiers. So a perf regression merged to main is now caught on the next night rather than at the tag, which is this row's bar. Stated plainly, because a mechanism that has never fired is not a proven mechanism: no perf regression has occurred in the window, so the catching path is exercised by construction and not by event. The gates themselves have teeth (perf-budget has failed on contention twice) |
| EC-Q11 | A run reports the calibrated constants it actually reached, with their provenance | ✅ MET | Landed 2026-08-30. src/aeolus1d/calibration_provenance.py is the registry; solver/startup_banner.py prints a calibrated constants reached: block after the settings block, listing only groups the run constructed — a case with no aftertreatment is told nothing, which is what keeps it readable. Each row names which constants the case set and which it inherited, read from the function signature or dataclass field rather than a second copy of the value. The mirrored discipline is the substance: declare() raises on a blank provenance exactly as load_pass_criteria raises on report_only without reason, and the error names that sibling so the fix is a sentence of provenance rather than a way around the check. Nine groups declared across five closures at landing; thirteen across six since 2026-10-11 (the two SCR storage variants scr_storage two-site and three-pathway, and the two TWC VML demos in closures/twc.py). This row's own premise was wrong in one place, corrected here: it named closures/scr_storage.py as an SCR site a user meets, and that module has no production call site at all — io/build.py wires closures/scr_kinetics.py, and scr_storage is reached only from tests/integration/. Both are declared, but a TOML case can only arrive at the latter. The row was assembled by grepping docstrings for calibrated, which finds text and not reachability. One marker collision, found by a test rather than by review: the first draft flagged shipped values with a trailing *, the same glyph the settings block uses for non-default — near-opposite meanings, and it broke test_default_value_has_no_marker depending on xdist worker assignment. The block now uses words, pinned by test_the_calibrated_block_never_uses_the_settings_marker. Scope held: no constant was re-derived or changed |
| EC-Q12 | At least two workflows carry an experimental reference | ✅ MET — and it already was when the criterion was written (2026-08-30) | Four references qualify, three of them obtainable by a third party. mendeley_ethanol_water (CC BY, DOI 10.17632/yvb7khhbrj.1); the DD13 closed-cycle suite (U-Michigan Deep Blue, stable handle, 9/9 on the predictive path); heliyon_twc_co_oxidation (open access CC BY, Heliyon 10 (2024) e25988 — measured Table-11 CO-step OSC durations on a synthetic gas bench, every number quoted from tables and text rather than digitised, predictive across two of three ageing states since only the fresh capacity is calibrated and mild/severe follow the paper's independent Table-13 site ratio); and ornl_passive_scr at trend level. W4's gt_acoustics_tl is a fifth but its data is proprietary, so it does not count toward obtainability. The interesting part is why this row said otherwise. Its body asked for "an open synthetic-gas-bench DOC or SCR set" as future work — while heliyon_twc_co_oxidation had been in the tree, CI-gated and passing, the whole time. The row was written from docs/validation-dossier.md's W1-W5 table, and these four catalyst suites live in prose below it under a heading that called them all "code-to-literature". Two of them are not: they compare against measurements. The same summary drove docs/scope-and-limitations.md to state "Not validated against lab / synthetic-gas-bench data", which had been false since the suite landed. Both are corrected, with per-suite reference types now stated on each entry. This is the third time a claim in this repository has been written from a summary table rather than the tree (cf. Phase 27c priced at 6-8 weeks while shipped, and the DD13 suite recorded as awaiting data while complete). What genuinely remains is the coupled DOC+DPF+SCR train — W3, the locked wedge's headline claim — which still carries no experimental reference of any kind, and is a larger thing than this row asked for |
Maturity sweep — 2026-08-29¶
Why this sits on this page. v0.4.0's criteria come from the 2026-08-22
critique, which was about engineering debt. A sweep of the whole tree on
2026-08-29 says engineering debt is not what caps this project — so a release
that fixes only the twelve critique items ships a better-built tool with an
unchanged credibility ceiling. This section records the sweep. EC-Q9…EC-Q12
above are what it proposes to do about it; everything here that is a defect
rather than a criterion went to BACKLOG.md, which owns those.
Corrected 2026-08-29, same day it was written. This section originally
carried its own nine-row table of dimension ratings. That was a second
scorecard: MATURITY.md at the repo root has scored this project across five
dimensions since 2026-07-06, is refreshed with dated lifts, and is pinned by
tests/unit/test_maturity_claims_match_code.py. Writing a rival table here is
precisely the "two documents both claim to be the source of truth" failure
CLAUDE.md forbids — and the sweep did it while quoting that rule. The table is
withdrawn. MATURITY.md owns the scored self-assessment; this section owns
only what is release-gate-shaped.
The withdrawal costs nothing, because MATURITY.md had already reached the same
verdict seven weeks earlier and by an independent route — Validation 3.5 / 5
against 4.5 for all four other dimensions, described there as "the credibility
bottleneck". The sweep is corroboration, not discovery, and should have said so.
The one-line verdict: the engineering is beta-to-production, the science is
alpha, and the gap between them is the whole story. Everything scored 4.5 in
MATURITY.md is infrastructure for a claim that does not yet exist at
strength — which is why the one dimension a user actually cares about sits a
full point below the other four.
The defining gap is already written down — docs/validation-dossier.md's
reference-type table, and the blind-predictable/structurally-blocked split in
docs/scope-and-limitations.md. Neither is restated here; what is new is the
ranking. Four of five workflows never touch measured data, the fifth is
mid-recalibration, and the locked wedge's headline claim — the coupled
DOC + DPF + SCR train — is a named v0.3.0 gate that is still open.
What the sweep found that was not already recorded, and where each went:
- The coverage gate reads one tier of four → EC-Q9.
- Perf gates never run on merged code before the tag → EC-Q10.
(No longer true, and kept as the finding it was: a nightly schedule
was created 2026-08-30 and EC-Q10 was met 2026-09-18. The gates now
run against merged
mainevery night.) - Calibration provenance is prose in docstrings, while the machinery to report it already exists and the sibling discipline (a reason required for an ungated result) is enforced in code → EC-Q11.
- Only W1 is experiment-referenced → EC-Q12, and it is the reason EC-Q5's classifier bar is annotated above.
Corrected in the same sweep. A draft of finding 3 asserted that "the code
does not label calibrated constants". It does — extensively, in docstrings,
plus one build-time warning at io/build.py:2891 — and the claim was
withdrawn before it reached this page after grep -rn calibrated src/ was
actually run. The surviving, narrower finding is that the labelling is not
aggregated and never reaches the user. Recorded because the wrong version
was the more quotable one, and this page's job is to be right rather than
quotable.
What does not raise the level, stated so effort does not drift there. More closures: the Cruise-M cross-check already concluded the physics gap to a commercial tool is small. More example cases: 232 is not the binding constraint on trust. More documents: at ~11.3 k lines across five position files, EC-C5 already names documentation cost as a critique item, and this section is written short for that reason.
The twelve critique items — v0.4.0's content¶
Scope decided by the maintainer 2026-08-22: v0.4.0 ships the fixes for
all twelve items of the 2026-08-22 project critique. An earlier draft of
this page argued the engineering tranches should not gate a release. That
was raised and overruled; this is the decision, and the rows below are the
release. Recorded rather than quietly dropped, because a reader a year from
now should see that the trade was considered.
What that costs, stated once so it is not a surprise. Items 2 and 6 are
multi-week refactors (5–8 and 4–6 weeks, parallelisable). Everything else
totals roughly 15–16 engineering days. v0.4.0 is therefore a ~3-month
release, not a few weeks. If that is wrong, the lever is moving items 2 and
6 to v0.5.0 — nothing else in the list is large.
Effort figures are plan_v3.md's, which survived three adversarial review
rounds. Items 8 and 3 shrank during review; item 1 is scoped below.
| # | Critique item | Effort | State | What closes it |
|---|---|---|---|---|
| EC-C5 | Documentation cost exceeds the product | 0.5 d done + 2.5 d | ✅ MET 2026-09-28 (was 🟡 PART DONE) | make status / make status-check landed with span-only substitution and shared regexes (scripts/status_position.py, scripts/_status_regex.py). The docs-only CI fast path is UNCUT and landed 2026-08-24 — the earlier CUT was correct about rules:changes (OR-over-globs, so **/*.md fires on any MR touching one Markdown file) but that killed one encoding, not the item; review B1's own "Resolves when" named a diff-computing pre-job as acceptable and it was never written. ci/changed_scope.py asks whether every changed path is documentation, so an unlisted path forces a full run — the failure signal B1 demanded, and the inverse of the positive-whitelist design it also rejected. A stood-down job exits green with a banner, never GitLab-skipped, so it cannot be mistaken for "not needed"; unit narrows to the doc guards rather than standing down, because it owns test_status_currency. Pinned by tests/unit/test_ci_docs_fast_path.py. Remaining: the rest of §4.5 MET 2026-09-28: the rest of §4.5 is §4.5.2, and it landed. tests/unit/test_backlog_sections.py: every BACKLOG.md section holds an open item (- [ ], or a partial - [~]) or declares itself <!-- backlog: retraction --> / <!-- backlog: narrative --> on the line after its heading — the inverted rule, so a retraction record is protected rather than evicted. It was red on six sections when written: four open defects had been filed as prose with no checkbox (they got one), one retraction and one decision record got markers. scripts/backlog_report.py --empty lists the archival candidates; moving one stays a human call, as CLAUDE.md says. Docs-only MRs run the full unit tier, so it needs no fast-path wiring. |
| EC-C8 | CI cost and duplicate pipelines | 1 h done + 0.5 d | ✅ MET | The in-pipeline duplicate was already fixed in the tree (.gitlab-ci.yml:151-160); retraction recorded in BACKLOG.md. §4.8b's remaining half — the first-push duplicate — landed 2026-08-24. Measured before fixing: the ordinary push-then-open-MR sequence created two full pipelines because workflow:rules only drops the branch pipeline once an MR exists; on !464, one edited Markdown file, that was 50.1 + 59.3 = 109 runner-minutes, and 13 pipelines ran that day for 4 MRs. Closed deterministically — a branch push with no open MR now creates nothing — rather than by §4.8b's push.pushOption merge_request.create, whose own text records the create-before-evaluate ordering as "not documented and not verified". Cost: no CI on a branch until its MR exists, which is free here because main is protected. Pinned by test_ci_docs_fast_path.py::test_branch_push_without_an_open_mr_creates_no_pipeline |
| EC-C3 | 43 functions over 200 lines | 0.5 d done + 3–4 d | ✅ MET 2026-09-28 (was 🟡 PART DONE) | Ratchet landed at 43 / 177, pinned to its own harness with a non-slack assertion, and is at 39 / 174 as of 2026-08-31 — moved four times, every time by item 6 rather than by work aimed at this row, which is what "delivered by items 2 and 6" predicted. The non-slack assertion has now twice REFUSED an intermediate state that looked like progress: save_case in step 1 and RunnerDock.__init__ in step 5, both of which had been split into children that were themselves over 100 lines. That is the row's real product — not the number, but a gate that can tell a decomposition from a relocation. Updated 2026-09-18 — the first cut aimed AT this row rather than arriving from item 6: 39 / 173 → 36 / 170, three functions, both counts moving by three because every child came out under 100. SettingsDialog.__init__ 225 → 31 + five page builders; NetworkViewDock.__init__ 205 → 33 + five builders/initialisers; NetworkViewDock._compute_chain_packed_positions 239 → 27 + four passes. The packer is the one that mattered: it had no test of its own, because all three existing test_network_view_* modules need a working OpenGL context and skip on a headless runner — so equivalence against the pre-split function was measured before the split was trusted, and that case is now aeolus1d_gui/tests/test_network_view_chain_packer.py (Qt-free, 9 tests, four mutations of the new seams each caught). Second cut, same day: 36 / 170 → 32 / 166 — linearize_network 264 → 196 (and _linearize_network_analytic 120 → 83 with it, which is what moved over-100), _update_two_zone_diagnostics 273 → 93, solve_modes_network 252 → 66, and _build_controllers 271 → 62, the first cut into io/build.py and the same dispatch-table move save_case already made for its eleven-branch BC chain. These three are numerical, so a pure extraction fails silently — nothing raises, the matrix is just slightly different — and each was therefore hashed against the pre-split code before it was trusted: 4, 10 and 8 configurations respectively, byte-identical every time. The second cut removed duplication rather than moving lines: one probe-read comprehension that existed in ten copies, one input-Jacobian loop written out twice, one DOF slice three loops re-derived. And mutation testing found a gap it had not created: the two-zone split's caloric (P2) branch could be deleted entirely with the suite still green, because test_caloric_zone_split.py guards the evaluator and the source text, both of which survive the branch going dead — tests/unit/test_two_zone_zone_temperatures.py now asserts the split uses what that module measures. Third cut 2026-09-19: 32 / 166 → 31 / 165 — dispatch_advance 404 → 98 plus one function per driver. It is the single point of routing for the CLI, the GUI and the library, and splitting it found that nothing tested where it routed: swapping the quasi-steady and Strang arms passed the entire quasi-steady suite, because those tests call the drivers directly and none goes through the router. tests/unit/test_dispatch_advance_routing.py (13 tests) now covers every arm, and six mutations of the new seams each fail it. MAX_UNREVIEWED 3402 → 3393 across the three cuts, since changing a legacy function is what triggers its sweep. Fourth cut 2026-09-19: 31 / 165 → 30 / 164 — _build_cylinders 460 → 71 plus a _CylinderCombustion record and thirteen helpers, the second cut into io/build.py. The four combustion arms now sit behind a _COMBUSTION_BUILDERS table whose keys are the accepted kind set, so the unknown-kind error can no longer name a different list from the one the code honours. Equivalence was measured before the split was trusted, as on every cut since the packer: 78 built cases — the 11 bundled examples under validate on and off, 27 synthetic TOMLs, and 8 Python-mutated cases for the guards validate_case rejects first — hashing identically across every cylinder attribute, every raise and every warning, with the harness first checked against known-different cases so that a blind comparator could not pass for agreement. And the sweep found the defect class again, twice. grep -rl INERT tests/ returned nothing: the warning that says two_zone = true is inert alongside a predictive combustion model — the condition under which the NOx post-processor silently substitutes bulk temperature for burnt-zone temperature — had never been checked by any test, and silencing it can only ever make fewer warnings fire, so no existing test could have caught it. Worse, the cylinder valve-conservation guard was branching on its own private copy of the implicit-rk set, one of five copies in the tree; a sixth implicit scheme would have switched that guard off in silence. The canonical set is now schema.IMPLICIT_RK_KINDS and tests/unit/test_build_cylinders_seams.py (38 tests) pins it as a partition — every accepted rk implicit or explicit, never both or neither — which is the half no mechanical check on the Literal alone can see. Four further silent fallbacks were filed rather than changed, since each would alter behaviour. Fifth cut 2026-09-19: 30 / 164 → 29 / 164 — scaffold_single_cylinder_engine 225 → 180 plus _scaffold_injection_schedule and _scaffold_far_bcs. The over-100 count does not move and deliberately so: the parent is 180 and chasing it under 100 would mean splitting a linear construction sequence at arbitrary points, which the ratchet exists to refuse. Both extracted functions hold a guard nothing tested — the cyclic-span refusal on an injection duration that wraps past the full cycle, and the latent-heat-without-injected-mass warning — and extracting the warning moved what stacklevel counts, so it is now 3 and a test asserts the warning is attributed to the caller's file rather than to aeolus1d. Mutation testing caught two of the new tests being too weak to detect the thing they were named for: test_inlet_pressure_is_converted_from_bar asserted only that the boundary condition changed, which stays true with the * 1.0e5 deleted; it now pins the magnitude against a hand-built make_stagnation_inlet_bc. Sixth cut 2026-09-19: 29 / 164 → 28 / 164 — probe_core_zone_partition 275 → 126 plus five helpers. This is the largest instance of the recurring defect class this row keeps finding: the function produces the G1 verdict for the AVL EPW diesel validation harness, and grep -rln 'probe_core_zone_partition(' tests/ returned nothing. The two existing test modules on the file are structural — an import allowlist, a ban on solver-shaped function names, one molar mass — and never call it, so all sixteen mutations of its arithmetic would have passed. tests/unit/test_core_zone_probe_partition.py (35 tests) now covers it and all sixteen are caught; one of them, swapping the two surround element paths, survived the first thirty tests because a case that closes makes the two paths equal — agreement being the whole point of G1 — and is only detectable by perturbing a species at a late sample, which the direct path must follow and the analytic path must not. Equivalence was measured before the split was trusted, as on every cut since the packer: 35 configurations, digest ec8149cf, identical. And the probe's own default fails its own verdict: phi_core=None selects the binding endpoint, where the surround is exactly zero, and two of the five G1 conditions check the sign against a bare 0.0 while the other three take a caller-supplied tolerance — so min_n_S comes out at −1.2e-17 and the report says the element bookkeeping does not close about a case that closes to machine precision. Filed, not fixed: choosing a tolerance silently is the move the "never refresh a reference number" rule forbids. MAX_UNREVIEWED 3392 → 3390 across these two cuts. Seventh cut 2026-09-19: 28 / 164 → 27 / 163, the first in the arc to move BOTH counts — _surface_untranslated_elements 289 → 65 plus four helpers (31/32/33/13) and four module-level tables. It moves the over-100 count because the bulk of those 289 lines was a dict literal of message text carrying no logic; lifting it to _UNTRANSLATED_MESSAGES moved real weight rather than relocating control flow. That table's keys are now the accepted type set, replacing three hand-synced copies of the same nine types (an if t in (...) tuple, the message map, and the silencing set) with nothing checking they agreed — the same fix IMPLICIT_RK_KINDS got in the fourth cut. The defect class appears again, and at its widest yet: this function IS the audit trail, the only thing that tells a user which objects in their .proj file did not make it into the Case, and nothing asserted it still speaks. The gap is structural rather than accidental — silencing a note produces fewer warnings, so every existing Cruise M test would stay green with the entire function deleted. tests/unit/test_cruise_m_untranslated_audit.py (49 tests, synthetic XML because no .proj is tracked and a fixture-based test would skip in CI reporting green) now asserts the notes ARE emitted; 18 mutations, all caught after two survivors were traced to weak tests of mine — one of them a substring trap, assert "enabled" in msg staying true when the code printed disabled (inflow_pressure_drop=false). Equivalence measured before the split was trusted: 2070 probes, 143 real Cruise M projects × 10 flag combinations plus 8 synthetic trees × 80, byte-identical warning lists, digest 1bea444c. The harness was checked against known-different inputs first and was wrong three times before it was right — a .proj is a ZIP holding project.xml, so the first baseline recorded a parse failure for all 143 and would have "agreed" with anything. Two defects filed, the larger being that the importer honours one AUCI transfer correlation per project while 24 of those 143 carry several, and reports the dropped ones as "no aeolus1d kernel" for a correlation it does have a kernel for. MAX_UNREVIEWED 3390 → 3389. Eighth cut 2026-09-20: 27 / 163 → 26 / 162, both counts again — _translate_boundaries 275 → 72 plus five helpers (42/7/66/62/70). It is the last pass an imported Cruise M case goes through: it decides what every boundary condition is and which species sits in each pipe's primary slot, and grep -rln '_translate_boundaries' tests/ returned nothing — covered only end to end through read(), where a wrong species order still imports and still runs, because closure kernels read Y[0] positionally and never by name. tests/unit/test_cruise_m_boundary_translation.py (37 tests, hand-built Case objects since no .proj is tracked) now covers it; 23 mutations, all caught. The measurement is the finding this time. Equivalence over all 143 Cruise M projects first reported 14 of them differing — and none of it was the split: node_roles is a set comprehension (_core.py:530) iterated directly to append junctions (:947), so CPython's per-process hash seed decides junction order, and io/build.py:2592 consumes case.junctions unsorted. Re-measured under PYTHONHASHSEED=0, where the baseline reproduces exactly, the split is identical across all 143 (digest b69b715f). An ast sweep of every set in the importer found this is the only live site — flow_gas_roles looks identical but its result is used only for a membership test. The class was fixed here once before (the Cruise-R note in CLAUDE.md) at one site, and never swept. Filed rather than fixed, with the ordering measurement in BACKLOG.md, because the one-line fix reorders junctions for every multi-junction case and the solver's order-sensitivity has to be measured first. Two smaller findings: an inlet emitted with a Y_t longer than its Y_v (the one line of four that carried no guard — preserved through the split and pinned by a test that says it pins a defect), and a dead statement that computed _pick_primary_species and discarded it. MAX_UNREVIEWED 3389 → 3388. Ninth cut 2026-09-20: 26 / 162 → 25 / 162 — _advance_single_pipe_implicit 282 → 169 plus _resolve_ghost_fill (28), _adaptive_modes (41), _select_step_dt (43) and _error_controlled_step (80). Over-100 deliberately does not move: the parent is 169 lines of which 121 are code, so it clears 100 with every comment stripped, and the only arm left would need a seventeen-parameter helper to buy nothing on either count — the same call recorded for the scaffold and core-zone cuts. This is the first cut in the arc on a function that was well covered indirectly (15 and 14 modules drive its wrappers) rather than not covered at all, which is a more slippery gap: a numerical driver fails silently, so a suite that checks "did it run and is the answer roughly right" stays green while the march lands somewhere else. Equivalence over 135 configurations — three stencils via the public wrappers, cfl and dt_fixed, adaptive off / Newton-heuristic / step-doubling, first-order and MUSCL, three initial states including a Sod shock — hashing the full final pipe.U at float precision, the returned t, every on_step time, the final clock.t and the exact text of every raise: digest 1031f847, identical. 25 mutations, all 25 caught — but only after three rounds. Nine survived the first pass; six were genuine weak tests of mine (asserting the accept flag rather than which state was kept; atol=rtol=1e-30 giving err 3.9e28, so a threshold moved to 1e12 still rejected — replaced by a straddling pair at err 0.83 / 1.03); one was not a gap at all (test_temporal_order.py catches it, my fast set was too narrow); and three were reachable only with a stub stencil, because they are contracts about bookkeeping — whose state is left behind, which Δt rule produced a number, what history reaches the next step — not about physics. A sweep of 168 physical configurations could not reach one of them; imposing the sequence directly on a stub does. A documentation defect was found and fixed: the driver's docstring claimed the IE wrapper takes no dt_fixed, which inspect.signature contradicts for all three wrappers — and the split's own harness had skipped 18 configurations on the strength of it, which is why the sweep contract's "read the call sites" step exists. MAX_UNREVIEWED 3388 → 3387. Tenth cut 2026-09-20: 25 / 162 → 24 / 161, both counts — loop_flow_core 258 → 88 plus seven helpers (35/50/21/23/63/18/28). Equivalence over 168 network configurations, digest 87b36660, identical. The blindness check found a hole in the measurement before the split was trusted: probing the continuity guard changed nothing across the first 156 configurations, because every network built was well-posed — one of the function's two raise paths sat outside the measurement entirely until a rank-deficient case (a junction fed only by mass-flow pipes, so its incidence row is all zeros while its injection is not) was added. This is the third harness in the arc that looked healthy and was not, and all three were found the same way: by breaking the code deliberately and checking the measurement noticed. tests/unit/test_loop_flow_seams.py (31 tests) covers seams the ten existing end-to-end tests never touched — which pipes become graph edges versus injections, what the topology cache reuses, and the failure channel, which is a negative n_iter rather than an exception and so is invisible to any test on a well-posed network. 25 mutations, all caught, after four survivors were traced to paths no real porous network reaches (the drag slope never hits zero, the head loss never falls below the pressure floor, the line search never backtracks); _loop_newton takes its head-loss law as a callable, so a synthetic two-edge loop reaches all three, and each of those tests asserts its own premise still holds. A defect was found and filed: the reachability check is gated on the edge count, so a network with no pressure-driven pipe at all skips it and returns every junction at the pre-filled reference pressure — the guard's own comment describes that exact failure. MAX_UNREVIEWED 3387 → 3386. Eleventh cut 2026-09-20: 24 / 161 → 23 / 160, both counts — _assign_role_names 380 → 44 plus nine helpers (23/50/6/40/12/43/65/39/10) and two keyword tables lifted to module scope, which is what moves the over-100 count: as with _surface_untranslated_elements, the bulk of those lines was table, not control flow. Equivalence over all 143 Cruise M projects under PYTHONHASHSEED=0, digest aa7ec881, identical. This cut's finding is about the measurement rather than the code, and it is the sharpest instance yet. Five deliberate breakages of the pre-split function, whole corpus re-measured after each: four of the five moved 0 / 143 projects. A digest cannot distinguish "equivalent" from "never executed", so the decision points were counted instead — 29953 tree-role reservations, 5437 best_match calls of which only 54 ever match, 40 boundary candidates collected, and zero executions of both the Cruise-F filter fallback and the Cruise-I / Cruise-R+ boundary attach. Three of the function's five passes never run on any bundled project, including the exact cases their comments name (C02010, C02011..C02029, C06015); the Element-tree walk appears to have taken over their job. Filed in BACKLOG.md rather than deleted — 143 AVL demo projects is not the set of projects users have — with the counts, the harness, and what must be true before any of it goes. Until then tests/unit/test_cruise_m_role_names.py (70 tests, hand-built elements rather than .proj fixtures, which would skip in CI reporting green) is the only guard those three passes have. 40 mutations: 35 caught first pass; four survivors were each a real gap in tests of mine (no empty flow name; every subsystem test wiring the boundary as role1 when Cruise M writes edges either way; no two boundaries competing for one name; no end-to-end drive of the filter fallback), all four now dead. The fifth is genuinely equivalent — _attach_boundary_roles is the last pass and nothing reads used after it, so that pass's bookkeeping is dead — and is recorded in its docstring rather than tested around. Two duplications removed on the way: flow_gas_names and flow_gas_roles were the same set computed twice, and the subsystem loop carried a tuple element nothing read. MAX_UNREVIEWED 3386 → 3385. Twelfth cut 2026-09-20: 23 / 160 → 22 / 159, both counts — _qs_surface_chem_step 368 → 95 plus twelve helpers and one _QsChemLayout record. The record is the honest abstraction: how a pipe's kinetic state packs into the ODE vector was decided across three interleaved blocks and re-derived at each use, and gathering it removed a predicate computed twice under two names. Equivalence over 54 configurations, digest 1020a52c, byte-identical. This is the cut where the blindness check stopped being a habit and became structural. The function's three rhs arms are interchangeable by design — the @njit fused arm is bit-equivalent to the Python one (what test_qs_chem_fused_rhs pins at atol=0) and the reduced arm solves a narrower ODE for the same answer — so a digest over the answer cannot see a mis-dispatch at all. Four of the first five probes moved 0 of 44 cases. The harness was rebuilt to record which arm ran, how wide the ODE was and what band the solve used; all eight probes then moved. That was not paranoia: of 44 mutations, the one routing the fused arm into the general one survived the entire suite, because test_qs_chem_fused_rhs compares fused-on against fused-off and that comparison stays true when the fused kernel never runs — the thing it exists to check is invisible to it. Seven further mutations survived the first pass, every one a weak test of mine that a constant-output stub had hidden: a two-element permutation is its own inverse so it cannot tell a correct scatter from a transposed one; active columns [0, 1] make the loop index equal the column so either indexing passes; skipping the commit, the coverage guard or the q_rxn update left every other assertion true. All 44 are now caught by tests/unit/test_qs_chem_step_seams.py (71 tests, stub closures — the contracts here are bookkeeping, not physics) alone. MAX_UNREVIEWED 3385 → 3384. Thirteenth cut 2026-09-20: 22 / 159 → 21 / 158, both counts — chemistry_step_surface_lsoda 214 → 38 plus nine helpers and two records. It is the previous cut's twin (the explicit driver's surface-chem step, which unlike the QS one lets chemistry move gas temperature: E rides in the ODE state and the commit writes U[:, 2] back), and the interesting part is what that made possible: the duplicated extras-reorder block moved rather than being split twice. Both drivers carried a private copy of "which pipe extras column is which seam column"; chemistry_ops imports no driver, so it is the module both can import without a cycle — the same argument _COVERAGE_VIOLATION_TOL already makes there. Equivalence over 30 configurations, digest 4734609c, byte-identical, with the QS kernel's digest 1020a52c unchanged across the move. The harness records what the injected solve_ivp was handed, because two things are invisible in the answer: the band is a performance hint that changes LSODA's step sequence, and the reorder memo's key cannot be seen by any pipe whose seam never changes — which is every pipe in every bundled case, so a cache ignoring its key would be right everywhere the suite looks. 39 mutations, all caught first pass, the previous cut's weak-test lessons carrying straight over. One survivor arrived from the other cut: re-anchoring its mutation list after the move landed only_first_coverage_checked on the commit loop instead of the guard loop, and it survived — no test had ever committed a multi-slot coverage. Four tests and two more mutations later both kernels read 42/42 and 39/39. MAX_UNREVIEWED 3384 → 3383. Fourteenth cut 2026-09-21: 21 / 158 → 20 / 157, both counts — _species_rate 345 → 88 plus nine helpers. Equivalence over 75 configurations, digest 50bd2926, byte-identical; the digest covers the returned triple, the caller's residual array (this function mutates it — reaction heat onto the energy row, the DPF trap sink onto the mass row) and the per-pipe scratch buffers, which are reused across calls so a wrong shape check shows up only on the second one. First cut in the arc where perf had to be measured rather than assumed. Its own comments record it at 15 % of case-41 wall time at ~20 K calls/s, and a split adds Python call overhead to exactly that path; the first measurement was ~5 % slower. Removing keyword binding from the eight-argument dispatch helper and letting the caller read pipe.filter (so a pipe with no DPF — nearly all of them — skips that call entirely) brought it to a paired-A/B median of +1.2 / +2.6 / +0.1 / +3.6 % across four shapes, inside the run-to-run spread of the unmodified code on the larger ones. 38 mutations, all caught, after eight survivors that were all one shape: the advection dispatch has eight arms and the telescoping two independent gates, and the first tests drove barely half of them — including the area-weighted telescoping that no bundled example reaches at all, so the seam tests are its only guard. MAX_UNREVIEWED 3383 → 3382. Fifteenth cut 2026-09-21: 20 / 157 → 19 / 156, both counts — build_network 322 → 93 plus eleven helpers, the third cut into io/build.py. Equivalence over 341 builds: all 83 bundled examples crossed with validate on/off and AEOLUS_SOA on/off, byte-identical, digest 4edc760c. The bundled corpus was not enough and the blindness probe said so before the split. Every bundled example builds, so the example corpus reaches none of this function's six refusals — an unknown pipe id, a BC colliding with a junction port, an uncovered pipe end, a bad species order, and two more — and a refusal that stops firing makes fewer things happen. Eight synthetic cases were added to reach them, plus the Phase-51c species-enabling pass that no example triggers. Two probes still moved nothing until they were covered directly: the _THROTTLES registry clear, whose entire purpose is letting a previous network's pipes be garbage-collected and which nothing about the returned bundle reflects, and that same species pass. 28 mutations, 27 caught; the 28th is a genuine equivalence (the reaction == "none" skip is an early-out, not a guard — the validator returns [] for it anyway) and is recorded as a comment rather than tested around. Eleven survived the first pass, and their shape is the lesson: four were arms of a dispatch tested by calling the builders directly, which proves they work and not that anything picks them. MAX_UNREVIEWED 3382 → 3381. Sixteenth cut 2026-09-21: 19 / 156 → 18 / 156 — _advance_network_strang_impl 271 → 152 plus seven helpers and two small records. Over-100 deliberately does not move: 62 of those 152 lines are the signature and docstring and 69 are code, so the parent is a linear driver sequence and chasing it under 100 would mean splitting at arbitrary points — the same call recorded for _advance_single_pipe_implicit and the scaffold. What did come out is real duplication: the T half-step was written out three times and the C burst twice. Equivalence over 38 configurations, digest e9d20d80, byte-identical — and the digest records the transport and chemistry call sequence, not only the final state, because two different step sequences land on very similar numbers. Three probes moved nothing until the fixtures grew to reach them: the two γ refreshes (a no-op on the scalar-γ pipes every fixture had), and the wall-heat Δt cap, which needs not merely a constant-T_wall pipe but one whose relaxation time is shorter than the acoustic step — a gentler wall runs the branch and decides nothing. 27 mutations, all caught, after two survivors that were both the parent's own dispatch: testing a branch helper directly proves it works, not that the loop picks it. A source-text test had to move with the code: test_ec_c2_phase_b counted the substep loop in the driver's own source to pin the decision that it stays out of chemistry_step_on_pipes. That decision is unchanged — the loop moved into a helper in the same module — so the assertion was re-expressed against the helper and both branch functions, with a dated note saying why the mechanism moved and the claim did not. MAX_UNREVIEWED 3381 → 3380. Seventeenth cut 2026-09-21: 18 / 156 → 17 / 155, both counts — Compressor.apply_port_bcs 258 → 81 plus nine methods, none over 40. Equivalence over 53 configurations, digest 2873f612, byte-identical over both pipes' padded state, the species-inflow dicts and all eleven last_* diagnostics. The harness had a bug of its own, and the probe is what exposed it: the case names were formatted :.0e, so 1.05e5 and 1.3e5 both rendered 1e+05 and five pressure ratios silently collapsed into three — 42 cases recorded as 36. Six further probes moved nothing until fixtures were built to reach them: the zero-mass-flow return, the eta floor (no shipped map goes under 0.05, so only a stub reaches it), casing heat, an outlet pipe holding a different gas, an off-test gas where the γ/R factor stops being the identity, and the fuel pass-through — which writes species_inflow_* and was therefore invisible to a digest that hashed only conserved state. 34 mutations, all caught, after seven survivors: three were assertions too weak to distinguish the two outcomes (both ghosts start at u = 0, so "walled shut" and "left open" look identical), and four were the parent's own dispatch — the third cut running where that was the shape of what got through. MAX_UNREVIEWED 3380 → 3379. Eighteenth cut 2026-09-21: 17 / 155 → 16 / 154, both counts — _augment_jacobian_with_closures 229 → 61 plus six helpers and one plan record. Equivalence over 41 configurations, digest 4dfdda6b, byte-identical. The harness fed an @njit kernel a wrong-length parameter pack and it read past the end without complaint: a 3-element constant-h catalyst pack handed to the 7-element variable-Nu kind produced garbage that changed between processes, and on one run looked exactly like a real difference in the code under test. Caught by re-running the snapshot three times instead of trusting a single comparison — a habit worth keeping. 21 mutations, 19 caught, and the two survivors are the finding: the JIT'd native FD Jacobian fallback is unreachable. Every native closure configuration the builders emit is analytic — all three friction kinds, both porous kinds under both viscosity models, the one native heat kind — and the three heat kinds without a closed form are implemented as py_heat overlays that set heat_kind = HEAT_NONE, so no pipe carries them natively. Forcing the state by hand reaches the kernel and it adds exactly zero. Filed in BACKLOG.md, not deleted. Of the five real survivors, one was a test I had deleted by accident with a careless slice edit — the mutation round is what noticed. MAX_UNREVIEWED 3379 → 3378. Nineteenth cut 2026-09-21: 16 / 154 → 15 / 153, both counts — _apply_layered_brinkmeier_calibration 203 → 97 plus six helpers, three of which lift a fact the pre-split body wrote out two or three times (the K1 label test, the E1 label test, the temperature-vs-energy E1 unit rule). Equivalence on two corpora because either alone is blind: the 143 real Cruise M projects (digest 2b7b4ce8) — only two of which call this function — and a 20-case synthetic set driving every layer combination with the layer suppliers stubbed (digest d9830181); both byte-identical. 28 mutations, 27 caught; the 28th is the empty-kinds early-out, an equivalence for well-formed input. One layer-3 statement is redundant by construction — it can only flag a reaction layer 2 already flagged — and is kept as the statement of intent, labelled as such. Two behaviour-identical clean-ups rode along: a dict whose values were ', '.join(set(...)) — a hash-order-dependent string nothing ever read, only its truthiness — is now a boolean, and a dead counter is gone. With this cut, every target on the EC-C3 list that is neither an @njit kernel nor owned by EC-C2 / EC-C6 is done. MAX_UNREVIEWED 3378 → 3377. Twentieth cut 2026-09-21: 15 / 153 → 14 / 152, both counts — chemistry_step_on_pipe 253 → 80 plus six helpers. A five-way solver dispatch where two arms are fall-throughs rather than returns (numbalsoda hands anything it does not own to LSODA; bs_brinkmeier is a no-op without its dispatcher), so the harness records which backend ran as well as the state. Equivalence over 24 cases, digest f798e3b8, byte-identical across two separate processes. 25 mutations, all caught, after six survivors that were mostly my own fixtures — a dispatch fixture that stubbed out the very telemetry check under test, and a uniform pipe that could not tell "every cell reads cell 0" from correct. MAX_UNREVIEWED 3377 → 3376. Twenty-first cut 2026-09-21: 14 / 152 → 13 / 151, both counts — advance_single_pipe 249 → 99 plus five helpers. Of the thirteen left, seven are owned by EC-C2/EC-C6 and five are @njit kernels; the thirteenth, make_brinkmeier_twc_demo (376), is plain Python and was wrongly grouped with the kernels when this pass was scoped. (A note in the budget test claimed the list done after the eighteenth cut; it was wrong by three, and the retraction is recorded next to it.) Equivalence over 76 cases, digest 99266bc0, byte-identical in two processes; 43 mutations, all caught. The sweep found that the implicit fork silently drops dt_fixed, live_control and cfl_ramp — filed, then fixed the same day in its own commit (the duplicated rk → driver table stays open in BACKLOG.md). MAX_UNREVIEWED 3376 → 3375. Twenty-second cut 2026-09-21: 13 / 151 → 12 / 149 — make_brinkmeier_twc_demo 376 → 100 plus seven helpers; over-100 falls by two because its nested BS-JIT step (142) was an entry of its own. The three hand-copied positional argument lists (rate callable, fused-rhs pack, BS kernel) now share one head. Digest d793710b over 43 cases, byte-identical in two processes; 38 mutations, all caught. Found: the BS step's three refusals (OSC heat split, NH3, C3H6) lived only in case validation — fixed the same day, the step now refuses them itself. MAX_UNREVIEWED 3375 → 3374. With this, the twelve over-200 functions left are exactly the seven owned by EC-C2/EC-C6 and the five @njit kernels (checked with an AST walk over src/ and aeolus1d_gui/). The residual long-function work (§4.3) — _read_impl 1495, gt_suite_native.read 1411, advance_network_implicit 1282, _attach_closures 1008 — is still delivered by items 2 and 6 Ownership moved 2026-09-22 (decided with EC-C2's closure): the over-200 network drivers the notes above file under "owned by EC-C2" are EC-C3's now. EC-C2 was about duplicated code and is met; their length is this row's. The notes above are left as written, since they were true when dated 2026-09-22: 12/149 → 11/148, the first driver taken over from EC-C2: advance_network_quasi_steady 590 → 93 (three junction solvers split out), byte-identical over 168 cases (digest 5a134c05). The end-to-end QS tests caught 2 of 14 seeded defects, and test_qs_junction_solver_seams.py raises that to 14/14. The sweep found and fixed a last-iterate defect (a solve converging on its final allowed iteration raised), and filed the absolute-tolerance gap on pressure-only networks in BACKLOG.md Then 11/148 → 10/147: advance_network_quasi_steady_transient 794 → 99, byte-identical over 104 runs, digest 82c62bf6. That digest covers every on_step time and every warning's file and line. Seeded defects caught went from 6/24 to 24/24 with test_qs_transient_step_seams.py, whose warning-location tests also caught a stacklevel slip of mine that the harness could not trigger Then 10/147 → 9/147: advance_network 933 → 169 (over 100 still: a 31-parameter signature plus its API docstring is 94 lines), byte-identical over 439 runs (digest 93862ad3). Seeded defects caught went from 13/20 to 20/20. The same branch repairs the QS hot-path perf gate, which MR !533's split had left timing only the driver loop Then 9/147 → 8/147: advance_network_implicit 1282 → 196, byte-identical over 508 runs (digest b367f469); seeded defects caught went from 14/25 to 25/25. Every network driver is now under 200 lines; left over 200 are _attach_closures, the two vendor reads and five @njit kernels 2026-09-23: 8/147 → 7/146: the Cruise M reader _read_impl 1495 → 94, a straight sequence of passes with no child over 100. Byte-identical over the 143 local_cases projects under PYTHONHASHSEED=0 (digest 138689f9, the whole Case, the report, every warning). The existing Cruise M tests caught 28 of 50 seeded defects; with test_cruise_m_read_passes.py all 50. The sweep filed four older importer gaps in BACKLOG.md, the widest being that every catalyst brick gets the first CellStructure in the file (15 of 143 projects carry differing ones). Left over 200: _attach_closures, gt_suite_native.read and five @njit kernels MET 2026-09-28: 7/146 → 5/143, and the two residual functions §4.3 named are gone. _attach_closures 1034 → 55 (a dispatcher over per-family attachers, each family's kinds in a table whose keys are the schema's accepted set; nothing over 100), byte-identical over 920 records (the 235 bundled TOMLs × validate on/off plus 85 synthetic specs × 5 contexts; digest 6a20a2bd); seeded defects caught 18/49 → 48/49 with test_attach_closures_seams.py (the 49th is equivalent). gt_suite_native.read 1565 → 96, a sequence of named passes, byte-identical over 181 inputs (the 104 local_cases .gtm files, 74 captured test fixtures, 3 synthetic; digest afa1acf9); 28/57 → 57/57 with test_gt_suite_native_read_passes.py. The sweeps fixed what they found (a KeyError('catalyst') on an unvalidated build, a frictionless minor_loss, an unknown nu_correlation running Hawthorn, a negative slow-soot seed, silent GT defaults now reported, a wastegate left pointing at a dropped turbine, a charge-air-cooler report claiming an effectiveness it never applied) and filed three design calls in BACKLOG.md. What is left over 200 is the five @njit kernels this row excluded from the start (three HLLC Jacobians, two Brinkmeier rate kernels). |
| EC-C7 | Skips are indistinguishable from passes | 2 d | ✅ MET | Census + gate landed and seeded from pipeline 2782026068: fast 69, unit 67, integration-tests 51, gui-tests 42 — ceilings may only fall. Annotated 2026-08-29: that is the seed, and it reads as the current set. ci/skip_budget.toml now holds three ceilings — 67 / 51 / 42 — because the fast job was removed 2026-08-24 and its ceiling deleted with it, which the budget file records in a comment. The row is history and history is right; it is annotated rather than rewritten because a reader was taking it for state. The census agreed with pytest's own summary in all four jobs; integration-tests is the proof, carrying 49 test-level + 2 collect-level skips + 2 xfailed, where a naive counter would have read 53 or 49. AEOLUS_REQUIRE_FIXTURES=1 (tests/_fixtures.py) turns a missing fixture into a failure for a maintainer holding the vendor data — never set in CI. Remaining, and NOT a gate: migrating the other ~48 private candidate-walks to the shared helper |
| EC-C4 | 57 % coverage floor, unit tests only | 1.5 d | ✅ MET 2026-10-08 (was 🟡 PART DONE) | 2026-10-08 — gate C floored at 86, floor(min) of its first three nightly readings (2884409607 86.50 %, 2886080911 86.90 %, 2887697613 87.35 %), passed as --fail-under=86 to ci/combine_coverage.py. It is about 10 points slack against the 95.75 % read on 2026-10-02 (2904880499); the ratchet is open in BACKLOG.md, "Gate C's floor is 10 points slack". 2026-09-25 — gate B floored, gate C landed. Gate B (gui-tests, aeolus1d_gui) gets --cov-fail-under=68, floor(min) of the three most recent pipelines that ran it, read exactly from coverage-gui.xml (2881221522 68.606 %, 2881114950 68.606 %, 2879022782 68.486 %), before its 2026-10-01 deadline. Gate C: unit, integration-tests and regression each export a named data file (the latter two measure on the schedule only), and a schedule-only coverage-combined job runs ci/combine_coverage.py, which refuses a missing contributor, two source roots, or a combined total below the unit one. A local dry run first: one source root, 67.73 % → 77.81 % combined. What was left for MET (done 2026-10-08, above): gate C's floor, floor(min) of its first three nightly readings, due 2026-10-09 and pinned by test_gate_c_has_a_floor_or_a_live_deadline. Earlier history follows. Floor ratcheted 57 → 62 on three consecutive main measurements (2779182612 / 2781664496 / 2781797137: 62.77, 62.80, 62.80 %) — the gate had ~5.8 points of dead band and could not have detected a real regression. The stale "58.87-58.89 %" comment is retracted in place. Separately, GitLab's coverage: regex matched nothing since --cov-branch was turned on (it expects 3 numeric columns; branch coverage prints 5), so the recorded coverage field read null on every pipeline checked while the log said 62.80 % — fixed and pinned by tests/unit/test_coverage_gate_config.py. Remaining: the per-package contributor design (gates B and C), which needs --cov-branch on every contributor or coverage combine aborts (review M2) and stated deadlines/provisional floors (review M5). 2026-09-18 — gate A closed properly, gate B landed, gate C re-specified. Gate A. The floor was 62 against a measured 65.85-65.93 % over eight consecutive main pipelines (2849601157 .. 2859085924) — 3.9 points of dead band, one month after the ratchet above closed 5.8. The instructive part is that the August drift had been fixed: the fix produced a better number and nothing that notices the number going stale, so the chore returned on the schedule a chore does. The existing guard is one-sided by construction (it sees a floor being lowered, not coverage rising away from it) and the measured rate lives in CI, so no unit test can close it. Ratcheted to 65, and ci/check_coverage_slack.py now runs inside the unit job and fails it when coverage sits more than a point above the floor, naming the digit to change. Allowance calibrated, not guessed: the run-to-run spread over those eight pipelines is 0.08 points, so 1.0 is ~12x it. A build going red because coverage improved is the ratchet working — the same bargain test_the_ceilings_are_not_slack already makes for function length. Its exit code reaching the job's exit status has its own test, since a check whose status is dropped is the defect it exists to prevent. The August regex fix is also now confirmed in production rather than reasoned about: GitLab's API reports 66.0 on every one of those eight pipelines, where it read null on every pipeline checked before. Gate B landed. gui-tests now measures --cov=aeolus1d_gui --cov-branch. aeolus1d_gui is about a third of the product and had no coverage figure from any job until this. It ships with no floor, and a dated bound rather than the open-ended gap M5 objected to: a floor must be floor(min) of a real measurement and there is none yet, so test_gate_b_has_a_floor_or_a_live_deadline reads the deadline out of the job comment and goes red once it passes, carrying the procedure in its failure message. No coverage: key was added to that job, deliberately — GitLab averages that value across jobs, so a second one would blend the two packages into a number describing neither while looking exactly like a working badge; a test counts the keys. Gate C is re-specified in BACKLOG.md, because its contributor table rests on a job that was deleted and one that never contributed what it was credited with. The plan listed fast as a contributor to both packages; fast was removed 2026-08-24, and it never exercised aeolus1d_gui even while it existed — testpaths = ["tests"] means -m fast never collects under aeolus1d_gui/, so the auto-applied marker lands on items that are never selected (measured: 0 GUI tests collected). So aeolus1d_gui's contributor set is exactly {gui-tests}, combining for it reproduces gate B's own number, and gate C should not cover that package at all — the plan itself flagged this case as breaking its monotonicity proof. For aeolus1d the case stands unchanged and is the point of the gate; contributors are three, not five, and the arity check re-scopes with them |
| EC-C11 | Released artifact behind the code | 1 d | ✅ MET | v0.3.3 tagged at 19e02c6e, published 2026-08-24, index-verified (EC-Q2). One thing plan §4.11 did not anticipate, because the delta grew after it was written: the release carried a feature (the component icon set's new default canvas style), not only fixes. It shipped as a patch anyway — this project reserves minor for breaking changes until 1.0 (release.md, "Versioning") and Legacy reproduces the previous canvas exactly, pinned by a golden test. Recorded in docs/changelog.md's entry so the trade stays visible |
| EC-C9 | Python 3.9 floor | 3 d | ✅ MET | See EC-Q4, landed 2026-08-30. This is the one critique item that is also a release-mechanics gate. Estimated 3 d, took under one — the estimate was sized on the Optional[X] migration, which turned out to be 40 annotations in 4 files because io/schema.py concentrates them; the surfaces the row did not list (four more CI/Docker sites) were the larger half of the actual work |
| EC-C12 | 132k lines of tests unchecked by mypy | 2/3 done + ~3 MRs | ✅ MET 2026-09-28 (was 🟡 PART DONE) | Two thirds landed 2026-08-25, and most of it was already true — the exclusion was three directory globs hiding that 507 of 758 test files already type-check (74 % tests/unit, 68 % tests/integration, 34 % aeolus1d_gui/tests). Narrowed to per-file exclusions: mypy went 403 → 910 files checked, and every clean test file is now locked against regression. Bringing them in under the real settings surfaced 62 errors in 24 files, all fixed — 61 stale # type: ignore comments (each of which was suppressing the next real error on its line) and one attr-defined on numba's Dispatcher.__name__. test_mypy_test_exclusions_only_shrink.py pins the direction: a file may leave the list, never join it. Remaining: 251 files, ~1,280 errors — arg-type 543, union-attr 207, attr-defined 171, genuine loose values with no systematic fix. check_untyped_defs is the value — without it mypy skips unannotated bodies entirely (review B3). warn_unreachable still last-or-never: both sampled findings are mypy artifacts. Measure with a config copy, never by passing the directories — exclude filters explicitly-passed paths and reports zero MET 2026-09-28: 195 → 0 files; [tool.mypy] has no exclude at all. 1,044 errors in 195 files (tests/unit 535 / 97, tests/integration 213 / 45, aeolus1d_gui/tests 296 / 53), fixed without adding one # type: ignore (ten stale ones came out); mypy now checks 1,611 files with none failing. test_mypy_test_exclusions_only_shrink.py now fails if an exclude comes back. What the gate found was mostly tests that could not fail: two asserting on validate_case's return value (always None; it raises), a resume test whose coolant half could skip itself, a GUI audit test running its BC branch on the invalid kind "wall_bc", a stand-in layout without the revision counter the code under test reads. |
| EC-C10 | Perf gates cannot give a trustworthy signal | 4 d, or 1 h | ✅ MET 2026-09-18 (step 1 closed 2026-08-30; the rest below) | The pre-tag pre-flight in CLAUDE.md now reproduces what the tag pipeline actually runs, and a test keeps it that way. It had drifted twice, both times silently, and the second drift was found only by checking the first. (a) 2026-08-24: the recipe set no AEOLUS_PERF_LOG, so two full pre-flight runs printed 8 passed and emitted no number. (b) With that fixed, the recipe still ran profile_regress alone — one of the three pytest invocations the two jobs make between them: perf-budget runs profile_regress and bench_regress as separate calls, and wallclock-budget is a second job needing AEOLUS_BENCH_HOST=1 without which every gate in it skips and the run is green having asserted nothing. So a pre-flight could pass while wallclock-budget was the gate about to fail at the tag. tests/unit/test_preflight_recipe_matches_ci.py makes the CI jobs the source of truth and the recipe the thing that must keep up — four guards, each teeth-tested against the old recipe, covering marker coverage, arming env vars, AEOLUS_PERF_LOG/AEOLUS_PERF_MARKERS, and that the recipe reads the log back (a green run is not the number). What remains is not a recipe problem: the gates still execute for the first time on the pipeline that also carries release-pypi, which is EC-Q10. ANNOTATED 2026-09-09 — a THIRD instance of the same defect, one layer further in, now closed. (a) was the recipe not setting AEOLUS_PERF_LOG; (b) was the recipe running one of three invocations. (c) is that bench_regress — one of those three, run correctly, with the log set, on a quiet host — emitted no record at all: profile_regress 8 passed / 8 records, bench_regress 8 passed / 0 records, wallclock_regress 1 passed / 1 record, measured 2026-09-07. Nothing skipped; the eight tests ran, passed, and wrote their number nowhere, so a third of the gates were absent from the file this criterion is about. The gate still failed correctly on a regression — what was missing is the trend, and a perf history with a third of its gates absent cannot answer "when did this get slower". Closed in 2d0714c3: the helper emits before asserting (8 passed / 7 records; the 8th compares two lists and measures nothing, exempted by a visible marker). The guard for this already existed and did not apply — tests/benchmarks/conftest.py fails a perf-gate test that completes without emitting, and its marker tuple carried the comment "Extend this when a new perf gate marker is introduced". bench_regress was not new and was not in it. tests/unit/test_perf_gate_markers_are_all_guarded.py now pins that tuple against CLAUDE.md's pre-flight recipe in BOTH directions, so the chain is CI -> recipe -> guard with no hand-copied link left in it. Step 1 stays closed and this row stays PART DONE: EC-Q10 is untouched by any of it. MET 2026-09-18. The remainder this row named — the gates still execute for the first time on the pipeline that also carries release-pypi — was already stale when written: the nightly schedule was created 2026-08-30 and has run every night since, so the tag has not been the first execution for three weeks. EC-Q10 is now met, and the chain gained its last link: CI -> recipe -> guard -> baseline. What closes the trustworthiness question itself is that there is now a trend to read. perf/nightly-baseline.json pins the band each series has produced over 19 green nightlies and python scripts/perf_nightly_history.py --compare perf.jsonl ends the pre-flight with where this run sits in it — because green is not inside the band: the ceilings are ~1.8x, so a run can clear every gate while being 40 % slower than the box's own recent nights. It reports and does not gate, on purpose. A FOURTH instance of the family this row documents, found by the first read of that trend and fixed with it. (a) was the recipe not setting AEOLUS_PERF_LOG; (b) the recipe running one of three invocations; (c) bench_regress emitting no record. (d) is that a measurement had no stable name: cProfile keys a function as basename:lineno(name) and that is what the record carried, so an edit that merely moved a function down its file renamed its series. Across the 19 nights advance_network_quasi_steady_transient appeared under four names and _species_rate under nine, cutting 19 samples into runs of 1 to 8. The gates were never affected — they match by substring precisely so line drift cannot break them — but the stable name the gate selects by was written nowhere, so nothing downstream could group the samples. emit_measurement now stamps a series derived once from that target. Same shape as (c): the number was emitted, and was absent from the history anyway. And the first thing the trend surfaced is recorded rather than smoothed away: on 2026-09-10 all eight profile series scaled by ~1.09 together and were back the next night, on a host whose loadavg at emission read 0.76 — quieter than the fastest night in the window. --compare therefore prints the whole-run scale beside the per-series table (every series moving by one factor is a slow host; one series moving alone is a slow commit), and the instrumentation gap itself is filed in BACKLOG.md — loadavg is necessary evidence of contention here and demonstrably not sufficient |
| EC-C6 | GUI god class | 4–6 wk | ✅ MET 2026-09-29 — by the plan's five steps (decided); the class overhaul is deferred (was 🟡 PART DONE — all five planned steps closed 2026-08-31; the class is not) | MainWindow spans app.py:239–4001. Steps 1–3 are ~1.5 weeks and deliver most of the value — a natural partial-credit boundary. Re-measured 2026-08-31 (ast, tree 125954a0): every span in this row and in plan_v3.md §4.6 has grown, so they are annotated not rewritten — MainWindow is app.py:268–3990 (3,723 lines), __init__ 484 (plan: 483), case_io.save_case 818 (plan: 790), RunnerDock.__init__ 560 (plan: 559). Two things the plan's ordering did not know: save_case is already banner-sectioned into 13 blocks (case_io.py:668,680,686,702,732,821,939,1043,1058,1091,1359,1363,1377) beside 17 existing _*_lines(spec) helpers, so step 1's seam is cut; and step 2's target is already a method, _make_actions at app.py:849–1089 — 241 lines, not the "~150, mechanical" the plan estimates, and the work is moving it to a module, not finding it. Step 1 is self-measuring: tests/unit/test_function_length_budget.py asserts over_200 == 43 exactly (non-slack) over src + aeolus1d_gui, so splitting save_case goes red until MAX_OVER_200 falls to 42 in the same commit. Step 1 landed 2026-08-31. save_case 818 → 91 lines: thirteen pure _serialise_* block writers, per-block helpers under the cylinder / turbo bodies, and _BC_KIND_EMITTERS replacing an eleven-arm if/elif chain. Verified by SHA-256 over save_case output for all 233 bundled examples/**/*.toml before and after — all 230 that load are byte-identical (the other three are a mechanism file, a surface-kinetics file and a solver config; they fail to load identically on both sides). The ratchet earned its keep and is the finding worth keeping: the thirteen-way split alone left it at 43 / 179 — over-200 unchanged, over-100 two worse — because three children were themselves long (cylinders 276, bcs 123, turbos 109). Thirteen functions, three of them still god functions, is a rename; the non-slack assertion is what said so. A second pass reached 42 / 176. tests/unit/test_case_io_serialisers.py is the new-tests half the plan promised — 45 tests, Qt-free so they run in unit not gui-tests, four teeth-tested by staging the violation. Its load-bearing guard is not bookkeeping: every kind in BCSpec.KIND_FIELDS must have an emitter, and every field a kind declares must be reachable from it. That is the defect class this file keeps recording — egr_valve / throttle had no branch at all and reverted to zero area and default Cd on a GUI save+run; the Phase-51c species schedules were missed on transient_stagnation_inlet. Neither raised anything; the case just came back a different case. Step 2 landed the same day. _make_actions, 241 lines of the same five statements repeated twenty-nine times, became a 29-row ActionSpec table in aeolus1d_gui/shell/actions.py plus a binding loop; MainWindow 3,723 → 3,503 lines and the ratchet fell again, 42/176 → 41/175. Verified the same way as step 1 — a live window built offscreen before and after, all 29 actions compared on text, shortcut, tooltip, checkable, checked, enabled, parent identity and receiver count on both signals; every field identical. Skip census 40 before, 40 after, which is the plan's own stated criterion for this step. One real cost, found by mypy-strict and not by reasoning: binding via setattr is opaque to a type checker, so the loop cost 59 attr-defined errors — every self.act_* use in app.py. Fixed by declaring the 29 attributes on the class, which is a second list of the same thing and is therefore pinned against the table by a test. aeolus1d_gui/tests/test_shell_actions.py adds 96 tests, four teeth-tested. Its two load-bearing guards were not expressible while the actions were longhand: no two actions may share a shortcut (Qt fires neither handler for an ambiguous window-level shortcut — the key silently stops working), and every action must name a slot the window actually has (a renamed handler is otherwise an AttributeError at click time, in a menu item CI never exercises). There are no duplicate shortcuts today — 19 assigned, 19 distinct — so that guard is preventative, not a defect it found; recorded because a guard that never fired reads like a fix. Step 3 landed the same day. The fourteen docks — construction, dock area and the tabify / split graph — became a DockSpec table in aeolus1d_gui/shell/docks.py; MainWindow.__init__ fell 484 → 125 lines and the ratchet fell again, 41/175 → 40/175. Over-100 does not move and that is the honest result: __init__ clears 200 but not 100, and none of the thirteen _wire_* methods it split into is over 100 (the longest, _wire_runner_dock, is 72). MainWindow 3,503 → 3,586 lines — it grew, by the fourteen class-level annotations, thirteen method signatures and their docstrings; the metric that moved is the god function, not the file. Verified three ways, because Qt dock layout is order-sensitive and CI cannot see it: a window built offscreen before and after, comparing every dock's objectName, class, dock area, floating / hidden / visible state, features, allowed areas, geometry, window title, tab-group membership, toggle-action text and checked state, plus the View → Panels contents, the toolbar set and a SHA-256 of saveState() / saveGeometry() — identical, field for field, on (a) a fresh launch against empty QSettings, (b) a save-then-restore round trip, and (c) the upgrade case, a layout saved by the pre-refactor code restored through the new table. The capture is deterministic — three runs byte-identical — and it needs a real GL context: under QT_QPA_PLATFORM=offscreen VTK SIGABRTs on the first render about half the time, which is the same reason gui-tests is wrapped in xvfb-run. A separate receiver census covers what a layout golden cannot see: every signal on the window, model, run-model, canvas, runner and all fourteen docks, counted before and after. 118 of 120 slots identical; the two that moved are destroyed() and destroyed(QObject*) on CaseSettingsDock, 2 → 1, because two closures inside __init__ became bound methods and PySide6 no longer needs its own lifetime guard for them. No application connection changed. aeolus1d_gui/tests/test_shell_docks.py adds 37 tests, nine teeth-tested by staging the violation. Two of the nine did not fire on the first attempt, and that is worth recording: deleting a panel_key makes the table self-consistent, so the checks derived from it cannot see the change — what catches a wrong table is the independent pin that the keyless set is exactly {PaletteDock, ComponentListDock, RunnerDock}, and that one did fire. The load-bearing guards are the ones Qt absorbs silently: no two docks may share an objectName (_ensure_dock_object_names renames a collision to <name>_2, which drops the panel out of every saved layout rather than failing), and no dock may anchor to one declared below it (Qt leaves the panel un-tabified in a corner instead of raising). There are no collisions today — 14 declared, 14 distinct — so that guard is preventative. One duplicate list removed and one third copy folded in: _right_pane_panels was a hand-written list of eleven docks beside the fourteen built inline, and test_panels_menu.py held a third; both now derive from panel_key. Three generations of the same number found in three places — __init__'s comment said the default columns were 15 % / 35 %, _apply_default_dock_proportions's docstring summary said 22 % / 28 %, and its body and the code said 26 % / 24 %. Only the code was ever current; the summary is corrected and the __init__ copy is gone. Step 4 landed the same day. The eight dock-layout methods — panels menu, close-button locking and reopening, objectName stamping, save / restore, default proportions — became free functions in aeolus1d_gui/shell/window_state.py that take the window. The reason is a wart this work had already tripped over: test_panels_menu.py could only reach these methods by assigning them onto a stub as unbound class attributes, which works at runtime and which mypy rejects — the step-3 test file hit the same wall and needed two casts to get past it. As free functions the stub is an ordinary argument and the borrowing is gone; MainWindow keeps a one-line wrapper for each, so every call site and the closeEvent hook are unchanged, and a new test pins each wrapper to calling its function and nothing else — a wrapper that grew a body would silently stop being the thing the tests cover. The QSettings scope and the two layout keys are now constants: they were hand-copied string literals in four places, and a typo in one splits save from restore with no error. MainWindow 3,586 → 3,454 lines; the ratchet does not move (nothing crossed a threshold), and it is not claimed to. All four goldens re-run and hold — fresh, restore, cross-version (re-seeded from 37483392 itself, since step 4 changes which function reads QSettings) and the receiver census, unchanged at 118 slots. Step 5 landed the same day. RunnerDock.__init__, 559 lines building 28 widgets inline, became 31 lines calling five per-row builders, each calling one builder per widget — 23 of those, largest 35 lines. The ratchet falls to 39 / 174, and this is the first step where both counts move. It took two passes, and the ratchet is why. Five row builders alone left four of the five over 100 lines — 155, 113, 108, 103 — so over-100 would have gone 175 → 178, three worse, for a function that had merely been spread out. That is the same shape the non-slack assertion caught in step 1 with save_case, and it is now the second time it has refused an intermediate state that looked like progress. Verified byte-identical on a widget-tree golden: the dock's layouts walked in insertion order (a flat findChildren cannot see order, and order is what a wrong extraction breaks) recording class, text, tooltip, stylesheet, enabled / visible, size policy, max size, and the per-type settings — spin decimals / range / step / value / special text / keyboard tracking, combo items and their data, progress-bar range / format, checkbox state, console read-only and block cap — plus a receiver census over the dock and all 28 widget attributes. 58 layout items, 28 attributes, 115 receiver slots, identical after both passes; deterministic across three runs before use. The whole-window goldens from steps 3 and 4 were re-run too and are unchanged, since the dock lives inside MainWindow. aeolus1d_gui/tests/test_runner_dock_structure.py adds 36 tests, seven teeth-tested, and zero skips — the row builders are filtered out of the per-widget parametrize rather than skipped inside it, because a skip spends the suite's skip budget on a case that is not applicable rather than one not yet covered. The guards worth naming: __init__ may construct no widget but the body; every builder must return the widget it assigns (one that assigns self._a and returns self._b puts the wrong widget on screen and orphans the right one, which reads as a missing widget rather than a wiring bug); every stored widget must reach a layout; and the header's add order is deliberately not its construction order — the progress bar is built after the two badges and shown before them, which the inline form carried in one statement stream and the split has to keep in two. WHY THIS ROW IS STILL PART DONE. All five planned steps are closed, and the item is the god class, not the five steps. MainWindow is 3,454 lines across 156 methods — down from 3,723, and every god function in the GUI is gone (nothing in app.py or runner_dock.py is over 125 lines, from a 484-line __init__ and a 559-line dock constructor), but a 156-method class is still a god class. Closing this row needs a decision on whether its criterion is the plan's five steps or the class itself, and that decision is not a refactor commit's to make DECIDED 2026-09-29 (the maintainer): the criterion is the plan's five steps, so the row is met. The GUI works, and every god function is gone; the remaining size of MainWindow (app.py 4,737 lines, 187 defs on 2026-09-29) is deferred to a complete overhaul once the GUI's functionality has settled, rather than cut piecemeal now against a moving feature set. Tracked in BACKLOG.md ("MainWindow is still one class"), not here. |
| EC-C2 | Driver duplication | 5–8 wk | ✅ MET 2026-09-22 — Phase C closed by measurement; Phases A, B, D before it (was: 🟡 PART DONE — Phase A, and Phase B complete by its own measure, 2026-09-03) | Re-scoped by review B4: no driver is deleted. quasi_steady is a low-Mach SIMPLE solver — a different physical model and the W3 / c02006 anchor — so the target is one shared physics layer under three retained integrators. The Mach sweep is CUT (review B2: it walks the parity case out of the calibration window its own harness depends on). Census measured 2026-09-01 and pinned by tests/unit/test_ec_c2_driver_census.py; the table is docs/roadmap.md §1. The item restated as a number: 41 distinct parameter names across the four network drivers, 12 common to all four — 22 / 25 / 35 / 13 per driver, with 11 unique to the implicit one and zero unique to the quasi-steady one, which is the whole argument for Phase A: the driver with the most knobs has the smallest signature because its knobs already live in QSNetworkConfig / QSTransientConfig. Three corrections to the plan fell out of measuring rather than copying: there are four entry points behind three integrator classes (so Phase D collapses advance_network and advance_network_strang, it does not retire a model), the advance_* surface is 11 functions and not 10, and both halves of the Phase A contract already exist in the tree, each applied once (advance_thermal_couplings, called by all four drivers). Phase A landed 2026-09-02 and the row moves off PROPOSED with it: src/aeolus1d/solver/_step.py holds NetworkRunSpec, the twelve parameters common to all four drivers, built once in io/case.py and splatted into all four call sites — the three ad-hoc mechanisms that used to carry them (named keywords, **extra, **extra_with_lc, the last two conditionally populated) are gone from the network path. No driver signature moved, so every census number above is unchanged; what changed is that a call site can no longer thread eleven of twelve, which is how coolant_couplings / wall_thermal_pairs and later live_control / cfl_ramp / dt_fixed / the HRR Δt cap were dropped on the Strang path — both found by reading, neither by a test. tests/unit/test_ec_c2_step_contract.py is that test, teeth-checked against all three mutations. Three corrections to the plan's Phase A fell out of measuring it: the contract is run-level and not step-level (no dt, no state arrays among the twelve), there is no StepOutputs (all four drivers return float), and no driver takes **kwargs — the catch-alls were at the call sites. docs/roadmap.md §1 carries all three. Phase B started 2026-09-02 with its first hoist. Its list in the plan — "boundary-condition application, thermal couplings, source terms, species update" — was measured before it was worked, and two of the four were already shared (fill_network_ghosts, advance_thermal_couplings) while the third, _species_rate, is shared as a function but defined in network_timeloop.py, the explicit-driver module, and imported from there by the other two. So the operators were largely hoisted already; the plumbing around them was not. An AST shape scan over the four driver bodies (identifiers normalised, docstrings excluded) counted 7 statement shapes of ≥ 5 lines appearing in two or more drivers, and that count is Phase B's work queue rather than the plan's list. That count read 9 for most of the day and was corrected: the scan folded every constant to one token, which is right for a number and wrong for a string, so any two multi-line raise SomeError("…") matched — pairing the Strang and implicit chem_every_n guards, which correctly name their own driver and should differ, and pairing the implicit cylinder-valve refusal with the quasi-steady non-finite-velocity abort, which are unrelated code. The per-hoist deltas were never wrong; the totals were inflated, and an inflated total makes the remaining work look larger than it is. The normaliser now keeps strings and a test guards the instrument. Hoist 1 took it 7 → 5: the heat-release-rate Δt controller moved to solver_ops.py as HRRSnapshot + hrr_measured_fraction / hrr_species_fraction / hrr_step_factor / hrr_next_dt_limit, removing the start-of-step snapshot comprehension (duplicated in three drivers) and the end-of-step controller update (three). Its two kernels were already shared but lived in the explicit-driver module with the other two drivers reaching in — the arrangement solver_ops.py's docstring names as the cause of the !301 silent-drop class, and the HRR cap had itself been dropped once on the Strang path. It is now in the driver-parity operator set, where it had never been. tests/unit/test_ec_c2_phase_b.py pins the census, the composition identity, and the one deliberate non-unification (the gas-energy snapshot: two drivers source it from copies they already hold, so HRRSnapshot.take accepts a caller-supplied U rather than forcing a second per-step copy). Six teeth-test mutations, six correct fires — including one that caught a guard of mine that could never have failed, because the shape normaliser strips the attribute names it was searching for. Hoist 2 took it 5 → 4: the live-control poll — "poll the control file, block while paused, break on checkpoint_and_stop, take a fresh CFL" — was written out at five sites (timeloop.py, network_timeloop.py twice, implicit_network.py, quasi_steady_network.py) and is now solver_ops.poll_live_control, returning (stop, cfl) because the block contains a break. This is the block whose absence is the recorded defect twice over: the Strang and quasi-steady drivers each ignored live_control entirely at some point, so Ctrl-C and GUI-cancel produced no clean checkpoint, and the quasi-steady driver is the longest-running one. It joins the driver-parity operator set alongside the HRR cap. The hoist nearly shipped an AttributeError and mypy caught it: the first draft called apply_to_adaptive_dt unconditionally on the correct grounds that it no-ops on None — but only if it exists, and callers pass structural stand-ins (_StopAfter in test_solver_audit_parity_2026_07.py has three methods). Both optional calls are now guarded on their argument, and the contract moved from a comment into the type system: a StopSignal protocol plus two overloads say that passing a CFL requires a full LiveControl and passing none does not, which let the quasi-steady signature go from object | None to StopSignal | None and dropped three # type: ignore[attr-defined]. implicit_euler.py:457 was deliberately left out — it polls and stops but never pauses, so the single-pipe implicit drivers cannot be paused while every other driver can; routing it through the helper would have silently fixed that, which is a behaviour change, so it is in BACKLOG.md with a test that fails when the fix lands. Hoist 3 took it 4 → 3: the pipe-union builder. Hidden neck pipes owned by junctions must be advanced alongside the user pipes, and three drivers built that list three ways — an identical _all_pipes() closure in the explicit and Strang drivers, and pipes + [p for j in junctions for p in j.neck_pipes] in the implicit one. Same list, same order, three definitions, now solver_ops.network_pipes; the implicit driver's local neck_pipes turned out to have no other reader. The quasi-steady driver was not made a caller — it references no neck_pipes at all, solving junctions as steady pressure nodes, so giving it the union would be a behaviour change; pinned as a decision. What is left is one duplication — three shapes, because the block nests — the chemistry / surface-mechanism dispatch shared by the Strang and implicit drivers, and it is a merge request of its own: hoisting only the loop would make the shared layer import from a driver module (a circular import, and the same anti-pattern), so the honest version moves 635 lines of chemistry into a new solver/chemistry_ops.py and updates 9 import sites. docs/roadmap.md §1 carries the costing. The sequencing prerequisite landed with all of it: regression-bitid now has a merge_request_event rule with when: manual, allow_failure: true, so Phase B's bit-identity tripwire is reachable before a merge instead of after it — an ordinary MR pays nothing and the bench host stays out of the merge path. A correction, measured on this merge request's own first pipeline. The rule first read allow_failure: false, as the plan specified — and a when: manual job with allow_failure: false blocks its stage. Pipeline 2814486224 showed it: every other test job green, regression-bitid at manual, all three integration jobs stuck at created, pipeline status manual rather than success. That version would have stalled every merge request behind a click on the bench host — the opposite of what it was written to do. It is now allow_failure: true on the merge-request rule only, so the branch / tag / schedule rules keep the default and a red bit-identity run still reddens main. What is lost is that a played-and-red MR run no longer blocks the merge by itself; GitLab has no "optional but blocking when played" for one job. Hoist 4 took it 3 → 0 on 2026-09-03, finishing Phase B by this measure: the chemistry / surface-mechanism dispatch is now chemistry_ops.chemistry_step_on_pipes, and the two stiff kernels it dispatches to (plus _chemistry_step_surface_lsoda, _bs_jit_applicable and _COVERAGE_VIOLATION_TOL) moved out of the explicit-driver module into a new solver/chemistry_ops.py — 635 lines, as costed. The circularity that forced the bigger move is not theoretical: putting the import into solver_ops instead fails collection with ImportError: cannot import name '_bs_jit_applicable' from partially initialized module. Four things the hoist measured that the costing did not. (1) The loop was written out three times, not two — advance_network_strang carried it twice, once per branch of chem_every_n, and the census buckets driver names in a set, so N copies inside one driver collapse to one; the census is blind to intra-driver duplication and that is now recorded and teeth-tested. (2) The census cannot reach 0 by hoisting alone. A successful hoist replaces a duplicated block with an identical call in each driver — which test_driver_operator_parity.py then requires to be there. The raw count after this hoist was 1, and the 1 was chemistry_step_on_pipes(...), five lines wide in both drivers; hoists 1–3 escaped only because poll_live_control fits on two lines and network_pipes on one. A metric that counts the residue of a successful hoist rewards hoisting less, so the census now excludes a statement that is nothing but a call to a shared-layer op — narrowly: wrap it in a loop or a guard and the enclosing shape counts again. (3) The implicit driver binds the dispatch as _chem_steps_fn via a lazy import inside the driver body, so alias resolution had to read both binding mechanisms out of the AST; a module-globals lookup alone would have counted one driver, paired nothing, and reported a false zero. (4) _COVERAGE_VIOLATION_TOL is defined three times across chemistry_ops, quasi_steady_network:2222 and chem_numbalsoda:201 — same value, same reasoning, no import between them, and under MIN_BLOCK_LINES so the census has never seen it; filed in BACKLOG.md. The estimated 9 import sites were 2: quasi_steady_network.py names the kernels only in prose and the test modules import them from network_timeloop, which re-exports. The two that changed are implicit_network.py and test_strang_refreshes_gamma_before_chemistry.py — the latter monkeypatched the old name, and a re-export is a second binding rather than the one the driver reads, so the patch stopped intercepting; it failed loudly on assert "chem" in seen rather than passing vacuously. Nine teeth-test mutations, nine correct fires — and one of the nine exposed a hole in a guard of mine. Inserting from aeolus1d.solver import network_timeloop into chemistry_ops failed collection with a circular-import ImportError, which is a fire, but the assertion written to catch it did not trip: it read only node.module ("aeolus1d.solver") and never the imported names. A driver imported without closing a cycle — solver_ops, say — would have slipped past silently. Both halves of every import statement are checked now. CORRECTED 2026-09-09 — this row said "Phases C and D are not started" and Phase D had already landed. 0de47c73, authored 2026-09-04: advance_network_strang became a thin behaviour-preserving redirect onto advance_network(..., strang=True), and its DeprecationWarning names the phase in so many words (network_timeloop.py:2526). docs/roadmap.md §1 carries a full subsection on what "three public entry points" turned out to mean — the two functions' bodies could NOT be merged, because plain SSP-RK and Strang splitting are different algorithms in the same model class and the Strang driver already called advance_network for both transport half-steps, which is composition rather than duplication and is exactly why the Phase B census found nothing left to hoist between them. So the roadmap was right and this row and STATUS.md were both wrong, in the same words — three documents describing one phase and only one of them reading the tree. Phase C alone remains, and it is still the largest item left in the release. It is not yet a coding task: its instrument, a descending Mach sweep to decide whether the 1.8 K quasi_steady − strang parity spread scales as O(M²) (the model, close as characterised) or is flat in M (a bug, and a location), was cut by review B2 for walking the parity case out of the calibration window its own harness depends on. A replacement instrument has not been proposed. CLOSED 2026-09-22 — the replacement instrument is grid refinement at fixed physics, and Phase C is characterised. Refining N moves nothing the calibration window depends on, and all three of the harness's preconditions (conversion in 0.3–0.7, settled, chemistry signal) were re-checked at every level: X_CO 0.4031 / 0.4024 / 0.4021, settle drift in θ 1.5e-05 at each. The QS − strang band at N = 10 → 20 → 40 is T 0.582 → 0.297 → 0.151 K, Y 5.49e-05 → 3.04e-05 → 1.57e-05, θ_Ce 1.28e-04 → 6.61e-05 → 3.38e-05 — ratios 1.8–1.97, extrapolating to ~0. It is first-order grid error from QS's upwind march (quasi_steady_network.py:462), and neither O(M²) model error nor a bug. The "1.8 K" was never the spread: it is the band's bound, 3x the 0.58 K measurement. QS's own knobs were checked at N = 10 and are not it: picard_tol 1e-4 → 1e-6 moves T by 1.4e-03 K, dt_max ÷4 by <1e-3 K; cfl_adv ÷2 halves Y and θ (a time-step share) but T only 0.582 → 0.559 K. The Mach sweep was then run where review B2 allowed it, on the non-reacting sibling (test_driver_steady_parity.py, inlet ×1 … ×1/8 with t_end and h co-scaled): flat in M, 1.010 → 1.097 K at N = 48 and 0.513 → 0.557 K at N = 96, M 0.032 → 0.004, while N = 12 → 192 halves it cleanly (3.69 → 0.259 K) — so the M² term is below 0.02 K there. Note the old plan's rule, "flat in M ⇒ bug", would have read this wrong: flat in M and halving in N is discretisation. Harness: Python 3.10.12, numpy 2.2.6, numba 0.67.0, the 16-core WSL2 dev box, scripts not committed (the numbers are reproduced by the pinned test and the tables here). Pinned by test_quasi_steady_band_is_first_order_discretisation (slow_mr): the QS band must halve from N = 24 to 48, ratio in [1.8, 2.2], measured 1.940. Teeth-checked by scaling QS's wall h alone: ×0.99 → 1.376, ×1.01 → 1.253, ×0.95 → 1.100, all red — where the 5.9 K bound passes both 1 % errors. The long driver functions stay on EC-C3's residual list; that is length, which EC-C3 owns, not duplication |
| EC-C1 | Not validated against experiment | ~4 d engineering | ✅ MET 2026-08-30 (by the row's own scoping) | Scoped, because the item as written cannot be closed. Closing W1/KTM needs proprietary OEM cam data that is not public (docs/release.md:180), so a criterion demanding it would block v0.4.0 forever. Acquisition stays explicitly out of scope. What the row asked for is landed: examples/validation/DATA_MANIFEST.md remains the single owner and gains a measured per-suite census — 47 tracked data files across 19 suites, 43 of them in the four vendor_tracked rows — with a six-word closed status vocabulary and licence_barred recorded for the three suites whose data cannot be redistributed (umich_diesel, avl_epw_diesel_steady, gt_acoustics_tl), whose 0 is correct and permanent rather than a gap. tests/unit/test_validation_data_manifest.py re-derives every count from git ls-files, because this file's own top block records that a provenance document is a claim, not a control — a census nothing checks would be the 2026-08-09 incident with newer numbers. Two of its seven guards are licence guards, not bookkeeping: a tracked file under a licence_barred suite, and a committed image anywhere but a vendor_tracked one. All three teeth-tested by staging the violation. One dated claim survived re-measurement — the "16 verbatim AVL screenshots" figure reproduces exactly (5 raw + 11 overlays) — and the four remaining PNGs turn out to be ours, a distinction nothing had recorded. This row said two contradictory things for a few hours on 2026-08-30 and the contradiction is worth recording: it declared acquisition explicitly out of scope and then listed "the acquisition half" as what remains. Both cannot hold — an out-of-scope item is not a remainder. With the in-scope half delivered, the row is met by its own terms. And the point is moot from the other direction too: EC-Q12, the acquisition criterion, turned out to be already satisfied by heliyon_twc_co_oxidation. What is genuinely open is the coupled DOC+DPF+SCR train (W3), which is not this row and is larger than it |
Two sections of the plan are cut, not deferred, both on verified blockers:
the docs-only CI fast path (EC-C5) and the driver Mach sweep (EC-C2). They are
recorded here so nobody re-derives them; the reasoning is in
.loop-review/improve-all-critique-issues/review_3.md.
Partial credit is the design. Three items are already part-done and the
two large ones have stated internal boundaries. If v0.4.0 needs to ship
before items 2 and 6 finish, the honest move is to drop those two rows to
v0.5.0 — not to declare them met.
Closed: v0.3.0 → v0.3.2¶
Historical from 2026-08-22. Everything below describes the
v0.3.0exit criteria and theirv0.3.1/v0.3.2re-closures. It is retained whole — the evidence trail, the retractions and the three corrections the header note went through are the most useful part of this file, and this project's rule is that drift is information. Do not edit these rows to describev0.4.0; add to the current section above.Header note, resolved 2026-08-14. This note existed to flag that
v0.3.0-rc1no longer describedmain— 54 commits behind, three of them changing shipped example behaviour, and the numerics stack moved underneath (numba 0.64.0 → 0.67.0, llvmlite 0.46.0 → 0.49.0). That is settled: rc2 was cut, tagged and shipped.git rev-list --count v0.3.0-rc1..mainandgit tag -lare the checks; EC-10 carries the evidence.The note went through three corrections in two days before resolving, and the pattern is worth keeping rather than tidying away: it first said "moved 35 commits … merged on 2026-08-12" while the audit MR was still open — a post-merge estimate written in the past tense — then a predicted count, and only then a measured one. A readiness document that lists only the finished work is a marketing page, and that applies to its own drift as much as to the project's.
Status: v0.3.2 cut and PUBLISHED 2026-08-19 — a patch on v0.3.1.
Every exit criterion remains MET, and the validation gate is still explicitly
WAIVED rather than met.
v0.3.2 fixes a Python 3.9 GUI startup crash (types.UnionType is 3.10+,
reached users in v0.3.1) and ships docs/ inside the wheel for the first
time — the published v0.3.1 wheel contained zero doc files, so
pip install gave a user no documentation and no tutorials. No solver
change, so every exit criterion below is inherited unchanged from v0.3.0
— no golden reference, validation score or energy closure moved. release-pypi was
clicked at 10:43 UTC on tag pipeline 2771548651 (22/22) and PyPI's
info.version reads 0.3.2 on both indexes — verified against the index, not
the job status. The published wheel carries 84 doc files where v0.3.1's
carried none, and pip install aeolus1d==0.3.2 into a fresh Python 3.9.25 venv
runs the reported crash path clean. For v0.3.1:
release-pypi was clicked on tag pipeline 2765689646 and uploaded the wheel
and sdist at 08:21 UTC on 2026-08-18; PyPI's info.version reads 0.3.1,
verified against the index. That pipeline needed one retry first — see EC-10.
For v0.3.0:
release-pypi was clicked on tag pipeline 2763935001 and uploaded the wheel
and sdist at 05:06 UTC; PyPI's info.version read 0.3.0, verified against
the index rather than inferred from a green job. All twelve criteria carry
evidence, EC-10 was the last to close.
The one thing this page does not certify. The two amber W1 validation
rows (KTM 6/10, blocked on unobtainable OEM cam data; Mendeley 39/60 baseline
/ 43/60 predictive, residual filed as a measured combustion-efficiency gap)
are not green, and v0.3.0 ships with them as documented limitations
under a dated waiver in release.md.
Exit criteria and the validation gate are separate questions; this table
answers only the first.
What that does and does not claim. It claims the release machinery is
proven end to end — built from a tag, uploaded over OIDC with no static token,
from a tree whose full pipeline was green. As of the 2026-08-17 publish, pip
install aeolus1d resolves to 0.3.0 without --pre, and the release-facing
surfaces (CITATION.cff, SECURITY.md, README badge) name 0.3.0. It does
not claim the validation gates are green — see the waiver above; that is a
separate question this page does not answer.
(Until 2026-08-17 this paragraph read "It does not claim 0.3.0 final is
ready: pip install aeolus1d still skips this … the release-facing surfaces
still name 0.2.0". Both were true of rc2 and became false at the publish.
Recorded rather than silently rewritten, because the drift is the same shape
this page's own preamble warns about.)
The paragraph this replaces read "NOT READY — but only on the release step itself", and was written 2026-08-08 when the remaining work was registering the production publisher and cutting the version. Both are done. Kept in outline rather than deleted, because this file's own principle — "a readiness document that lists only the finished work is a marketing page" — cuts both ways: it should not hide what it used to say either.
Generated against the plan in .loop-review/plan_v6.md (approved round 6)
and its post-approval decisions in .loop-review/decisions.md.
Exit criteria¶
| # | Criterion | State | Evidence |
|---|---|---|---|
| EC-1a | Every gated EPW criterion declares scale, floor, delta_min, band_basis, band_op_set and exactly one tolerance |
✅ MET | 7 gated criteria carry all five keys and one tolerance; unknown body keys and the two-tolerance case are load errors. test_criteria_failability.py + test_criteria_body_keys_are_known.py. Vendor suite still exits 0 in both modes |
| EC-1b | Anchored gates ≥ 3× max residual over band_op_set |
✅ MET | Bands re-sized from the measured residuals: 6 of 7 satisfy band ≥ 3×residual. T_EXH is a deliberate exception — the rule would demand 100 K, which certifies nothing — so it errs tight (1.95× slack), risking a false RED not a false GREEN. Annotated 2026-09-07: the slack figure has expired. The residual it was computed from (20.5 K) is now 47.09 K measured over the same three anchors, so the 40 K band is narrower than the residual (0.85×, not 1.95×). The row's reasoning — err tight, prefer a false RED — is unchanged and is exactly what happened: the gate is red and it is being investigated. The band is not widened to restore the ratio; that would be refreshing a reference to clear a red gate. Annotated 2026-10-10 (EC-Q7): re-measured on the v0.4.0 tree, the anchored residuals are −10.53 / −35.95 / −29.29 K, max 35.95 K, so the 40 K band is 1.11× the residual and the anchored gate passes at all three anchors. The band was not touched; the model moved, and that move is not bisected |
| EC-1c | band ≤ 3·delta_min, on the ladder, tier from arithmetic |
✅ MET | All 7 satisfy band ≤ 3·delta_min and band ≥ floor; 6 of 7 sit on the 1/2/5 ladder (T_EXH excepted as above). Net effect is a tightening — IMEP_Low 12→2, MFB50 20→10, PFP 30→20, BMEP 6→5 — and the vendor suite still exits 0 in both modes |
| EC-2 | EPW physics has a CI regression pin needing no vendor data | ✅ MET | Golden row epw_diesel_engine (screened bit-reproducible: 3 processes × NUMBA_NUM_THREADS ∈ {1,2,4} → one sha256) + test_epw_engine_selfconsistency.py |
| EC-3 | ≥ 1 EPW number carries u_num from a GCI study |
✅ MET | T_EXH at 3 uniform mesh levels (scale 1.0/1.5/2.25, all 17 pipes): observed order p = 1.286, Richardson value 458.312 °C, u_num = ±0.035 °C, asymptotic ratio 0.99996 (in range). Re-measured 2026-10-10 on the v0.4.0 tree (EC-Q7): p = 2.28, Richardson 427.233 °C, u_num = ±0.015 °C, ratio 0.99996 |
| EC-4 | NOX gated, or report-only with a measured, current reason |
✅ MET | Stale again, noted 2026-10-11: the 306/312/318 ppm and the T_active_min reason below are superseded. The N₂O kinetics are fixed (the gate now moves the answer 0.08 %), the compared value is the core-zone model, and it reads 892.5 / 620.7 / 1229.2 ppm against AVL 293.1 / 240.4 / 509.4. It is still report-only, for a 2.67× over-prediction with no fitted parameter (the NOx section below). This cell was itself stale until 2026-08-08 — it cited the ~1960 K cold-burnt-zone explanation that MR D (!411) retired, while pass_criteria.toml three files away carried the replacement. EC-4 asks for a current reason, so citing a superseded one failed the criterion it was recording as met. Current, measured: the zero was the integration window (full 720° fed blowdown samples to the stiff solver); narrowed to [IVC, EVO) it returns 306/312/318 ppm vs AVL 293/240/509. Still report-only for a different measured reason — the value is set by the T_active_min gate, not kinetics (sweeping it 1500→2000 K walks the answer 117→1115 ppm on an unchanged trajectory). Live reason: pass_criteria.toml, which is the file the harness reads |
| EC-5 | CV-A closes; CV-B < 3 % or attributed; bookkeeping deltas ≤ 0.5 % | ✅ MET | CV-B closes at 1.302 / 0.808 / 0.701 % against a 3 % threshold, and again at CFL 0.1 (1.301 %), so not by cancellation. Re-measured 2026-10-10 on the v0.4.0 tree (EC-Q7): 0.001 % at all three ops, with each half (cylinders / rest-of-network) under 3 % on its own; the CFL 0.1 repeat was not re-run |
| EC-6 | docs/release.md readiness table reflects current state |
✅ MET | This file + the release-doc update. It was false from 2026-08-09 to 2026-08-14 while reading MET: docs/release.md's validation-readiness gate still said "NOT READY for v0.3.0 — three exit criteria are unmet in code (EC-1a/1c, EC-3, EC-9), and EC-12 is blocked on a human action" after all four had closed. This row asserts that the other document is current, so it is the one row that cannot be verified from inside this file — and nothing was comparing them. Corrected 2026-08-14, with the failure recorded in release.md itself. A cross-reference is not a check; if this row is to mean anything it wants a test that diffs the two claims |
| EC-7 | Validation dossier carries the rows P8 finds missing | ✅ MET | EPW had zero dossier coverage; a full W1b section now carries the gated/report-only split, both energy closures, u_num, the CI-without-vendor-data story and the open gaps. DD13's "pending data fetch" row is accurate — umich_diesel/data/ is empty — so it is left alone rather than overclaimed. Annotated 2026-08-29: that reasoning has expired. data/ being empty stopped being evidence when the harness gained $AEOLUS_DD13_DIR and the dataset was staged outside the tree; the suite runs 8/9 measured points within band and its own README has said Active since. The dossier row is corrected in the same commit as this note. The lesson generalises past this row: a dossier status inferred from a path in the repository is wrong as soon as the data is allowed to live outside it |
| EC-8 | No shipped document claims something the code contradicts | ✅ MET | Frozen report corrected by erratum (its EPW row said "NOx over-prediction"; the code produces 0 ppm — the sign is reversed). D17 fixed: release-pypi-test still justified its pause by a name blocker the aeolus1d rename retired, contradicting the corrected reason a few lines above it. A guard now asserts the retired phrasing is absent everywhere, not merely that the correct one is present somewhere |
| EC-9 | OIDC exchange succeeded against TestPyPI, evidence recorded | ✅ MET | Both indexes, 2026-08-09, on !417 b97b00e4 with no environment: on either side: job 15795100117 OK: exchange succeeded. https://test.pypi.org … (292 chars, not shown) and job 15795100118 … https://pypi.org … (285 chars, not shown). The 2026-08-08 evidence (job 15789233388) is superseded — it passed under the old pairing (Environment pypi on both sides) and against TestPyPI only, so it no longer describes a configuration that exists. That first day took two runs: the initial attempt (job 15788368642) was rejected invalid-publisher because the publisher carried an environment and the job declared none, so GitLab omitted the claim entirely. Scope, stated precisely: this proves the AUTH half. It does not prove twine upload over OIDC. Updated 2026-08-09: both release jobs are now armed and OIDC-based — the static TWINE_PASSWORD is gone from both, and the password is minted per-run by ci/pypi_mint_token.py; they remain tag-gated and when: manual. The upload half is exercised for the first time at EC-10 — and was, on 2026-08-14: release-pypi (job 15894781946) minted a token per-run and completed a real twine upload of 0.3.0rc2 to production PyPI, so both halves are now evidenced rather than one asserted from the other. Note also what the traces measured: ci_config_ref_uri carried @refs/heads/fix/drop-pypi-environment and ref_protected = false, and PyPI accepted it — the publishers match the config path and ignore the ref, so any branch in this project can mint. Acceptable for a private single-maintainer project; reconsider on going public. Decision formally recorded 2026-08-15 in SECURITY.md ("Release supply chain — OIDC token minting scope"): accepted with two standing conditions — protect v* tags (maintainer-only creation; a project setting, done by hand), and the acceptance expires on going public or gaining a second contributor. Condition 1 closed 2026-08-18: pattern v* at create_access_level = 40 (Maintainers), read back from GET /projects/:id/protected_tags; the project had no protected tags whatever before this, and all five existing tags now report protected: true. It narrows the release path only — branch minting is unchanged, and condition 2 still stands. Note what this row can and cannot assert: the setting lives in GitLab, so nothing in this repo can verify it and the evidence here is a transcript of one API read, not a check |
| EC-10 | Version invariant at 0.3.0, rcN before tag |
✅ MET | pyproject bumped 0.3.0.dev0 → 0.3.0rc1 2026-08-09, then → 0.3.0rc2 2026-08-13, each ahead of its tag as test_version_agreement.py requires. Closed 2026-08-14: v0.3.0-rc2 tagged at 68eb6e74, tag pipeline 2759402899 green on 22/22, and release-pypi (job 15894781946) uploaded. Verified against the index rather than the job status — https://pypi.org/project/aeolus1d/0.3.0rc2/, and PyPI's JSON API lists ['0.3.0rc1', '0.3.0rc2'] with info.version = 0.3.0rc2; TestPyPI lists the same pair via job 15894781952. Why rc2 and not rc1's tree: main had moved 54 commits past v0.3.0-rc1, three changing shipped example behaviour and one moving the numerics stack the whole build sits on (numba 0.64.0 → 0.67.0, llvmlite 0.46.0 → 0.49.0). The release-facing surfaces (CITATION.cff, SECURITY.md, README badge) remained at 0.2.0 deliberately — they track the latest final release, and a pre-release does not move them. Closed for the FINAL 2026-08-17: v0.3.0 tagged at 26e5ad4f with the surfaces bumped in the release commit itself (bump-then-tag and tag-then-bump are both red against test_release_facing_matches_latest_release_tag, which compares them to the latest final tag — that is why the rc tags never tripped it); tag pipeline 2763935001 green on 18/18 automated jobs with both perf gates passing on their first-ever execution, and release-pypi uploaded the wheel + sdist at 05:06 UTC. Verified against the index, not the job: PyPI's info.version is 0.3.0. Closed again for v0.3.1 2026-08-18: tagged at 775923d6, pyproject and the three release-facing surfaces bumped in the release commit as before, tag pipeline 2765689646 green on 22/22, and release-pypi uploaded at 08:21 UTC — info.version is 0.3.1 on both PyPI and TestPyPI, each listing ['0.3.0', '0.3.0rc1', '0.3.0rc2', '0.3.1']. That pipeline was red first, and the reason is not a code regression: perf-budget, wallclock-budget and integration-slow all failed while an unrelated OpenFOAM job held 4–6 of the runner box's 16 cores; a plain retry on the same commit with the box idle turned all three green with no reference number touched (perf-budget 154.86 s → 77.8 s against an 80 s ceiling). Retrying re-ran only the failed and skipped jobs, so the 15 already-green ones were not re-executed. The third instance of the host-load trap — STATUS.md carries the table. Cut again for v0.3.2 2026-08-19: tagged at bfb71dbe, pyproject and the three release-facing surfaces bumped in the release commit as before, and the root CHANGELOG.md entry added inside the tagged commit — test_root_changelog_names_current_release_line reads pyproject's version, so a tag whose commit lacks the entry makes the tag pipeline red, which is the pipeline that carries release-pypi. Closed 2026-08-19: all four release jobs run by hand at 10:42–10:43, pipeline 22/22, info.version = 0.3.2 on PyPI and TestPyPI, both listing ['0.3.0', '0.3.0rc1', '0.3.0rc2', '0.3.1', '0.3.2']. Unlike v0.3.1, the tag pipeline needed no retry — the host was checked idle (1-min loadavg 0.51, zero containers) before the tag was pushed, and all three contention-sensitive gates passed first execution. Cut again for v0.4.0 2026-10-10: pyproject, the three release-facing surfaces and the root CHANGELOG.md entry moved in the release commit. The tag was moved once before publication: first pushed on cc8bd1c8, its pipeline (2932335961) failed at lock-verify because PySide6 6.12.0 (2026-10-08) had walked the gui extra off the tested set. With the maintainer's yes the extra was capped <6.12 (9830ce29) and the tag deleted through the API (a git push delete is refused for protected v* tags) and re-pushed there. Tag pipeline 2932837691 green on all 17 automated jobs first time, both perf gates included; release-pypi and release-pypi-test run by hand at 07:30–07:32 UTC; info.version = 0.4.0 on both indexes, each listing ['0.3.0', '0.3.0rc1', '0.3.0rc2', '0.3.1', '0.3.2', '0.3.3', '0.4.0'] Cut again for v0.4.1 2026-10-10, a patch with no solver change (it links the now-public docs from PyPI and carries two GUI changes): pyproject, the three release-facing surfaces and the root CHANGELOG.md entry moved in the release commit, as before. Tagged at 10139563; tag pipeline 2934012507 green on all 17 automated jobs first time, both perf gates included; release-pypi and release-pypi-test run by hand at 19:46–19:47 UTC; info.version = 0.4.1 on both indexes, with project_urls {'Documentation': 'https://aeolus-8b65d9.gitlab.io/'}, each listing ['0.3.0', '0.3.0rc1', '0.3.0rc2', '0.3.1', '0.3.2', '0.3.3', '0.4.0', '0.4.1'] (TestPyPI's JSON lagged its simple index by about a minute) Cut again for v0.4.2 2026-10-13, a patch that, unlike v0.4.1, changes code under src/, some of it moving results (the held-back contract fixes and three security fixes, each in docs/changelog.md with its measured change): pyproject, the three release-facing surfaces and the root CHANGELOG.md entry moved in the release commit, as before; tagged on that commit (c02a4d33). Published 2026-10-13: tag pipeline 2941886538 green on every automated job after one retry (perf-budget failed first, 80.9 s against its 80 s ceiling, on a bench host running about 1.65x slow on every series together (wallclock-budget on the same host read 351 s against a 195-218 s nightly band), and passed on a retry at 67.3 s; nothing in product code since the last green nightly reaches the case); release-pypi at 12:05 UTC and release-pypi-test at 12:07 UTC, verified against the index: info.version is 0.4.2 on PyPI and TestPyPI, wheel and sdist |
| EC-11 | Every job enumerated from the pipeline API reached a terminal state, twice | ✅ MET | ci/check_pipeline_terminal.py enumerates jobs and separates non-terminal / failed-in-script / failed-outside-script, with distinct exit codes. Verified against a real pipeline (15/15 terminal). test_ci_job_set_is_enumerated.py pins the classifier against the two traces actually seen on 2026-08-07. Refined 2026-08-08 after it cried wolf at itself — it labelled a job 1029 s into a 972–1533 s baseline [STUCK], and attached a tags:-mismatch hint to created jobs, where that diagnosis is simply wrong. running / pending / created now report separately, all still exit 2, and the exit code is pinned by running the script rather than by inspecting its constants — the first draft of that test passed with the exit code disabled. Then it produced a false pass of its own: a superseded pipeline reported 15 enumerated / 8 terminal-ok / 0 failed / 0 NON-TERMINAL and exit 0 — canceled matched no bucket, so 7 jobs were silently dropped. 8 ≠ 15 was printed on screen and nothing compared them. The buckets now partition the job set, unrecognised states are fail-closed, and exit 0 additionally requires len(ok) == len(jobs) |
| EC-12 | PyPI pending publisher registered | ✅ MET | Both registered by the maintainer and both VERIFIED by exchange, not asserted — TestPyPI 2026-08-08, production pypi.org 2026-08-09, evidence in EC-9. Registration alone was never evidence here: the first TestPyPI configuration was registered correctly and still did not match, so the exchange is the only thing that closes this row. Both publishers now carry a blank Environment name, and both preflight jobs declare no environment: — the pairing that works. No MR can perform the registration step |
What was closed this cycle¶
| MR | What | Merged |
|---|---|---|
| !405 | Read-only instrumentation, gate G1, and the fuel inventory G1 exposed | 003c9d1a |
| !406 | CV-B whole-network energy closure, refusing when it cannot report | 8ff85828 |
| !407 | Residual localised → the charge-air cooler; CV-B closes | 446f7954 |
| !408 | EPW engine CI gate needing no vendor data | pending |
| — | P4 plenum verdict; gates demonstrated to fail | pending |
The defect that mattered most¶
CV-B's 4.3 % residual was the charge-air cooler, a 7–27 kW heat sink
enumerated as nothing. The audit had a refusal for pipe heat closures.
It checked heat, wall_h_conv, q_wall_closure — none of which the
builder ever sets; it sets py_heat. The guard could not fire on any
real input, and its silence read as evidence the term was absent.
Its test passed throughout, asserting against a stub shaped like the guard rather than like the builder's output.
That shape recurred four times in one session: a guard probing
attribute names no object has; a test asserting against a matching mock;
a network driven without a Clock (silently freezing the piston, and
looking exactly like a physics defect); and a regression tier compared
against references that had just been regenerated. None raised. All
produced confident, green, wrong results.
The countermeasures now in tree: enumeration tests that build the real
network from the real TOML; a test asserting every attribute the audit
branches on exists on a built Pipe; gates demonstrated to fail on
fabricated data; and a ratchet on dual-tolerance criteria.
What the GCI says, and what it does not¶
Re-measured 2026-10-10 on the v0.4.0 tree (EC-Q7)
u_num is now ±0.015 °C (p = 2.28, Richardson 427.233 °C) and the
anchored errors are −10.53 / −35.95 / −29.29 °C. The conclusion below
stands: the mesh contributes nothing next to the model error.
T_EXH carries u_num = ±0.035 °C against a measured error of +20.5 °C
versus AVL. Discretisation contributes essentially nothing to that gap:
it is model form, not mesh. This retires "perhaps it is under-resolved"
as an explanation, which is worth more than the uncertainty band itself.
Re-measured 2026-09-07 — the error figure above is stale, and its SIGN flipped
The +20.5 °C dates from 2026-08-08. Measured anchored over the same
three anchor ops on 2026-09-07 the errors are −15.18 / −47.09 /
−43.09 °C: max 47.09 °C, and negative. The model now runs COLD
where that figure said it ran hot.
The conclusion the paragraph draws is unaffected, and in fact
strengthened — that is why the text stands. u_num = ±0.035 °C is
now 0.07 % of the error rather than 0.17 %, so "model form, not mesh"
holds a fortiori. What changed is the size and direction of the gap,
not its cause.
Cause: 975211ec corrected the HP-EGR reservoir, which legitimately
cooled the charge, and wall_h_conv was left identified against the
old, hotter model. A refit was measured on 2026-09-07 and refused —
it failed a pre-registered rule at a 109.9 % spread. See BACKLOG.md.
The observed order is 1.286, not the 2 the case's scheme = "muscl"
would suggest — limiters and the orifice-valve coupling degrade it.
Measured, not assumed.
u_val is deliberately NOT claimed: the AVL CSV header documents no
instrument uncertainty, so there is nothing to derive one from, and the
criterion stops at u_num.
The one deliberate band exception¶
Six of seven bands were re-sized from their measured residuals onto the 1/2/5 ladder, satisfying both rules. The net effect is a tightening (IMEP_Low 12→2, MFB50 20→10, PFP 30→20, BMEP 6→5), verified against the vendor suite in both modes.
abs_err_T_EXH is the exception, and deliberately so.
The EC-1b / EC-1c tension, which is a real property of the channel¶
The two rules bracket the band from both sides:
3 * residual <= band <= 3 * delta_min
EC-1b keeps a gate from sitting inside its own known error; EC-1c keeps
it narrow enough to see the regression it was written for. Together they
require delta_min >= residual.
For abs_err_T_EXH that window is empty. The measured residual is
20.5 K, so EC-1b needs band >= 61.6; its declared delta_min of 14 K
caps the band at 42. Raising delta_min to the residual collapses the
window to the single point 61.6, and no 1/2/5 ladder value (50, 100)
lies in it.
That is not a bookkeeping slip. It says the T_EXH residual is large enough, relative to any credible smallest-detectable-change, that no band can both avoid failing on it and detect a regression smaller than three times it. The honest response is to record it — widening the band until the tension disappears would make the gate unfailable, which is the defect the whole exercise exists to prevent.
abs_err_PFP shows the same arithmetic for a different reason: its
predictive error reaches 25.7 bar (needing band >= 77), but it is
report-only in predictive mode, and its anchored residual of 4.2 bar
needs only 12.7 — which the 30 bar band satisfies comfortably.
Both are pinned in test_criteria_failability.py with the measured
numbers, and the exception list is itself checked: if a residual falls,
the exception goes stale and the test says so.
Bands are deliberately not re-quantised onto the ladder. Laddering up loosens every one of them (6→10, 4→5, 12→20, 30→50, 40→50); laddering down tightens, which can turn a working suite red. Either is a calibration decision that needs a vendor run behind it, not a tidy-up.
NOx: the kinetics are fixed, and the residual is one temperature¶
NOX read exactly 0.000 ppm against AVL's 240–509 for months, then
306 / 312 / 318 — suspiciously flat across a 2× load range. Both were
symptoms, and the causes are now found and fixed.
The zero was the integration window. The rate was integrated over the
full 720°; feeding blowdown and gas-exchange samples to the stiff solver
drove it to return 0. Narrowed to [IVC, EVO) it returned 306 / 312 / 318.
The flatness was the N₂O path, and it is corrected (2026-08-26). Two
independent errors, both in the magnitude of R4 = k4·[N₂O]ₑ·[O]ₑ:
k4_Awas2.9e10m³/(mol·s) = 2.9e16 cm³/(mol·s), 300× above the bimolecular collision limit. GRI-Mech 3.0 givesN2O + O <=> 2 NOas2.9e13cm³/(mol·s); the shippedk4_Ta = 11650K is exactly 23150 cal/mol ÷ 1.9872, so the activation energy came from GRI and only the unit conversion slipped — ×1e-3 where the other four constants in the same block correctly use ×1e-6. Now2.9e7.K_N2O_Eawas 0.0, giving N₂ + ½O₂ ⇌ N₂O no enthalpy, so [N₂O]ₑ never fell as the charge cooled. Derived from ΔrH° = +82.05 kJ/mol and ΔrS° = −74.225 J/(mol·K) asK_N2O = 9.339e-5·exp(−9.1125e4/RT), a van't Hoff fit good to 1.83 % over 1600–3000 K.
Neither alone is a fix — either one reverted leaves the rate 10²–10⁵ too fast, which is why this was pinned as a pair and never patched. Together the total lands at 0.99–1.81× Heywood eq. 11.1 across 1600–2400 K (was 9.5e5–1.3e8×), sits just above the thermal path as an additive channel must, and the N₂O share runs 8.5 % at 2400 K to 62 % at 1600 K — the lean, low-temperature dominance the path is in the model for.
A third defect went with them. integrate_zeldovich_nox could step
over the ~1.3 ms active window and return an all-zero history with
success = True — worse than the stall path, which at least warns.
max_step is now capped at the caller's own sample spacing, and a
converged all-zero result across an active window is refused.
NO now freezes kinetically. Sweeping T_active_min 1500 → 1900 K moves
the answer by 0.08 %, where it used to move it 9.5× on an unchanged
trajectory. End-of-cycle NO retains 79–100 % of the cycle peak.
That exposed the entity defect, which is now also fixed. With the
kinetics right the channel read 17.8 / 3964.9 / 5875.2 ppm — a 330×
spread against a reference that moves 2.12× — because last_T_burnt is a
mass-average burnt zone. It sits near the global φ, so it swung
582 K (1995 / 2429 / 2577 K) across the load range, and exp(−38370/T)
turns 582 K into 330×. A diesel flame sheet does not do that: it burns near
stoichiometric at every load.
aeolus1d.analysis.core_zone_temperature tracks burned-gas elements at
phi_core = 1. Each forms at the constant-pressure adiabatic flame
temperature — a twelve-species equilibrium enthalpy balance, so dissociation
is solved rather than correlated — and is then compressed isentropically by
the pressure history, which is what gives the burnt gas a temperature
distribution rather than a mean. The entrained charge is the actual
trapped gas, so recirculated CO₂ and H₂O cool the flame: measured, dropping
Y_O2_IVC from 0.2334 to 0.150 takes it 2666 → 2273 K.
Measured 2026-08-26, --ops 3 predictive:
| op | AVL | well-mixed | core-zone | T_burnt | T_flame |
|---|---|---|---|---|---|
| 1200 rpm / 780 N·m | 293.1 | 17.8 | 927.0 | 1995 K | 2715 K |
| 1500 rpm / 1473 N·m | 240.4 | 3964.9 | 1802.6 | 2429 K | 2814 K |
| 1800 rpm / 1769 N·m | 509.4 | 5875.2 | 2198.7 | 2577 K | 2863 K |
| mean |error| | — | 899 % | 399 % | ||
| spread | 2.12× | 330.66× | 2.37× | 582 K swing | 148 K swing |
Re-measured 2026-10-11 — the table above is history.
--ops 3predictive, run onmainat56dad407and on the step 2 branch (fix/step2-held-back-findings), same host, same day. The two agree within 5.3e-5 relative on every channel except one, an MFB50 defect found by this run and fixed on the branch before it landed (changelog.md, Unreleased).
op AVL well-mixed core-zone T_burnt T_flame 1200 rpm / 780 N·m 293.1 5.6 892.5 (3.05×) 1890 K 2648 K 1500 rpm / 1473 N·m 240.4 27.8 620.7 (2.58×) 1997 K 2617 K 1800 rpm / 1769 N·m 509.4 635.9 1229.2 (2.41×) 2189 K 2755 K mean |error| — 168 % Geometric-mean over-prediction 2.67×, not 4.68×. The 1500 rpm dip is reproduced (1500/1200 = 0.695 against AVL's 0.820), so reason (2) below is superseded. The 1500 rpm op did not reach a sustained plateau in this run, on
mainor on the branch, so its row is not converged. The last figures recorded before today were 821.6 / 630.7 / 1126.9 (2026-08-27,BACKLOG.md). What moved them since has not been bisected.
The core-zone value is now the compared one. It is better on every axis and is the physically right entity; leaving the worse number in the comparison because the better one is still wrong would not be defensible.
Validated against a number that was not fitted: stoichiometric
CH_1.9167 in dry air with reactants at 298.15 K and 1 atm gives 2267 K,
against a published 2250–2300 K. n_elements is a discretisation, not a
knob — the answer moves 0.5 % over 6 → 48. And setting
phi_core = phi_trapped reproduces the well-mixed zone it replaces, so the
new model contains the old one as a limiting case.
Still report-only, for three stated reasons. (1) A systematic 4.68×
over-prediction with zero fitted parameters; the likely missing physics is
burnt-gas mixing during expansion, since the elements cool isentropically
and freeze hot. Do not calibrate phi_core to close it — a
stoichiometric sheet is the one physical claim the model makes. (2) AVL's
1500 rpm dip is not reproduced; the input CSV shows it is an EGR effect
(HP_EGR_Rate 21.5 / 28.4 / 21.4 %), our loop delivers the right
trapped oxygen to within 3 %, but the flame does not cool enough to invert
the load trend — and PFP +24.5 bar and MFB50 −2.7 CAD both push it the
other way at that op. (3) G1 fails, on one cause and upstream of the
partition: volume.m and sum(m_species) disagree by 0.178 % at IVC,
before combustion, and that single gap drives all three of the gate's
criteria. Its substantive check — two independent paths to the surround
element counts — agrees to 5.5e-15.
Every non-NOx channel is bit-identical throughout: this is a post-processor with no feedback into the solve.
Two retractions, both from one run.
- The 2026-08-08 "TWO COMPENSATING ERRORS — the burnt-zone T exceeds
~2500 K" inference was drawn from a
include_n2o=Falsemeasurement of 0.000 / 0.000 / 5675 ppm in which two of the three ops were the integrator artefact above. "Above ~2500 K" was true of the one op that was real (2577 K at 1800 rpm) and false of the others (1995 K at 1200 rpm). - The 2026-05 diagnosis the changelog marked SUPERSEDED — "the well-mixed burnt zone peaks at 1960 K … it needs a local-φ / stoichiometric-core model, not kinetics tuning" — was right about the temperature (1995 K measured at the same op) and right about the mechanism. It was wrong only that this explained a zero. Superseding it wholesale discarded the correct half.
A NEW compensating pair, named before anyone trips over it.
equilibrium_NO_ppm is a two-point T-only fit that understates real
12-species equilibrium NO by 2–11× and carries no composition dependence
at all. It is currently the ceiling limiting the 1800 rpm op
(NO_max 7475 against the fit's NO_eq(2577 K) = 7681). Correcting it in
isolation raises the two over-predicting ops further and makes the channel
worse. Fix the burnt-zone temperature first.
EC-4 is unaffected — it accepts report-only with a measured, current reason, and the reason is measured and current.
Known limitations, stated¶
- The species pool does not close during gas exchange (~12 %). Closed-period accounting is exact — inert-species drift identically zero — so it is localised to valve-flow bookkeeping, not combustion or storage. Measured and reported; deliberately not gated at a tolerance fitted to the observed number.
- The bundled
89_epw_diesel.tomlis Wiebe-only, so CV-A and the species checks cannot run against it. They run againstbase_diesel.toml, which is committed. Pinned as a divergence that goes red if closed. - The model's CAC duty (18.8 kW at 1500 rpm) is below AVL's measured
27.4 kW. A constant-
UAclosure whose own comment predicts this; not addressed. -
plenum_wall_fractionis 0.0837 — PARTIALLY CLOSED. The term is not proven physical;h·Aandwall_Tremain jointly unidentifiable againstT_EXHalone.↳ RE-MEASURED 2026-09-11: 0.1646 → 0.0837, per-op 0.0725/0.1449/0.1646 → 0.0536/0.0791/0.0837. The sink has halved and two of the three ops are now under the 0.08 "closed" line. The verdict does not change — the pre-registered rule branches on the max, and 0.0837 is still the middle band — but the margin is now 0.0037 where it was 0.0846. Harness:
run_validation.py --ops 3 --anchor-bcs --no-predictive --network-audit, tree4dd98c4f, CV-B closes at all three ops. Note this did not shrink theT_EXHresidual, which came back bit-for-bit unchanged at −15.184 / −47.092 / −43.090 K.
Before anyone publishes¶
~~1. EC-9 — prove the TestPyPI OIDC exchange.~~ DONE
2026-08-08, job 15789233388,
via oidc-preflight-testpypi. Note it took two runs: registration by
itself was not evidence, and the first configuration did not in fact
match. Register → verify, never register → assume.
Still open:
- Wire OIDC into
release-pypi-testand delete its- when: never. The preflight proves the auth half only; the release job still authenticates with$TEST_PYPI_API_TOKEN, which is the path that closes the pending-publisher window if it is ever used. It must declare noenvironment:, matching both publishers — the guard pins each PyPI-token job to its own index's registered config, so a release job that disagrees fails the suite rather than failing at the exchange after a preflight had certified it. - EC-12 (production) — register the pending publisher on pypi.org
(human, web UI) and add an
oidc-preflight-pypijob withaud: pypi. Exact field values: PyPI registration. The pending-publisher window closes on the first token upload. - EC-10 — bump the version surfaces in the same MR the tag is cut
from, not before.
test_version_agreement.pycomparesgit describeagainstpyproject.toml, and0.3.0rc1 > 0.3.0.dev0under PEP 440, so tagging first turns it red. The tag isv0.3.0-rc1— with the hyphen; the CI rule does not matchv0.3.0rc1.
(An earlier revision of this list carried a fourth item claiming EC-1c's
ladder quantiser was "still missing". It contradicted EC-1c's own MET row
three screens above, and was stale — the quantiser exists and all 7 gated
criteria satisfy band ≤ 3·delta_min, with T_EXH's off-ladder value the
single documented exception. A readiness document that disagrees with
itself is EC-8's exact failure mode, so it is removed rather than
softened.)
PyPI publication is one-way: a yanked version does not remove pinned installs. None of the above is a formality.