# 📐 Phase 17 — R1 pose-objective parity


## WP-178 — the pose section was there all along

<a id="wp-178"></a>

**A false premise, found from outside.** Issue 2 on the tracker reports what the record should have caught itself: R1 sec. 3.4.2 is a pose-estimation section — a sigmoid-bounded sigma branch, Eq. 10's normalized residual, Eq. 11's RLE term and an OKS-based loss the two are combined with — Table 9 ablates the OKS/RLE split and Table S10 publishes every pose scale's size, all inside R1's allowlisted role. D21, A65, A68, `_build_keypoint_stem`'s docstring, `test_param_flops.py`'s comment, the [WP-139](https://borda.github.io/lucid-YOLO/work_packages/wp-139/) entry above and both keypoint sections of the reproduction report say R1 has no such section. The citation map shows how: sec. 3.4.1 and 3.4.3 are cited across the tree, Eq. 7–9 and 13–15 likewise, and nothing between them — the gap was visible without opening the paper, and `model_cards/keypoints.md` had quoted Table 9's 63.0 since 0.5.0 while calling it a different paper's number. Confirmed 2026-09-17 against the arXiv PDF, not against the issue's paraphrase.

**What changes and what does not.** D22 withdraws D21's rationale clause and nothing else — the tier stays keypoints-not-pose, classification and YOLOE-26 stay out — and states what shipped as a *partial* reproduction of R1's objective: Eq. 11's RLE term, transcribed from R14 where R1 itself draws it, without the OKS term. In the registers and logs — DECISIONS, ASSUMPTIONS, this log, PROVENANCE — every stale sentence is struck in place with the date, on D21's own reasoning about records; the reproduction report, append-only under D10, gets a correcting note after its last section and its 0.5.0 section stays byte-identical; the model card, two docstrings and a test comment are rewritten outright with a dated note, since none of those is a record of a decision. Three findings the section forces are registered rather than resolved. A75: R1 gives the OKS loss no formula and cites it to a paper outside the allowlist, so `losses/oks_loss.py` derives `1 - OKS` from R12's own definition — box area for `s^2`, since a keypoints batch carries no segment area — and enters at `oks_gain = 0.0`, keeping the shipped objective bit-exact and [WP-125](https://borda.github.io/lucid-YOLO/work_packages/wp-125/)'s paired-run evidence meaningful; Table 9's `(24, 1)` is the published split no run here has tested. A76: R1's printed Eq. 11 carries `log sigma` twice, once as the reparameterization Jacobian and once inside a Laplace term written in the un-normalized frame, where R14 Eq. 8 — what the code carries — has it once; the two differ by a `1/sigma` gradient on every scale, and which R1 means is not settled by its text, so the form stays and the paired run that would settle it is a roadmap item. A77: measured at Table S10's protocol the point stem is 13.0% / 26.5% under the paper's `n` params / FLOPs and converges by `l`/`x` — the same finding A20 made for the angle stem before its width was chosen against Table S11, and it gets the same treatment: recorded, gate deferred until a width is selected against the table, nothing tuned in a correction commit.

**What the term costs switched off.** `_score_branch` gathers each branch's assigned positives once and scores them with both terms, so the added cost at `oks_gain = 0` is one OKS evaluation per branch per step over already-gathered tensors — an exponential over the labelled points, no decode and no second argsort. It is logged pre-gain as `train/oks` for the same reason every other term is, so a run can see the number it is not yet optimizing.

**The lesson is a check, not a resolution.** Before the record says a source does not cover something, grep what the record already cites from that source and look for the hole: two adjacent subsections cited and the one between them never is not an absence in the paper. `.notes/lessons.md` carries it as a rule.



## WP-183 — the box terms scored densely, so the step traces as one graph

<a id="wp-183"></a>

**A boolean gather is a shape the compiler cannot know.** `pred_boxes[fg_mask]` lowers to `aten.nonzero`, whose output length is a property of the data, and `torch.compile` answers that with a graph break; the detection step carried seven of them, at the masked gathers. Recorded 2026-09-18. The replacement evaluates every term over the full `(B, A)` anchor grid — CIoU through a `reshape(-1, 4)` since its contract is two-dimensional, ProbIoU directly since it broadcasts, the L1 differences against the dense stride, the angle term over the whole branch — and selects the positives afterwards. The selection is a `torch.where`, not a multiply by the mask: a background anchor's target is the all-zero box, so the dense CIoU there is scored on a degenerate pair, and `0 * NaN` is still `NaN` where `where` is an exact selection in both the value and the backward pass. What remains is a dense sum in which every off-positive term is an exact zero, and `square_angle_loss` already documents whole-branch input with `q_i = 0` background as its intended second use.

**Loss-identical, measured rather than argued.** On a COCO batch the two forms differ by an absolute `0.0` on every term, the gradients stay finite on an image with no positives, and the pre-change CPU step snapshot holds at its existing tolerance; in eager the step is 2% faster, with the seven `nonzero` calls gone. The masked implementation is kept verbatim as a private oracle in each test module, and the dense form is held against it on random assignments — an image with no positives, an all-background batch, and degenerate predictions (zero-area, inverted, all-zero, both sides collapsed) planted on background anchors — to `1e-6` on values and gradients. The oriented oracle draws `gt_index` from `[0, N)` with `N ≥ 2`, so the gather over the padded instance axis is exercised on every column rather than only its first. WP-184 is what the one-graph step is for.

**What the accelerator gate said afterwards (2026-09-19).** On an A100-40GB, two of the five `goldens/gpu/` rows moved out of band under this change alone: keypoints `0.5696 → 0.4919` and oriented `0.9783 → 0.9088`, per-commit on the same machine, with WP-182's layout change accounting for `−0.021 / −0.040` of the path and detection, segmentation and `shapes_regression_det` holding. The arithmetic is the same; the summation order is not, and the overfit gate is a 100-epoch trajectory that amplifies a `1e-7` per-step difference into a different endpoint — the same sensitivity the [WP-177](https://borda.github.io/lucid-YOLO/work_packages/wp-177/) escalation measured across seeds on unchanged code. The principal resealed both rows at the measured values (ESCALATION 2026-09-19, WP-186); the oriented band is now `clamped_tolerance`-narrow at `± 0.0088`.

**And what three seeds said (2026-09-20).** The control the escalation asked for, run per commit on the same A100: the oriented move that looked monotone at HEAD splits into WP-182's `−0.020` (3 of 3 seeds down) and this change's `−0.014` (2 of 3), and keypoints span `0.49`–`0.73` across nine runs of the same objective. A layout change with no arithmetic in it producing the same signature as the loss rewrite is the evidence that the signature is the gate's, not the change's. Full table in ESCALATION 2026-09-19's follow-up.



## WP-188 — notebooks as sources, the `.ipynb` as a build product

<a id="wp-188"></a>

**The format question was decided by what the gate can read.** Recorded 2026-09-21. A notebook is worth publishing only while it still runs, and nothing about publishing one checks that; so the first decision was that every notebook is a `.py` the existing machinery already handles -- ruff lints it, `license-headers` scopes it, and `tests/notebooks/test_notebooks_run.py` runs it as a script under `LUCID_NOTEBOOK_FAST=1` on every `make gate`. Jupytext's percent format is the one that costs nothing: a `# %%` line is a comment to Python and a cell to the converter. The `.ipynb` follows from the `.py` at build time in two places, `docs/notebooks/` for the site and `notebooks/<name>.ipynb` on `gh-pages` for the Colab badge, and neither is ever committed to `main`; `no-ipynb-tracked` reads the index on every commit because `.gitignore` is advice and `git add -f` ignores it (D25). The script run ruled out one thing: the Colab bootstrap could not be a `!git clone` line, because jupytext writes a magic into the `.py` as a comment and a guarded block of comments alone is a `SyntaxError` when the file runs as a script -- so the bootstrap, and after it every console command the demo notebooks ran, went through `subprocess`.

**The script run is withdrawn; the gate executes the notebook through a kernel.** Revised 2026-09-21, once the four demo notebooks existed. Each of them is a sequence of console commands -- `lucid-data download`, `lucid-yolo fit`, `lucid-eval`, `lucid-predict` -- and as `subprocess.run([...])` lists they read as argv arrays, not as the command lines `docs/TRAINING.md` spells. The user's ask was that they read as plain shell, and that decides the gate's mechanism rather than the other way round: each command is a `%%bash` cell -- `# %% language="bash"` in the `.py` with the script commented beneath, comments to a script run -- so the proof moves from "run the `.py`" to "convert the `.py` and execute the `.ipynb` with a kernel". The test now runs `jupytext --from py:percent --to ipynb --execute --set-kernel python3 --run-path <repo root>` per source, the same conversion `make notebooks` makes plus execution, and the `subprocess` reasoning above no longer applies because nothing runs the file as a script any more; the bootstrap is `!git clone` and `%pip install -e lucid-YOLO` under the same `google.colab` guard, with `os.chdir` and the `sys.path` insert keeping the block a block. Values cross into a shell cell through the environment -- the Python cell above does `os.environ.update(DATA_ROOT=..., FIT_ARGS=...)`, the fast budget being one such string of flags -- and a switch the shell itself has to make reads `$LUCID_NOTEBOOK_FAST`, so the download the fast run skips and the `lucid-predict` loop only the fast run makes are `if` blocks in bash beside the command they guard. Three things were measured rather than assumed. A `!` line does not fail the notebook -- `!false` followed by a `print` printed, under `nbclient`, with the cell green, IPython storing the status in `_exit_code` and continuing -- which is why the commands are `%%bash` cells and not `!` lines: the script magic raises `CalledProcessError` on the cell's exit status and nbclient stops, as a probe with `false` confirmed; that status is the last command's, so a cell with more than one opens with `set -e`. `--set-kernel -`, jupytext's spelling for "the current interpreter", raises `No kernel found that matches the current python executable`: the kernelspec `ipykernel`'s wheel installs into `.venv/share/jupyter/kernels/python3` has `argv[0]` equal to the bare word `python`, and jupytext compares that to `sys.executable` with `os.path.samefile`. `--set-kernel python3` names that spec directly, and `jupyter_client.manager.KernelManager.format_kernel_cmd` swaps a bare `python` for the launcher's `sys.executable` -- the venv's, since pytest runs jupytext as `python -m jupytext` -- which a probe confirmed from inside the kernel; so there is no `ipykernel install` step, and the test sets `JUPYTER_PATH` to the venv's `share/jupyter` so a user-level `python3` spec, which the search order otherwise puts first, cannot take the name. And `--from py:percent` is not optional: jupytext's `guess_format` counts an *indented* `    # !git clone` -- the Colab block's -- as an unescaped magic, because its escaped-magic test is `not line.startswith("#")` with no `lstrip`, and guesses `hydrogen`, a format that leaves every `# !` line a comment. A jupytext header after the SPDX line and a `[tool.jupytext]` table in `pyproject.toml` were both tried and neither reaches the CLI reader, so the flag went into the test, the `Makefile` and `docs.yml` together; without it the published `.ipynb` would carry `# !git clone` as a comment and the badge would open a notebook that clones nothing. The `dev` group gains `jupytext`, `nbconvert` and `ipykernel` -- `nbconvert` and not `nbclient`, because `jupytext --execute` imports `nbconvert.preprocessors.ExecutePreprocessor` and declares neither as a hard requirement -- and the licence audit on the dev environment stayed clean at 152 distributions. Under `LUCID_NOTEBOOK_FAST=1` the five sources take 18–32 s each through the kernel on an Apple-silicon CPU (the four-task wiring gate the 32 s), kernel start included, against the test's 60 s ceiling, which therefore did not move. The full runs' paths were settled at the same time: a dataset goes under the gitignored `data/` of the checkout -- `data/coco2017` for the three COCO demos, `data/dota` and `data/dota_tiles` for the oriented one -- and the run under `results/demo_<task>`, the Colab detect demo keeping its Drive guidance; the fast run keeps its temporary directory.

**The demos moved off the shell cells; the kernel run stays.** Revised again 2026-09-21, once the four demos were rewritten onto the library: every step is now a call on an object the kernel holds -- `download_coco`, `DetectionLitModule` and `DetectionDataModule` built from the tier's YAML values, `Trainer.fit` and `Trainer.validate`, `lucid_yolo.predict` and `lucid_yolo._viz` -- and the console command is quoted as prose, never run, so the `%%bash` cells, the `os.environ` hand-off and the `$LUCID_NOTEBOOK_FAST` test in bash are gone from them. The three measurements above still stand as measurements, they just no longer bear on the demos. What keeps the gate on `jupytext --execute` through a kernel rather than a script run is the bootstrap: `!git clone` and `%pip install -e lucid-YOLO` are magics, written into the `.py` as comments, and a script run would read them as comments and prove nothing about the two lines a Colab reader depends on; the `--from py:percent` reasoning is theirs as well. One consequence for a figure cell: its last expression is `None if FAST else prediction_grid(640)`, because a bare name as the cell's last line is `B018` to ruff and a call is not. Measured on an Apple-silicon CPU, the four demos take about 11 s each under the fast contract, against 18-32 s through the console scripts. The gate notebook went the other way for the same reason, one file later: the four overfit commands and the shapes regression are development tooling with no library entry point, so `notebooks/development_gates.py` runs them as the `!` lines TRAINING.md spells, `$PROBE` appended under the fast contract -- `--epochs 1 --batches 4`, a probe override the overfit script gained on the shapes script's model, reporting PROBE with the score and enforcing no floor; 26 s for all five commands.

**What the scratch-venv audit found.** The docs group alone resolves to 43 distributions with `jupytext` added and 77 with `mkdocs-jupyter`, the 34 between them being `nbconvert`, `nbclient`, `ipykernel` and what they pull; `pyzmq` (BSD-3-Clause) is the one shipping a native binary the audit's wheel scan walks. Licence audit clean at both counts, nothing copyleft. `docs.yml`'s build job is the only CI environment holding this tree, and its audit step already runs before the build, so the exposure is checked where it enters. The site is rendered with `execute: false`, so that job stays torch-free, which is what keeps the docs group small enough to audit by hand as well.

**The `--strict` risk was checked with a throwaway notebook and did not fire.** The plan flagged that `mkdocs-jupyter` might warn on a converted file missing kernel metadata, which `--strict` would turn into a failure. A percent-format source with one markdown cell and one code cell, converted by jupytext and listed in the nav, built strict with no warning and rendered its cells; jupytext writes the kernelspec. The source and its `.ipynb` were deleted after the build. Nothing in the shipped tree depends on this, since WP-188 lands zero notebooks -- it is recorded so [WP-189](https://borda.github.io/lucid-YOLO/work_packages/wp-189/) does not re-derive it.

**Two checks moved from the site to the audit, and one rule changed shape.** `check_every_nav_entry_points_at_a_file_that_exists` used to ask whether the nav target was a file under `docs/`; a `notebooks/<name>.ipynb` target now resolves against `notebooks/<name>.py` instead, whether or not the `.ipynb` has been written yet. The alternative -- accept the entry when the generated file is on disk -- would have answered differently on a warm checkout and a cold one, and a checker whose verdict depends on whether `make notebooks` was run is a checker that passes locally and fails in CI. `check_every_notebook_source_has_a_nav_entry` is the page rule (a register nobody can navigate to is one nobody reads) applied from the source side, and it tolerates a `mkdocs.yml` with no `Notebooks` section while no source exists, because MkDocs rejects a nav section with no children and the section therefore cannot arrive before the first notebook does.

**The badge decided how the site is published.** The first draft of this row added a `publish-notebooks` job writing the `.ipynb` files to an orphan `notebooks` branch, beside the existing Pages artifact deploy -- two publishing mechanisms for one site. The revision (2026-09-21) collapses them: the site itself now goes out as a branch deploy, `ghp-import -n -p` committing `site/` onto `gh-pages` on top of that branch's history, and the converted notebooks are copied to `site/notebooks/<name>.ipynb` before the import so the badge's `blob/gh-pages/notebooks/<name>.ipynb` is a path on the branch Pages serves. `mkdocs-jupyter`'s `include_source` places its own copy at `site/notebooks/<name>/<name>.ipynb`, beside the rendered page; the flat copy is the badge's, and both ride the same commit. `ghp-import` arrives with mkdocs, so the docs group gains nothing for it; `deploy-pages`, `upload-pages-artifact`, `configure-pages` and the `pages`/`id-token` permissions leave the workflow, and `contents: write` sits on the one job that remains -- a pull-request run holds it too, since a job's grant is fixed for the job, and never reaches the step that uses it. The `Makefile` and both workflow loops iterate a shell glob with an existence test rather than `$(wildcard)`, since `for f in ;` is a syntax error under `/bin/sh` and an empty `notebooks/` is the state this row ships in.



## WP-189 — the drawing code moves into the package, and the dependency it already had is declared

<a id="wp-189"></a>

**Four notebooks would have carried four copies.** Recorded 2026-09-21. [WP-188](https://borda.github.io/lucid-YOLO/work_packages/wp-188/) made `notebooks/*.py` gate-run sources, and the first four notebooks each want two figures: a run's curves and a checkpoint's predictions over a few images, labels beside them. Both existed — `scripts/plot_training.py` drew the first and `scripts/draw_predictions.py` the second — and a notebook cannot import a script, so each notebook would have re-spelled the drawers inline. That is the failure the [WP-067](https://borda.github.io/lucid-YOLO/work_packages/wp-067/#wp-067) entry names from the other direction: a notebook nothing tests rots, and drawing code copied into four of them rots four ways at once, each copy free to draw the rotated box as its envelope again. So the functional core of both scripts moved to `src/lucid_yolo/_viz/` — `curves.py` for the panels and the CSV reader, `overlay.py` for the four per-task drawers, the renderer table and the checkpoint-to-axes path — and each script is now its argument parse, its backend selection and its write. The notebook entry points are two: `plot_curves(metrics)` returns the report's figure titled by the run directory, and `show_predictions(checkpoint, images, annotations)` loads the checkpoint once, draws one panel per image, and lays the labels under the predictions when an annotation file is given; a notebook that already holds the trained module draws each panel with `predict_and_draw(module, image, options, axes=ax)` and `draw_ground_truth(ax, targets, task)` instead, the skeleton for a pose grid travelling in `RenderOptions.skeleton` so the four tasks share one call shape. Every public name the scripts published stays importable from them as a re-export, so `scripts/_tests/test_draw_predictions.py` keeps its command-level cases and the drawing cases moved to `tests/viz/`, where the code they assert now lives.

**Ground truth is dashed, unfilled, and read by the training reader.** The new drawer, `draw_ground_truth(axes, targets, task)`, draws the label layer in the task's own geometry — boxes, instance rings, rotated quadrilaterals through the same `rboxes_to_polygons` the predicted rings use, boxes with a hollow ring at each visible point — and never fills or writes a score, so a prediction drawn solid on the same axes stays tellable from its target without a legend. What it takes is a `Targets`, which is what `CocoDetectionDataset` yields per image in the reading mode the task needs (`oriented` for `obb`, `keypoints` for `keypoints`); the file is not parsed a second time. The dataset needed two accessors for that — `file_names`, the companion of `image_ids` for a caller who arrives with a file rather than an id, and `targets_at(index)`, the precomputed targets without the image decode `__getitem__` performs — and both are the WP's one edit outside `_viz`, the scripts and the tests.

**Private, and matplotlib is a runtime dependency — because it already was.** The [WP-067](https://borda.github.io/lucid-YOLO/work_packages/wp-067/#wp-067) paragraph kept the drawers out of the package on the claim that `matplotlib` sits in the `dev` group and "a wheel a consumer installs pulls no plotting stack". Checked here rather than repeated: `pytorch-lightning[extra]`, in `[project].dependencies`, requires `matplotlib>3.1` (`importlib.metadata.requires("pytorch-lightning")` lists it under `extra == "extra"`), so every consumer install has pulled matplotlib since that extra was declared, and `torchmetrics` imports it the moment it finds it installed. The claim was false before this row and the row changes nothing about what a consumer receives. What it does change is the declaration: `matplotlib>=3.8` moves from the `dev` group to `[project].dependencies`, on the jsonargparse and tqdm precedent already in that list — a direct import is not a transitive dependency — and because [WP-168](https://borda.github.io/lucid-YOLO/work_packages/wp-168/)'s release guard reads declarations, not the transitive graph: `check_dependency_tiers` refused the tree with `_viz` importing a group-only distribution, and five `scripts/_tests/test_release_guard.py` cases (`test_happy_path_passes`, `test_main_without_tag_falls_back_to_head`, `test_metadata_only_leaves_gate_command_unrun`, `test_certifying_run_invokes_gate_exactly_once`, `test_metadata_only_verdict_refuses_to_call_tag_shippable`) failed on the live tree until it moved. The package is still `_viz`, underscore first, and the underscore is now an API contract rather than a dependency one: nothing else in `lucid_yolo` imports it, nothing outside it imports matplotlib, and the notebooks are its only callers, so its signatures can move with them. `tests/viz/test_import_surface.py` pins that from two sides. The dynamic side imports the shipped surface — `lucid_yolo`, `lucid_yolo.predict`, `lucid_yolo.eval.checkpoint`, `lucid_yolo.data` — in a fresh interpreter and asserts `lucid_yolo._viz` never entered `sys.modules`; the static side reads every shipped module's source and refuses an import line naming matplotlib or `_viz` outside the `_viz` subtree, which is the check that catches a function-local import no import of the surface executes. Neither asserts matplotlib absent from `sys.modules` after the import: torchmetrics loads it (`torchmetrics/utilities/plot.py`), and that is theirs.

**No backend is selected in the package.** The plan had `_viz` keep the scripts' `matplotlib.use("Agg")` at module top. It does not: `use("Agg")` is a process-wide setting, and a notebook importing a package that makes it is switched away from its inline display — figures returned from a cell would stop rendering. So the backend selection stays in the two scripts and the two test conftests, which are the headless callers, and the package imports `pyplot` unconditionally with no `use()` before it. For the same reason `build_run_figure`, `plot_curves`, `predict_and_draw` and `show_predictions` return their figure open: the script saves and closes, a notebook displays, and closing inside the package would serve the first by breaking the second. The `svg.hashsalt` moved with the backend line, since it is the committed figure's concern and the same kind of global.



## WP-192 — the complete oriented evaluator uses the worker policy it already had

<a id="wp-192"></a>

**Timing evidence is host-specific and non-gated.** The off-repository procedures were profiling the public scoring loop, comparing five-run medians with `channels_last`, traversing the loader, and calling public `run`; the warm-up and repetition procedure is recorded below where available. The benchmark harness and raw output are not committed.

**The standalone path predates the image-size target and inherited-validation guard.** The evaluator's explicit `num_workers=0` arrived in `0fe08f4` on 12 August. The free-`/dev/shm` cap on automatic workers was already present from `9f1fa80` on 2 August; the image-size validation worker target (`9d0fa88`) and inherited-validation guard (`d09ded9`) arrived on 13 August, one day after the call site. `00af9e4` on 6 September renamed `_val_worker_cap` to `_val_worker_target` and clarified its documentation without changing the policy. Nothing revisited the call site, so automatic worker selection could only govern datamodules whose callers left worker selection open. Through the inherited-validation guard, the evaluator's explicit zero was raised to one validation worker wherever `/dev/shm` exists. All measured "existing route" baselines below, including 0.850 s, 9.757 s and 13.104 s, therefore used one validation worker; the current low-startup route runs zero workers and is unmeasured. A profile of the public scoring loop over 117 real DOTA tiles spent 3.60 of 5.10 s waiting for the loader. The rotated accumulator took 0.051 s, so even removing it entirely could save only 1.0% of that run. Converting the standalone module to `channels_last`, the other live candidate, moved the five-run median from 2.679 to 2.645 s and overlapped both ranges; it was rejected.

**Completeness is not itself the crossover.** Starting eight workers to score only eight tiles took a 1.784 s median against 0.850 s on the existing route. A positive `--limit` therefore keeps that route unconditionally — the measurement is the smallest size the diagnostic path runs at, not a sweep, so a large positive `--limit` keeps the same route by scope rather than by a remeasured crossover. With no limit, the evaluator asks the datamodule for its existing policy, then keeps it only when the loader has more batches than workers: otherwise no process can be reused after its first batch. That structural guard covers the measured one-batch regression and a complete but tiny custom split without introducing a dataset-size constant. No crossover measurement was taken on macOS / MPS, where workers spawn rather than fork, and the band of batch counts just above the worker count is also unmeasured. Nothing in the reader, letterbox, model, decoder, merge or metric changes. Tests walk `run` through both public branches and the tiny-complete fallback, while a separate real-pool case reads the same twelve tiles through one and two workers and compares images, boxes, labels, rotated boxes, difficult flags, order and the complete report.

**The loader result was repeated on an isolated CPU.** Five measured traversals after one warm-up on an `n2-standard-8` Ice Lake VM (Python 3.12.3, torch 2.14.1) moved from a 9.757 s median (9.651–9.987 s) with one validation worker to 2.658 s (2.609–2.781 s) with the policy's eight, a 72.96% median reduction. Every traversal yielded the same 117 tiles, 2,691 instances and tensor checksum. That figure is the loader alone: CPU-device inference was not measured. The policy's worker count derives from `os.cpu_count()`, which is blind to container CPU quotas and process affinity, so a host that limits either may start more workers than it can run. A quota- or affinity-aware count would change the datamodule policy that training shares and belongs in its own work package, not in WP-192.

**The complete pipeline keeps the gain.** Five measured public `run` calls after one warm-up on an NVIDIA L4 (driver 580.178.04, torch 2.14.1+cu130, batch 8) included loading, model inference, rotated decoding and scoring, whole-image merge and scoring, and report construction. The existing route took a 13.104 s median (12.909–13.253 s); the policy route took 7.830 s (7.336–8.090 s), a 39.66% median paired reduction. Each pair used the same freshly initialised oriented nano model over the same official-DOTA-derived 117-tile slice; metrics and report payloads were equal in these runs after removing the recorded seconds and the intentionally different worker-count diagnostic. The in-repository, gated CPU evidence of report equivalence is `tests/eval/test_eval_obb.py::test_run_through_real_workers_matches_run_through_low_startup_route`, which compares a real worker pool with the zero-worker route after removing those same fields; it does not establish CUDA determinism.

**The report now says which count ran.** A complete run's worker count is no longer the fixed `0` the report used to imply; it is whatever the datamodule's shared-memory- and image-size-aware policy picked on the host that ran it. `SplitScoring` and the JSON report's `info.workers` field now carry that count, read off the same loader `score_split` already builds, so a report is self-describing about which route scored it without reaching into the datamodule's private state. A failure of the policy route's worker pool itself, such as a worker that dies mid-pass and surfaces as the loader's `RuntimeError`, is not one of the pairing `ValueError`s `run` turns into a `FAIL:` line: it reaches the terminal as a traceback, `lucid-eval` exits with status 1, and no report is written.

**That field is an additive schema change, and its consumers were checked.** A repository-wide search over `tests/`, `notebooks/`, `docs/` and `src/` found no oriented-report golden and no consumer comparing the whole payload or the exact `info` key set. The tests and the other task reports read named keys, while `demo_obb.py` and the documentation only produce or name `obb_report.json`. The regression therefore pins the new field directly; downstream code that selects existing keys is unchanged, while a consumer enforcing an undocumented closed schema would need to accept the additive key.

**The floor moved to its exact live count, not a one-row nudge.** `_WP_FLOOR` only tracks additions correctly when it is raised to the roadmap's exact live count — the same ratchet rule [WP-152b](https://borda.github.io/lucid-YOLO/work_packages/wp-152b/) and [WP-165b](https://borda.github.io/lucid-YOLO/work_packages/wp-165b/) record, and the one `_ASSUMPTION_FLOOR` was caught violating at 47 rows of slack. This row found `_WP_FLOOR` at 177 against a live count already at 191 before it, 14 rows of slack that would have let most of Phase 17 be deleted without the ratchet firing; it is now 192, the exact count after this row, leaving zero slack for the next one to inherit.



## WP-192b — decode once, augment many times

<a id="wp-192b"></a>

**The input stalls, not the compiled loss, were the complete-path bottleneck.** Profiling twelve real oriented DOTA samples at 1024 px attributed 0.791 of 1.233 CPU seconds to `torchvision.io.decode_image`; the four-source mosaic makes that repeated work the dominant single stage. Compiling the oriented objective was rejected after the five-repeat steady-state result proved noisy: its paired median-step reduction was 43.39%, but the pairs ranged from 26.68% to 46.08%, the baseline median had 31.08% relative spread, and the repeat-median mean start-to-start interval worsened 24.68%. The intended repeated `Trainer.fit` wall-time run yielded no admissible measured pair after its discarded warm-up because SSH failed. The selected change acts on the stage the complete path was actually waiting for, and earns its claim from the complete repeated fit below.

**The cache is raw and opt-in.** Both readers can eagerly retain one RGB `uint8` tensor per distinct training file. The entry sits before mosaic and every random transform, and `_load_image` still allocates the float image each draw, so the cache removes decoding without freezing augmentation or handing mutable cache storage to a caller. `DetectionDataModule.cache_train_images` routes the switch to the training reader only; validation remains uncached. D27 keeps the default off because the memory bill is the uncompressed split, and limits the copy-on-write claim to forked workers.

**Public-data values are unchanged.** Two seeded pipelines, one cached and one uncached, produced bit-identical float-image `int32` views and exactly equal target fields over 100 real DOTA draws carrying 7,083 instances. The slice held 41 distinct decoded tensors using 125,370,774 bytes.

**The isolated CPU path establishes the stage ceiling.** On an archbench C3 VM (8 vCPU, Intel Xeon Platinum 8481C, torch 2.9.1+cpu), five measured 1,640-draw traversals after one warm-up included dataset construction and returned the same checksum in both arms. The uncached median was 52.713 s (52.520–53.127 s); cached was 9.440 s (7.936–9.494 s), with a paired median reduction of 82.14% (81.99–84.95%). The VM was deleted and an explicit instance query returned no row. This is the reader-stage bound, not the training headline.

**The training headline clears the complete-path bar on top of [WP-193](https://borda.github.io/lucid-YOLO/work_packages/wp-193/).** [WP-193](https://borda.github.io/lucid-YOLO/work_packages/wp-193/) is merged (`e8d632c`); both arms of the L4 comparison ran on that post-backward head so adjacent gains are not double-counted. Five measured 257-step `Trainer.fit` pairs after one discarded warm-up, oriented nano at batch 16 / 1024 px / BF16 with six workers and EMA, moved from a 436.922 s median (435.429–441.999 s) to 332.337 s (332.170–343.212 s): 23.94% from arm medians. Every pair improved by 21.97–23.97%, with a 23.68% paired median. The ordinary step median was actually 2.42% slower; the interval mean fell because the multi-second loader stalls disappeared, which is the measured mechanism rather than a GPU-kernel claim. First input and first loss matched in every pair, and peak CUDA allocation was exactly 11,174,604,800 bytes in both arms. The isolated `g2-standard-8` used an NVIDIA L4, driver 580.178.04, torch 2.9.1+cu129 and Python 3.12.3; archbench deleted it and both the helper cleanup and explicit inventory found no live instance.

**The headline is a small-slice, decode-bound-host result and does not transfer unchanged to a full-size split.** The L4 fit slice was the same 41 distinct tiles (125,370,774 bytes of cache) the parity paragraph cites, expanded to 4,100 records under fresh IDs per the PR description, so the cache build cost about 41 decodes rather than 4,100; the build sits inside the measured wall time and was nearly free. The host was an 8 vCPU `g2-standard-8` with six workers, the regime where the measured mechanism, the loader stalls that disappeared, applies. A real split costs about one decode per distinct file to build, and its RAM bill follows `sum(3*H*W)` over those distinct files (TRAINING.md) rather than the 125 MB measured here; neither that build time nor that memory was measured on a full-size split, so the 23.68% and 23.94% figures say nothing about either.

**Why the row is lettered 192b.** It was lettered from [WP-192](https://borda.github.io/lucid-YOLO/work_packages/wp-192/) so that the then-open [WP-193](https://borda.github.io/lucid-YOLO/work_packages/wp-193/) post-backward contribution kept its identifier; [WP-193](https://borda.github.io/lucid-YOLO/work_packages/wp-193/) has since merged.



## WP-193 — post-backward state updates use fewer CUDA launches

<a id="wp-193"></a>

**The measured target is the complete interval, not either helper.** A public COCO val2017 training harness measured from one completed batch to the next, including loader wait, host-to-device transfer, forward and loss, backward, clipping, MuSGD, EMA and synchronization. On an isolated `g2-standard-8` NVIDIA L4 (driver 580.178.04, torch 2.9.1+cu129), variant `n`, batch 4, 640 px and BF16, five warm ABBA pairs moved from a 256.8 ms baseline median to 222.5 ms with this row. The paired reductions were 13.28%, 13.13%, 14.90%, 12.94% and 6.38%: median 13.13%, range 6.38–14.90%. The fifth candidate arm was the run's one timing outlier at 246.4 ms, but remained faster than the fastest baseline arm at 254.9 ms. Each arm discarded 15 warm-up intervals and measured the next 44. Peak allocated memory was 822.1 MiB in both arms. The paid VM was deleted after the run and an independent cleanup found no archbench instance alive.

**Two launch-bound stages make one post-backward change.** The existing optimizer already groups exact-shape CUDA matrices, but the `torch.vmap` layer around the scalar Newton--Schulz expression remains; it already lowered each product to one batched multiply, so the change drops that layer and folds the scale-and-add into the product epilogue. `_orthogonalize_batched` uses `baddbmm` for the same `b*A + c*A@A` and `a*X + polynomial@X` expressions, preserving each slice's normalization, coefficient triple, requested step count, gains, scaling and decay. EMA previously launched one multiply and one add for every floating parameter or buffer; grouping by `(device, dtype)` and calling `torch._foreach_lerp_` once per bucket evaluates the same `shadow + (1 - decay) * (live - shadow)` recurrence. Singletons and non-CUDA optimizer paths remain scalar. Both transformations are scheduling changes derived from this tree's buckets and profiler counts; no training constant or imported implementation enters the diff.

**Floating-point schedules are equivalent, not bit-identical.** Native batching and interpolation reassociate operations, so the regression contract is a bounded trajectory against the prior scalar formulas rather than byte identity. The repository's multi-step trajectory tests run on CPU, over three (6, 4) float32 matrices for the optimizer and float32 parameters for the EMA. The 40-step fixed-gradient CUDA run on an L4 is a one-off measurement, not a repository test, and no multi-step trajectory test covers square, wide or tall shapes or a float64 bucket. A finite one-step public-COCO run starts from the same loss and checks model and momentum differences directly. This row does not claim an unchanged long-run stochastic trajectory: tiny per-step differences can change later discrete assignments, as the repository already records for the exact-shape batching it replaces.

**The rejected queue was judged end to end.** MuSGD's native batch alone reached only 7.48% median on its final five-pair L4 rerun and was rejected despite a larger exploratory result. EMA foreach was retained only after the combined five-pair run; no isolated repeat-level EMA claim is made. Five segmentation mask-loss compilation repeats had a 4.44% median but ranged from -10.88% to 6.05%, crossing zero. Packed host metadata was rejected from its structural stage bound, while loss logging and scaled-dot-product attention had only inconclusive single local A/B screens; none carries a quantified claim here. cuDNN benchmarking was slower in its exploratory complete-path run and also carries no repeat-level claim. None became a dossier. Combining the two adjacent post-backward launch reductions is the first candidate whose complete-pipeline median clears the requested 8% threshold.

**The accelerator golden check is outstanding, not run clean.** `_orthogonalize_batched` reassociates the same CUDA Newton--Schulz buckets the [WP-177](https://borda.github.io/lucid-YOLO/work_packages/wp-177/) entry (ESCALATION.md, 2026-09-15) already measured moving `goldens/gpu/overfit_micro_kp.json`'s `train_oks_ap` 0.117 outside its stored band from float-scheduling alone, on a question that entry leaves unresolved. This row's own correctness evidence is the 40-step fixed-gradient CUDA trajectory and the one-step public-COCO check (both bounded-agreement, not golden-harness), plus the offline `make gate` (50/50, CPU-only). `make gate-gpu` / `check_goldens.py --include-gpu` was not run against this diff, so whether the additional reassociation here moves `overfit_micro_kp` or `overfit_micro_obb` further is unmeasured — the roadmap row stays `🔄` until it is.



## WP-193b — compatible matrix batches and momentum dispatch

<a id="wp-193b"></a>

**Compatible matrix batches and momentum dispatch.** One diagnostic session after the native-batch and image-cache changes attributed a 49.28 ms median to the optimizer within a 159.60 ms complete detection interval on an isolated L4. Its 10.90 ms momentum-dispatch median suggested a 6.8% stage share; this is an exploratory opportunity estimate, not an independently repeated speedup or a universal ceiling. A single bounded-padding-only complete-path screen suggested 5.94%, without a run-level range; it did not establish a standalone improvement above the investigation's 8% bar. The combined candidate targets the remaining launch overhead within the same optimizer phase.

**Zero extension changes the batch, not the quintic.** For a wide matrix `X`, appending zero columns preserves its Frobenius norm and `X @ X.T`; every iterate of the existing polynomial therefore keeps those columns zero. CUDA matrices sharing the smaller dimension can run in one native batch, then crop and transpose back before their original dimension-dependent scale, gains and decay. Each padded gradient stack is bounded by the parameter and gradient bytes of the matrices in its own batch. A singleton, or a batch whose matrices all share one shape, keeps the exact-shape update; equal-shape matrices that share a smaller dimension with a matrix of another width join the padded batch. Parameters whose memory ranges overlap keep exact-shape keys, whichever storage object wraps them. No coefficient, iteration count or normalization rule changes.

**Momentum keeps its rounded operations.** Three foreach operations replace the CUDA per-parameter multiply, add and Nesterov add, with the original operation order. Device and dtype buckets keep independent state. Shared parameter/gradient/buffer storage and unusual restored state use the original scalar method; CPU and MPS keep their previous implementation. No public flag or checkpoint field is added.

**The numerical contract is bounded agreement.** Mixed-width GEMMs and reductions may round differently. A real one-step COCO run and its baseline-against-itself control have the same loss bytes, all 369 momentum buffers match bit for bit, and the largest model/buffer difference is 2.98e-8. A 60-step zero-warmup screen diverges in its BF16 trajectory, so this is not a claim of identical stochastic training or final accuracy. The mixed-shape scalar-oracle, storage-budget, alias, DLPack-overlap, dtype, checkpoint-resume and foreach recurrence tests cover the altered scheduling. The full accelerator goldens and long-run accuracy remain unverified; the existing [WP-193](https://borda.github.io/lucid-YOLO/work_packages/wp-193/) status stays open.

**The padding region is measured, not universal.** A five-repeat optimizer-only crossover screen found the unguarded x scale slower: 111.26 to 119.37 ms per step on the L4. Padding is therefore restricted to a smaller dimension of at most 256, the largest present in the measured nano workload; larger matrices retain exact-shape batches. The guarded screen improved the optimizer in all five scales, but those stage numbers are not the training claim. Parameter subclasses may legitimately refuse storage inspection: a baseline-compatible vector subclass exposed that regression before handoff. Such groups now keep their original keys and scalar momentum, including mixed groups where opaque storage may alias an ordinary parameter.

**The final claim includes the complete short fit.** Five alternating paired `Trainer.fit` runs on an isolated NVIDIA L4 (driver 580.178.04, torch 2.9.1+cu129, Python 3.11.17), after two discarded warm-up fits, used detection n, batch 4, 640 px, BF16 and four workers over 256 real COCO val2017 images. Each six-epoch / 384-step fit included cache setup, augmentation, forward/backward, the default three-epoch LR warmup and decay, alpha scheduling, EMA, mosaic closure, four validation batches each epoch and checkpoints. Baseline median 89.448 s (88.765–89.646); candidate 79.183 s (77.799–79.660). The paired reduction median is 11.19% (10.88–13.22%), with the same 809.9 MiB peak allocated. Timing brackets are synchronized and contain no profiler. This is a complete short fit, not a full-COCO or final-accuracy measurement. The author reports that the code measured here passed 65 focused tests on the L4 with two newer-runtime skips, and that those cases passed separately on CUDA. The pytest selection and the revision of that run were not recorded, so the count is not reproducible from this tree; the two optimizer test files collected 71 tests at the reviewed head `b060de4`. Full `make gate` and accelerator goldens were not run.

**Why the row is lettered 193b.** [WP-193](https://borda.github.io/lucid-YOLO/work_packages/wp-193/) had already merged (`e8d632c`) when this change arrived, and its row stays open on the accelerator-golden check, so the follow-up carries its own row, scope and gate instead of extending the merged one. The commit trailer check accepts digits only, so the commits keep the `WP: 193` trailer, as the commits of [WP-192b](https://borda.github.io/lucid-YOLO/work_packages/wp-192b/) keep `WP: 192`.



## WP-194 — static checks leave pytest, test names lose their filler words

<a id="wp-194"></a>

**Pytest owns behaviour; hooks own file content.** A scan of `tests/` and `scripts/_tests/` found four tests in `tests/` that read a file as text and exercised no code, and fourteen in `scripts/_tests/` that asserted a hook passes on the live tree. The second group repeated what `lint.yml` and `make gate` already run, since both call `pre-commit run --all-files`, so those tests are deleted rather than moved. The first group moved: the matplotlib and `_viz` import boundary under `src/` is ruff `TID251` banned-api with per-file ignores for `_viz`, `scripts/`, `tests/` and `notebooks/`. A probe module holding two function-local imports showed the rule fires on both, which is the case the regex test existed for. The top-k decoder's no-suppression text pin and the two packaged-recipe pins (no `deterministic:`, `task:` outside `det_*`) became the `content-pins` hook, `scripts/lint/audit_content_pins.py`, with its functional core in `scripts/_tests/test_audit_content_pins.py`. The runtime `sys.modules` probes in `tests/viz/test_import_surface.py` stay in pytest because they import code. Two kept groups in `test_check_commit_trailers.py` also read the real register, `TestPlaceholderRows` and `TestAssumptionRegister`; they stay because they test how the parser reads that register. Removed alongside: the register-contiguity assertion, which `docs-present` already checks, and the docstring-path assertion on the trailer script.

**Names drop `a an the its their is are does that`.** 1057 test functions were renamed mechanically, and no two names collided in any file or class. The offline collection count is 2910 both before and after the rename. One name that read badly after the drop was renamed by hand: `test_rejects_row_naming_class_absent_from_data_yaml`. References in ROADMAP, ASSUMPTIONS and this log were rewritten to the new names. `Gate:` trailers in commit history still name the old ones, because history cannot be rewritten. This reverses the sentence-style naming that `audit_flat_test_groups.py` describes, at the principal's direction, and the flat-group hook stays green on the renamed tree. No guard hook stops filler words from returning, so it stays a convention.



## WP-195 — rotated augmentation shares clipping and refitting dispatch

<a id="wp-195"></a>

**The profiler selected the whole operation.** An isolated one-thread CPU pass over 32 public DOTA augmentation draws at 640 pixels spent 1.15 s of its 5.67 s input interval in rotated clipping and refitting, about 20.3%. Optimizing only the half-plane helper did not move the enclosing input path enough; grouping the clips but leaving each refit scalar also fell below the training target. This row batches both parts. At 1024 pixels the same stage was only 6.8% of the input interval, so that profile does not support a comparable claim there.

**The batching follows the existing arithmetic.** Each of the four half-plane passes groups rings by their current vertex count, evaluates the scalar clipper's existing pointwise expressions over the group, and splits the surviving vertices back in their original ring order. Refitting groups the surviving polygons the same way: `vmap` applies the unchanged `_polygon_area` and `_fit_corners`, and the existing `polygons_to_rboxes` converts the resulting quadrilaterals together. There is no polygon padding, vertex cap, replacement fit, changed threshold or imported geometric convention. Results scatter back to their original instance rows. The original scalar clipper remains the reference and the route for single clips, accelerator inputs, other dtypes, gradients and non-finite inputs.

**The scope is CPU input work during oriented training.** It changes no model, loss or optimizer arithmetic. The public entry point and return layout are unchanged. Tests compare raw storage bytes with the scalar route, exercise changing vertex counts and empty parts, retain non-contiguous input ownership and check the fallback conditions. Full `make gate` and accelerator goldens are not claimed for this contribution; validation is focused on the changed geometry and a complete CUDA training comparison.

**The new test follows the ordinary staging path.** The notebook dataset ignore is anchored to `/data/`, matching its documented root-directory scope. The unanchored `data/` also ignored new files below `tests/data/` and `src/lucid_yolo/data/`, so `git add -A` skipped them. The root notebook-download exclusion remains; already tracked files keep their behaviour.

**The complete CPU input path confirms the mechanism.** Five paired input epochs on an isolated n2-standard-8 (8 vCPUs, Intel Xeon at 2.80 GHz, torch 2.9.1+cpu, one thread) traversed all 117 distinct public DOTA tiles and 2,691 annotations, including image-cache construction. Median wall time moved from 22.758 s (21.629–23.423) to 20.870 s (20.783–21.168); paired median reduction 8.27%, range 3.51–9.91%. Clipping/refitting accounted for 17.98% of the baseline input interval and its median cost fell from 6362.72 to 2856.17 microseconds per real-data call. Input images and every packed-target field matched at byte precision in all pairs. The instrumented input epoch includes checksum work; the separate complete CUDA fit carries the training claim.

**The training result is measured after the merged cache and state updates.** Five counterbalanced complete 20-epoch fits on an isolated g2-standard-8 with an NVIDIA L4 (driver 580.178.04, torch 2.9.1+cu129, Python 3.12.3, Lightning 2.6.6) used nano OBB, 640 pixels, batch 16, BF16, six persistent workers, cached images, mosaic and EMA. The first 20 sorted official DOTA validation images supplied 117 distinct lossless tiles and 2691 annotations; they are training-throughput inputs, with no held-out accuracy claim. Every fit traversed 2340 augmented samples and 160 optimizer steps; validation, logging and checkpointing were disabled. Baseline median 165.056s (164.518–169.835) became 149.772s (146.751–152.531); paired median reduction 10.19%, range 8.68–12.25%. Timing synchronizes CUDA around the whole fit and includes cache construction, worker startup, data and augmentation, transfer, forward/loss/backward, optimizer, EMA and Trainer teardown. Final model, EMA, optimizer and scheduler storage hashes, first input and sampled losses match in every pair. Peak CUDA allocation is unchanged at 3,583,640,576 bytes. One discarded two-epoch warmup precedes the five fresh-process pairs. No comparable 1024-pixel, larger-scale, MPS, DDP or convergence benefit is claimed. The paid VMs were deleted and independently verified.



## WP-197 — the input workers sample colour and CUDA applies it in a batch

<a id="wp-197"></a>

An isolated CPU profile attributed 5.476 of 12.668 seconds to the colour transform over 128 public COCO augmentation draws. That selects an enclosing input stage rather than a small helper.

`device_hsv` is opt-in for CUDA with float32 or mixed precision. Workers keep the existing HSV draw before the flip draw and carry the stated gains with the flipped image. A pointwise colour transform commutes with that pixel permutation. The consumer batches the existing RGB/HSV expressions with `vmap`, applies the same gains, then quantizes and restores the consumer dtype at the existing boundary. Float32 images cost four times the default uint8 image IPC and transfer bytes; the worker cap already budgets float32. Masks, validation and the default route keep their existing behavior. Precision plugins that cast floating inputs before the data hook retain worker-side HSV.

CUDA's host-scalar division lowered the hue divisor to a reciprocal multiply and changed 72 of 314,572,800 real augmentation pixel codes by one. R40 explains that boundary. A device-tensor six keeps the CPU division convention; the captured pixel is a regression test, and changing the divisor back to a host scalar makes it fail. No gain, recipe, target geometry or model arithmetic changes.

Five paired complete six-epoch detection-n fits on 256 public COCO images (640 px, batch 8, BF16, four workers, cached decoding) reduced the median from 102.053 s (99.831–103.663) to 79.468 s (78.859–81.501). The median paired reduction was 22.22%, range 18.72–22.79%, on an isolated four-vCPU NVIDIA L4 host, driver 580.178.04 and torch 2.9.1+cu129. Loss, model, optimizer, EMA, scheduler and metric byte hashes matched in each pair and across repeated baselines. Peak allocated CUDA memory was 1,595,021,312 versus 1,596,232,192 bytes.

With the batched MuSGD change, merged as 01b4d4c, applied to both arms (the measured row ran on that change's pre-merge revision, not on the squashed commit), five batch-4 fits (384 updates each) reduced the median from 115.651 s (115.136–116.204) to 95.898 s (95.501–96.696). The additional median paired reduction was 16.93%, range 16.38–17.82%, with the same six state/loss hashes equal. This is a separate workload row, not a percentage to add to the batch-8 result.

The final real-input replay compared 1,258,291,200 consumer image bytes over all 256 COCO images: zero differences, with equal packed geometry and a stable repeated baseline. The L4 passed 172 focused regressions, 28 changed-code doctests and one targeted augmentation-producer doctest; ten pre-existing doctest examples were explicitly skipped. A scalar-divisor mutation fails the captured CUDA pixel and the CPU-only divisor contract; restored code passes. Float32 and FP16 mixed precision each completed a finite one-batch real-data compatibility smoke with deferred mode active. Full pre-commit passed on CPU torch 2.14 with 149 focused tests and three CUDA skips.

Focused tests cover RNG order, ordinary and deferred transport, masks, consumer precision, the CPU fallback, validation and CUDA rounding. `_host_sample` lets the grid and golden helpers accept a deferred sample without quantizing it (unit-tested); both scripts build the default pipeline, so their outputs are unchanged. The complete-fit evidence uses public COCO, includes loading, augmentation, forward/backward, optimization, EMA, validation and checkpoints, and compares loss and final-state bytes. Full `make gate` was not run; this work package remains open.

