Skip to content

๐Ÿ› ๏ธ Engineering Log

What executing the work packages taught about the repository itself, split out of docs/RESEARCH_LOG.md: CI and commit-time gates, packaging and licensing, formatting and doc-generation tooling, environment reproducibility, and the CLI/report code the reproduction runs on top of but that carries no fidelity claim of its own. The two logs are split by claim, not by work package -- a WP whose finding straddles both gets one entry in each, cross-linked; most don't straddle and land in exactly one.

This file is not a register, for the same reason RESEARCH_LOG.md is not: a choice the papers left open belongs in ASSUMPTIONS.md, cited by id rather than restated. Release-level results live in REPRODUCTION_REPORT.md.

Sections are anchored by work package (#wp-XXX) and the roadmap links to them. A package with nothing worth recording here has no section -- and a package whose whole finding is a fidelity claim rather than tooling has none here at all; see RESEARCH_LOG.md for it.

Dates are the day the finding was recorded.


๐Ÿงต Cross-cutting

Float reproducibility has two axes, not one

A26 already records that the synthetic fixture generator is byte-identical per seed on a given platform and not across platforms โ€” libm last-bit rounding differs, macOS arm64 against ubuntu x86-64 CI. That entry is about image generation. The same fault line runs through torch reductions, and it was found the hard way on 2026-08-14.

test_obb_training.py carried a snapshot of a task="detect" training step asserted with ==, captured on arm64 at one intra-op thread. Three values of that same total have now been observed from source trees computing identical arithmetic:

total where
22.80110550 arm64, 1 intra-op thread (the recorded snapshot)
22.80111694 arm64, 12 threads
22.80112076 x86-64, ubuntu CI

The spread is 1.5e-5 at magnitude 22.8 โ€” about eight float32 ulps โ€” and comes from the summation order a build chooses for the dense classification term. The test passed for the whole of Phase 8 because until 0.3.0 was pushed it had only ever run on the machine its snapshot came from; its first x86 execution failed it. It now compares at a relative 1e-4 with an absolute floor.

The general rule this establishes, beyond that one test: bit-identity is a within-architecture property. A gate that asserts it across machines is asserting something no library promises. What survives across architectures is a tolerance argued from ulp count, and what such a gate can still catch is a change to the objective โ€” those move by percent. What it can no longer catch is a change of one rounding step. No tolerance choice recovers that.

A green gate is only as trustworthy as the interpreter it ran under

Mid-session on 2026-08-13, uv run silently rebuilt .venv โ€” Python 3.14.2, torch 2.13 โ€” replacing the 3.11.16 environment a passing make gate had just run under. Nothing announced it; the first sign was unrelated behaviour changing. Restored with make setup, and every later invocation named .venv/bin/python explicitly rather than going through uv run.

The general point: "the gate is green" is a claim about an environment as much as about a tree, and a tool that can reconstruct that environment between two commands can invalidate the claim without touching a tracked file.

Deterministic SVG is deterministic per matplotlib, not per run

WP-080 fixes svg.hashsalt and suppresses date metadata so an unchanged run regenerates its figure byte-identically. That holds โ€” within one matplotlib. On 2026-08-14, regenerating the committed detection and segmentation figures produced files differing from the committed ones, and the cause was isolated by regenerating with the git-stashed original script: the committed script also differs, so the difference is the renderer, not the edit.

The committed figures were left untouched rather than churned. The consequence to know: a figure diff is not evidence that a plotting change altered a curve. Prove which by re-rendering with the pre-change script before reading anything into it.

Two process failures that each cost a run

Both recurred often enough to be written into AGENTS.md rather than left in notes.

The gate certifies a tree, not an intention. The loop runs make gate at step 4 and flips a roadmap row at step 6, and the gap between them is where a docs edit rides into a code commit that was never gated with it. That is how commit fcf3040 left main red on two meta tests, and the next agent spent a full run reporting a blocker it was forbidden to fix. The invariant is a set equality between what was gated and what is committed; the docs-only exemption is the thing it is constantly confused with.

A completion notification is not a completion. A delegated work package returns marked completed whether the agent finished or stopped mid-sentence on a partial edit, and the notification carries a result field that reads like a report โ€” so an unfinished run looks like a finished one that summarized badly. It happened four times in Phase 8. The check that catches it is one git status against the spec, and resuming the same agent beats respawning it, because its transcript is the context.


๐Ÿงฑ Phase 0 โ€” Foundation

WP-001 โ€” one place to write the version

The version was declared twice and the two had already drifted: pyproject.toml said 0.1.0 while lucid_yolo.__version__ said 0.0.1.dev0, so a build published one number while every runtime consumer reported the other. pyproject now declares the version dynamic and reads the module attribute.

The attribute stays a plain string literal deliberately: setuptools resolves a literal by static analysis and only falls back to importing the package when it cannot, so a computed value would pull torch into every build and every source install. A meta test pins the literal form for that reason.

Also recorded โ€” a test deliberately not written: importlib.metadata asserted against __version__. An editable install freezes its metadata at install time, so that assertion would fail after every bump until someone reinstalled, i.e. fail for a reason unrelated to what it claims to guard.

WP-001 โ€” markdown formatting, and what it found

Directly relevant to why this file exists.

--compact-tables was added on evidence rather than preference. Table padding is charged per column at its widest cell, and this repository's governance rows run past 2,500 characters, so padded output grew the tracked markdown from 162 kB to 375 kB โ€” all of it trailing spaces. Compact output is size-neutral at 161 kB. A single very wide cell taxes every other row in its table.

The GFM plugin is required, not optional: core mdformat is CommonMark-only, reads a GFM table as a paragraph, and --wrap=no would then collapse every row onto one line.

The content-preservation check โ€” strip whitespace, cell pipes and backslash escapes, compare the remaining characters before and after โ€” found a real defect. A roadmap row held an unescaped pipe inside a code span, and a pipe opens a table cell wherever it appears: the row parsed as eight cells against a six-column header, so GitHub had been rendering its tail into the wrong columns and silently dropping the overflow. A sweep found no other row in the repository whose cell count disagreed with its header.

WP-004 โ€” auditing what a wheel bundles, not what it declares

The copyleft audit read what a distribution says about itself โ€” License, License-Expression, OSI classifiers โ€” and nothing about what it ships. A wheel may vendor a native library under a licence its own metadata never mentions, which is precisely the case the gate exists to catch.

Reading the License-File documents too, it found something on the first run:

package declares bundles
numpy BSD-3-Clause libgfortran, libgcc โ€” GPL-3.0-or-later with GCC Runtime Library Exception; libquadmath โ€” LGPL-2.1-or-later
shapely BSD-3-Clause GEOS binaries โ€” LGPL-2.1

numpy is a runtime dependency, not a test-only one, and no audit this project had run could ever have seen it.

Three rules keep the result meaningful rather than merely red:

  • Only declaration lines are read, never licence prose. A bundled copy of the LGPL names the GPL on dozens of its own lines, and a gate that fires on every wheel shipping a licence file teaches its reader to bypass it.
  • A recognized licence exception is permissive by construction, matched by expression rather than by name, so a future gcc-built dependency needs no entry.
  • The exception is tested before the allowlist, not after. Written the other way round the allowlist short-circuits first, the exception never runs, and a per-package entry quietly covers whatever that package vendors next. Mutation testing is what caught the ordering: with numpy delisted the audit reports libquadmath alone, and with the exception disabled it reports the GPL-3 line โ€” evidence the two mechanisms act independently rather than one masking the other.

A related gate lesson from the same package: the decisions test moved from a hardcoded D1โ€“D14 to contiguity plus a floor. The hardcoded upper bound failed the moment a decision was added, which is a test asking for maintenance rather than reporting a defect; contiguity alone would not notice the last row being deleted, since what remains stays contiguous. The roadmap's _WP_FLOOR ratchet is the same pattern.

WP-005 โ€” goldens that survive a second machine

The first fixture golden byte-hashed the generated annotation JSON. Those hashes are per-platform stable only โ€” see Float reproducibility โ€” so they were replaced with structural metrics: exact integer counts (images, annotations, categories, polygon points) plus bbox area and coordinate sums pinned with a small absolute tolerance.

The split that has held ever since: integers exact, aggregates to tolerance. Counts are platform-stable by construction; sums of floats are not. Every golden this project has frozen follows it.


โšก Phase 5 โ€” Lightning training loop

WP-038 โ€” four defaults that only fail off the developer's machine

Each was found by running somewhere other than a local checkout, and none is visible in a unit test.

Strict determinism aborts on MPS. Auto-picked MPS killed a tier run mid-step: deterministic=True hits index_put_with_accumulate_mps, which has no deterministic MPS kernel. The default is now accelerator-aware โ€” "warn_only" where MPS is available, strict True on CPU/CUDA โ€” and the shipped configs dropped their pins, since a config value would override the accelerator-aware default.

Configs were not in the wheel. They lived at the repo root and never entered the built distribution, so a wheel-only install (Colab) could not run --config at all. Moved to package data with a repo-root symlink so documented commands keep working, then given name resolution (--config det_smoke resolves onto the installed tree when no local file matches) and a default recipe, because a wheel install has no checkout to write a path into.

Rich floods notebook output. Lightning auto-picks RichProgressBar whenever rich is installed โ€” it is, transitively โ€” and Rich live rendering prints one line per refresh in a Colab cell. A newline flood every step. Default is now tqdm.auto, which renders a widget in notebooks and a single-line bar in terminals.

Loggers. An unset trainer.logger resolved to TensorBoard only, so a run left no plain-text metrics. It now resolves to a TensorBoard + CSV pair pinned to the same version_N directory โ€” which is what makes every later metrics.csv analysis in this project possible at all.

๐ŸŽฏ Phase 6 โ€” Evaluation, release 0.1.0

WP-079 โ€” per-worker, per-epoch augmentation RNG

_TrainPipeline seeded one generator in the parent process. A parent's generator never advances when workers are used, so every worker replayed one identical augmentation-parameter stream โ€” diversity 1/num_workers. WP-076 then made workers non-persistent, so the pool restarted from that same state every epoch, replaying a single epoch's parameters for the entire run.

What the two defects together cost the training-data variety is measured at RESEARCH_LOG.md#wp-079.

WP-083 โ€” synthetic-shapes generalization golden

The cheap stand-in for a tier run: 2000 generated scenes split 1800/200, n-scale, 6 epochs at 320 px, scored on held-out images through both decode paths. Unlike the overfit-100 gate it never scores what it trained on, so it measures generalization rather than loop composition, and mosaic stays on for five of six epochs so a collapsed augmentation RNG (WP-079) is detectable rather than merely absent.

The frozen numbers this golden pins are recorded at RESEARCH_LOG.md#wp-083.

WP-084 โ€” commit-time gates

Three checks were moved to where they can catch something, and the reasoning generalizes past this repository.

The commit-trailer validator was a CI job. Under a squash merge the per-commit messages CI validated are replaced by the squash message โ€” so the job gated text that never lands. It belongs in a commit-msg hook or nowhere.

The copyleft audit went the other way: it stays a standalone always-run job rather than a diff-triggered one, because it scans the installed environment. A transitive copyleft bump touches no tracked file, so a diff-scoped trigger would never fire on the case it exists for.

The dev extra became a PEP 735 [dependency-groups] entry, which is what stops build/lint/test packages advertising themselves in the published wheel's metadata (Provides-Extra: None on the installed dist is the check).

Test-suite and docs-updated checks were deliberately left unhooked โ€” a pre-commit hook that runs the suite trains the operator to pass --no-verify, which is worse than not having it.

WP-085 โ€” doctests join the offline gate

Every public function is required to carry an Examples section, and no gate had ever run one: testpaths pointed at tests/ alone and addopts carried no --doctest-modules. Four examples had rotted unnoticed over roughly eighty work packages.

The worst was not stale โ€” it was never true. orthogonalize's example asserted near-exact orthogonality that the Newtonโ€“Schulz coefficients cannot reach, and which that function's own unit test documents as unattainable. It could not have passed at any seed, and was additionally unseeded. It now states the guarantee that actually holds (no singular value expanded). The other three pinned stale float reprs (0.01 against 0.010000000000000009; 0.01... against 0.00999...).

Wiring it up took the offline suite from 558 to 699 cases and coverage from 96% to 97%. The lesson recorded for later gates: a requirement nothing executes is a documentation convention, not a gate, and the two are indistinguishable from a green run.


๐Ÿ”„ Phase 8 โ€” Oriented detection, release 0.3.0

WP-105 โ€” a report that survives

Two defects the first real oriented evaluation exposed, both in the CLI rather than in what it measures.

--output into a directory that did not exist yet raised after the scoring pass finished and printed its numbers โ€” minutes of accelerator work producing a traceback and nothing else. The parent is now created rather than required, because the file is the only durable form of the expensive part. The general rule: a long computation's output path should be validated or created before the computation, never discovered after it.

Second, a 10,132-tile pass showed one line at the start and nothing until it ended. The run has a known length; it now draws a progress bar, wrapped at the CLI rather than inside the evaluator, which tests drive in-process.


WP-094 โ€” the difficult flag died at the loader

WP-057 manufactures difficult instances at crop time and A51 defines the channel they are supposed to travel on, but nothing connected the two: the reader did not forward difficult out of the annotation record, so every instance the tiler had flagged arrived at the loss indistinguishable from a whole one. Nothing disagreed anywhere โ€” the counts are right, the boxes are right, and the only thing wrong is that a protocol decision the register spent a row on was not in force.

A53's non-schema keys are what make the flag survivable through a COCO container at all, since COCO's schema has no field for it or for the tile's window provenance, and a standard reader ignores both and still sees an ordinary detection set.

WP-095 โ€” a run monitor is not an acceptance instrument

The oriented tier reached its release row with no way to produce an acceptance number. What existed was the epoch metric val/rotated_mAP (WP-102), and that is a run monitor: it scores whatever the training loader hands it, under torchmetrics' own protocol rather than A46's recall grid, A47's cap and A48's difficult rule. Two figures computed under two protocols are not the same measurement, and the one a release quotes has to be the registered one.

One decision inside the instrument is worth recording. It scores in letterbox coordinates, with the ground truth letterboxed alongside, rather than inverting the predictions back to tile coordinates first. Both sides then pass through one geometry, which makes the comparison an equality rather than a proxy โ€” the inverse is where a frame error would enter, and here it is not on the path at all. The same reasoning put checkpoint loading in lucid_yolo.eval.checkpoint instead of a second copy: one loader, one place for a task to be read wrong.

WP-096 โ€” a rename with a published cost

Parsing moved to jsonargparse so that flags and help text derive from the operation signatures rather than from a hand-maintained parser that is free to disagree with the function it calls. The cost is real and is stated rather than absorbed: jsonargparse spells a parameter with underscores, so every documented flag changed shape in one commit.

lucid-download therefore survives as a deprecated alias rather than being dropped. It is named in published reproduction instructions, and an instruction that no longer runs is a worse outcome than a duplicate entry point โ€” the alias keeps accepting its old flags, which is the part a copy-pasted command depends on.

WP-097 โ€” a published total no correct download can reach

check_dota_root compared its summed train+val totals against R18's whole-dataset figures (RESEARCH_LOG.md#wp-097), so it failed on every correct download, at the first command an operator is told to run.

The fix is not a looser tolerance but a different question. A count the caller did not state is reported, not compared: on an unstated expectation the check's job is to say what it found, and a number with no stated referent cannot fail. What still fails is a stated expectation that misses โ€” and, new here, a DOTA expectation aimed at a COCO root, which had previously been accepted and silently ignored.

Recorded because the failure was in the gate rather than in the data, and a gate that fires on every correct input is the one shape of gate that teaches its operator to disbelieve gates.

๐Ÿ”ฎ Phase 9 โ€” Inference and generalization

WP-089 โ€” the grid was already written three times

The package exists because a letterbox inverse must not be written twice: two copies of one transform, each with its own passing tests, are free to disagree by a pad the day either side's rounding changes. That part went as intended โ€” nothing in the predict path computes a ratio, a pad or a corner.

What the work found is that the other geometry had already made the mistake. The head's anchor grid โ€” divide the canvas by the three strides, build the points, move them to the device โ€” was written once in the dual-path evaluator, once in the oriented evaluator, and would have been written a third time here. The stride triple (8, 16, 32) had three separate declarations to match, two of them private. Nobody duplicated it carelessly; each consumer needed three lines and wrote them. That is how the duplication a project actively guards against still accumulates: the guarded case is the one everyone can see is dangerous, and the neighbouring case looks too small to bother with.

Consolidated into assign/grid.py, beside make_anchor_points. The general signature won: a canvas is (height, width), and a square side cannot express a letterbox that is not square. Every golden was unchanged afterwards โ€” 20/20 โ€” which is the only evidence that a refactor of the scoring path changed nothing.

A second finding, recorded because it is a trap the next two packages walk into: DetectionLitModule.forward returns a DualHeadOutput for every task. A segmentation or oriented checkpoint handed to a detection predict path therefore produces boxes, silently, with its mask or angle branch never consulted โ€” plausible output, no error, and the masks the caller asked for simply absent. The refusal is in the library rather than in the command for that reason: a wrong answer that looks right is worse than a traceback, and the command is not the only door.

Two citations that belong with the above rather than in the roadmap row. The duplicated-inverse hazard has a name in this project โ€” it is the WP-053a defect class, the shared-decode function whose second caller found what its first never could (WP-090b is the same class caught later). And the task string is read from the checkpoint by the same mechanism lucid-eval reads it, so the two commands cannot disagree about what a checkpoint is: a refusal that depended on which entry point asked would be worth less than no refusal at all.

WP-090 โ€” a crash the evaluator could never reach

decode_instance_masks raises on a zero-length instance axis: F.interpolate rejects a [1, 0, 16, 16] tensor outright. The function has been in the tree since WP-053a and no test caught it, because from the evaluator it is unreachable โ€” both decoders always hand it a fixed 300 rows, padding included, so the axis is never empty. Predict decodes only the survivors, and an image with nothing above the confidence threshold has none.

The general shape is worth keeping: a function whose only caller pads to a fixed length has never been tested at its degenerate input, and the untested case arrives with the second caller rather than with the first. Guarded at the call site here, with the crash pinned by a test on both decode paths; the shared function itself is left for its own package.

Two things this package had to get right that a shape check cannot see. The masks are assembled while the boxes are still on the letterboxed canvas, because that is the frame the A11 box crop is defined in โ€” cropping after the inverse letterbox leaves every box right and every mask quietly wrong. And the mask path is not decoder-independent: the one-to-one coefficients are what the E2E decode reads and the dense ones are what the suppression path reads, so pairing either decoder's rows with the other branch's coefficients yields a plausible mask of a different object. The test plants different prototypes per branch, so a path reading the wrong one returns a mask on the other half of the picture and fails rather than passing with a mask nobody looks at.

For the report, COCO RLE through faster_coco_eval.mask.encode โ€” already a declared dependency, and already the encoder every segm_ statistic is measured through, so a predicted mask on disk and a scored mask are the same object in the same format. Rejected: a polygon contour needs a tracer and is lossy on masks with holes, which the box crop routinely produces; a sidecar .npz splits one prediction across two files, so a report can be archived into silently describing masks that are gone; mask-derived scalars answer a smaller question than the caller asked.

WP-090b โ€” the degenerate input arrives with the second caller

decode_instance_masks has raised on a zero-length instance axis since WP-053a wrote it. F.interpolate treats that axis as channels and rejects an empty one outright. No test caught it and no reviewer would have: from the evaluator the case is unreachable, because both decoders always hand the function a fixed 300 rows with their padding included, so the axis is never empty. Predict decodes only what survives the confidence cut, and an image can have nothing above it.

The rule worth keeping: a function whose only caller pads to a fixed length has never been exercised at its degenerate input, and that input arrives with the second caller rather than the first. The shape of the bug is not "someone forgot the empty case" โ€” it is that the empty case did not exist while there was one caller, so no amount of care at the time would have surfaced it.

Returning the empty stack rather than raising is the shape contract, not politeness: a caller assembling per-image results needs a tensor of the right rank and dtype to stack, and an exception pushes the count check out into every such caller, which is where the copies start.

WP-091b โ€” the tie-break decoder implementation

The decoder itself is the small half. The rule is greedy class-wise suppression whose overlap is rotated_iou โ€” the same exact polygon measure the oriented evaluator grades with โ€” over boxes canonicalized first, so two detections stated a half turn apart cannot read as two objects. Class separation tests the label directly rather than borrowing batched_nms's coordinate-offset trick, because there is no offset that separates rotated boxes without also turning them.

Why its overlap threshold has no paper source to cite, and what that cost to establish, is recorded at RESEARCH_LOG.md#wp-091b.

WP-099 โ€” a format with no specification

The clean-room rule says the reader is written from the format as the datasets publish it, never from an implementation's reader. That is a clear instruction with a hole in the middle: the YOLO label format has no specification on the paper allowlist, and the obvious ways to learn it โ€” a search, a docs page, somebody's loader โ€” are all the thing the rule forbids. What closed it was a published dataset export already on the machine, CC BY 4.0, a data artifact with no implementation lineage: registered as R32, and read for what it actually contains rather than what a tutorial says it contains.

That got four facts and left four gaps, and the gaps are the useful part. The export's fields lie in (0, 1] and its clipped corners land on exactly 0 and 1; names is an index-ordered list beside nc; split entries are written ../train/images for a tree at <root>/train/images; every image has a label file and none is empty. What it could not settle: whether a ten-field oriented row exists (R18 appends a difficult flag to its own label lines, and the normalized variant has no published spelling for one), whether data.yaml may carry a path: key, which of the two directory spellings ranks first when a root satisfies both, and which image extensions are in scope. Each of those is rejected or documented as a precedence choice rather than guessed โ€” A54 through A58 โ€” because a guess that silently mis-parses is exactly the failure the clean-room clause exists to prevent, and it fails quietly by producing a dataset rather than an error.

Two decisions where the format's own limits set the line. A missing label file is an error; an empty one is a background image โ€” writing zero bytes is a positive statement that the image holds no objects, while not writing one is silence, and a tree with no manifest cannot tell silence from a half-finished download. Reading silence as "no objects" turns a broken export into a dataset that trains on backgrounds without ever saying so. A coordinate outside [0, 1] is rejected, not clamped, and compared with no epsilon โ€” it is not a slightly-off box but a file that was never normalized, typically written in pixels, and clamping turns it into a wall of degenerate edge boxes that would then be trained on.

The layout question was the one that looked smallest and was not. resolve_split requires a directory and an annotation file, and a YOLO split's annotations are a directory. Folding YOLO into the existing CANDIDATES would force that predicate to accept either kind of thing, and a root satisfying both conventions would then resolve by table order โ€” handing a labels tree to the COCO reader, or an instances_*.json to this one. Which convention applies is a property of the reader asking, so the reader asks its own table.

What did not land: the datamodule still does not dispatch between the two readers, so a YOLO root is reachable from a library call and not yet from lucid-yolo fit. Recorded as 099b rather than absorbed into the row, because a package that reports itself done while a clause of its scope is unbuilt is how a roadmap stops describing the code.

Three details of the shipped surface, kept out of the roadmap row for length. The reader produces the same Targets container the COCO path does, which is the property that leaves assignment, loss and metric code untouched by a second dataset format entirely โ€” the format question stops at the loader. Class names and split paths come from the dataset's own data.yaml rather than from a config, so a YOLO root carries its own label space. And a layout row was added alongside, so --data.data_root resolves a YOLO root the way it resolves every other, through resolve_yolo_split and from_root.

The clean-room instruction this was written under is AGENTS.md's prime directive, cited here because the register rows (A54-A58) record what was unknown and not what forbade looking it up.

WP-099b โ€” the tie-break that must not exist

resolve_split already had a precedence rule: candidate spellings are tried in table order and the first match wins. Reusing that shape across the two readers is the obvious move and it is wrong, for a reason worth stating in one line โ€” within a table the loser is another spelling of one reader, and between the tables the loser is a different label space, a different image set and a different parser. A first-match rule in the first case picks the wrong directory of the right dataset; in the second it trains on annotations nobody named and reports a plausible number while doing it. So the probe evaluates both tables in full and returns a pair of booleans: there is no order for the answer to depend on, and ambiguity is refused rather than resolved (A63).

The refusal only works if the operator has a way to answer it, which is what earned the explicit layout override its place. Two states are otherwise unreachable: an ambiguous root on a read-only dataset mount, where the only remedy would be moving files; and a YOLO root whose data.yaml points its splits outside the convention table, which from_root resolves โ€” it is why val: ../valid/images works at all โ€” and which no directory probe can see. A knob that exists because two legitimate datasets are otherwise unreadable is a different thing from a knob that exists because one was possible.

The construction-versus-setup split was forced by something outside this package. Refusing at construction is the better instinct and it collides with tests/ptl/test_cli.py, which instantiates every shipped config through LightningCLI(run=False) while configs/det_smoke.yaml carries a placeholder data_root. A filesystem verdict at construction fails that dry-parse for every run not yet pointed at data. The line drawn instead: everything decidable from the arguments alone raises at construction, and anything needing the disk raises at setup() โ€” still before an image is decoded and before the trainer takes a step.

Two absences the YOLO path has to declare rather than discover. mask_targets is refused, because the format carries no per-instance rings and rasterising empty ones would produce a segmentation run supervised by nothing that still reports a detection number. Copy-paste is the quieter one: unlike the oriented path it does not raise on polygon-free targets โ€” it decodes an entire extra source sample, finds no candidates and does nothing โ€” so at copy_paste = 0.1 every YOLO run would have paid for an augmentation that can never fire. The suppression keeps consuming the RNG draw, which is what leaves the COCO path byte-identical and the frozen data checksums unmoved. An augmentation that fails silently costs more than one that raises.

One more property of the override, which is what keeps it from costing anything: naming any COCO-shaped path โ€” train_images, val_annotations, any of them โ€” is itself read as stating the COCO layout. Every caller that predates the probe is therefore already off it, and the probe runs only for a root that has said nothing about its own shape.

WP-099c โ€” a pre-flight that asks the run's question

check_dataset defaulted to coco, which was correct for as long as COCO was the only layout a run could read. WP-099b ended that, and left the check answering a question its own run no longer asks: pointed at a YOLO root with no flag, it reported a missing train2017 for a tree fit trains on without complaint. The fix is not a third branch but a deletion โ€” an unstated --dataset now runs detect_layout, the same probe the datamodule dispatches on, so the pre-flight and the run resolve the root identically or not at all. DATASETS is built from DatasetLayout members rather than string literals, which is what stops the flag and the probe drifting apart later. DOTA stays outside it deliberately: no run reads a DOTA root โ€” it is tiled to COCO first (WP-094) โ€” so a probe covering it would advertise a path that does not exist, and the "satisfies no convention" report ends by naming --dataset dota for the operator who has one.

Both of the probe's undecidable states are verdicts about a disk, so both come back as report lines and exit 1. WP-097 already paid for that lesson once, on the first command an operator runs against a fresh provisioning: a traceback where a report belongs reads as a broken tool rather than an unusable dataset.

The extraction that mattered is resolve_split_dirs. A validator that resolves splits its own way validates a tree the reader will not open โ€” and the YOLO layout has exactly the case that makes this bite, a data.yaml naming ../valid/images for a directory no convention row spells (A58). Shared code between a feature and its checker is usually a smell, since the checker then inherits the feature's bugs; here it is the point, because the alternative is a check that passes on a different dataset than the one that trains. scan_yolo_label_file is the other half: the same grammar load_yolo_targets reads, stopping before the denormalization, so checking a split does not decode every image to obtain a pixel scale it immediately discards. Validation that costs an epoch is validation that gets skipped.

DotaSplitCheck became PairedSplitCheck, and the naming records what the two layouts actually share. It is not that both are "not COCO" โ€” it is that both put one text file per image beside it, paired by stem, so both need the same two-directional pairing report and the same totals arithmetic. COCO's single manifest cannot be checked that way at all. The shape of the annotation decides the container, and the rename says so where a Yolo-prefixed twin would have said nothing.

No register row. A64 is still unconsumed, which is worth stating explicitly because the run made three decisions that look like assumption material and are not: the inference default is grounded in A63 and WP-097, the YOLO branch reuses A54โ€“A58 through the reader rather than restating them, and the flag guard extends WP-097's own rule to one more layout. A register row for a decision that already has a documented parent is noise in the register that matters.

One absence left standing: splits is still not a CLI flag, for YOLO as it never was for DOTA, so a root shipping only train fails on the missing val. That is right for a fit pre-flight, which needs both, and wrong for anyone checking a partial download โ€” a small follow-up rather than a defect, and recorded here so it is a choice rather than an oversight.

WP-099d โ€” the code is the specification

The gap this closed: docs/DATASETS.md said where COCO and DOTA come from and nothing about what either layout looks like on disk or what one annotation contains, while the YOLO layout the project had just started supporting appeared in it nowhere at all.

docs/DATASETS.md also carries a standing rule that a layout reference is the first real test of: the code is the specification and the document follows it. Applied here, that means every claim about a directory convention is cited to the module that enforces it โ€” data/layout.py, data/yolo.py, data/coco.py, data/dota.py โ€” rather than described from the outside from what the layouts are believed to be.

The alternative is not a worse document but a second specification. Two independent statements of a convention agree on the day they are written and disagree the first time a candidate table gains a row, and the one a reader trusts is the one that cannot be executed.

WP-108 โ€” a cell grows where its section is missing

The Scope column had drifted to a 286-character median with a 1709-character worst case, and the drift is not random: of the 32 rows over 400, 12 had no RESEARCH_LOG section at all. A row whose package never got a section is a row where the only place to write down why a thing was done is the row itself, so the reasoning lands there and stays. The length is the symptom; the missing section is the cause, which is why this package wrote the 12 sections before compressing anything.

The gate was considered and rejected, and the reasoning is worth keeping because it runs against this project's usual instinct. Every other convention here is enforced by a meta test โ€” status icons, six columns, the header's package count, the register ids โ€” and the argument for those is that a documented rule with no gate decays. A length gate is different in kind: the others fail on a fact about the file, while a length gate fails on a judgement about how much a row needs to say, and the cheapest way to make it pass is to delete a claim. A rule whose easiest satisfaction is losing substance should not be automatic. So the median is written into the header as guidance, and the enforcement is a reader noticing that a cell runs several times its column's median.

What the compression turned up is that most of the overflow was already recorded somewhere with an owner. Twenty rows needed no new writing at all: their long clauses restate what the log section, or the assumption register, already carries โ€” A40's exact-under-similarity split, A59's core-ownership rule, A24's polygon measure. That is the duplication the header paragraph has always warned about, visible for the first time because something counted it. Three claims had no other home and were moved rather than cut, the sharpest being WP-064's: that the release discloses its per-tile figures in the report, the model card and the README. A disclosure claim living only in a roadmap cell is one edit from disappearing.

Two rows deliberately got no section: 091d and 091e were โฌœ, and their content was a finding of 091c's, recorded there. A log section for unstarted work would be a stub asserting what the package will conclude, which is the opposite of what this file is for โ€” so both rows pointed their ยท log tail at #wp-091c instead, and the sections got written when the packages produced something to record, which is the paragraph below this one.

WP-099e โ€” the gap was one layer above where it looked

WP-099c closed by naming this itself: splits was still not a CLI flag, so a root shipping only train fails on a missing val. That was recorded so it would be a choice rather than an oversight, and this is the follow-up. A third-party export whose validation split was never cut is a correct export, and the first command an operator runs against a fresh provisioning refused it over a directory nobody had promised.

What is worth writing down is where the parameter was actually missing. check_yolo_root and check_dota_root had both taken splits from the day each was written โ€” tests/data/test_check.py had been calling check_yolo_root(..., splits=("train",)) all along, because the fixtures only ever build one split. The capability existed; the route from the command line to it did not. So the fix is not a feature the layout checkers lacked but one argument check_dataset and _check_root dropped on the floor. A gap that presents as missing functionality is worth pricing against the layer below before implementing: the two root checkers were the expensive place to fix this, and the wrong one.

The default belongs to the layout rather than to the call. _check_root names YOLO_SPLITS or DOTA_SPLITS at the branch that already knows which layout it is dispatching to, instead of defaulting splits in check_dataset's signature. The two constants are equal today and are documented for different reasons โ€” DOTA's testing third has no public labels, YOLO's test: entry is a split no run of this project opens โ€” and an entry-point default would have quietly made them one fact, so that changing either would have to be discovered rather than read.

COCO refuses the flag rather than defaulting it, on the reasoning that already governs --expected_images there: check_coco_root checks train2017 and val2017 against 118,287 and 5,000, and the names and the numbers are a single published fact. There is no subset of that to ask for. Following _check_flags_apply's existing message shape was the whole of the design decision โ€” a flag that silently does nothing makes the report read as though it had been honoured.

The empty tuple is the sharper case, because it inverts the failure mode the rest of the module guards. Every other bad argument here fails loudly; splits=() fails cleanly โ€” no split checked means no problem found means PASS: dataset layout valid printed over a root nothing looked at, a false green from the one command whose purpose is to be believed. It is refused in _require_splits, called from check_dataset before the layout is even inferred and from both root checkers, because those two are documented as the importable core: guarding only the entry point would leave the false green reachable by the shorter route, and a library caller reading ok off the result has no report to notice the emptiness in. The entry-point call is what makes --splits '[]' fail at the command line, which is where an empty list is easiest to type by accident.

The annotation is tuple[str, ...] | None, and the CLI surface it produces was verified rather than assumed: jsonargparse renders it as a list literal, --splits '[train]', which is the spelling lucid-data download --splits has always required โ€” a bare --splits train is refused by both. One flag's syntax is not a second thing to learn. The --help line for it is less self-describing than download's, the | None union suppressing the [ITEM,...] metavar; cosmetic, and not worth changing the annotation for.

๐Ÿ“ฆ Phase 10 โ€” Consolidation

WP-066 โ€” an untrained checkpoint cannot tell you whether an export is right

The architecture-fidelity half of this finding โ€” the exported graph confirming the one-to-one, DFL-free claim โ€” is recorded at RESEARCH_LOG.md#wp-066. What is here is the export-and-comparison harness, which turned out to be where nearly all of the difficulty was.

The graph half of this package was the easy half. All three deploy paths export first try on the legacy TorchScript tracer at opset 20, in about 0.3 s each, and none of them contains a NonMaxSuppression node โ€” 598 nodes and 30 op types for detection, with a single TopK where suppression would be. dynamo=True, the torch 2.13 default, is not used: it needs onnxscript, which nothing else here needs, and the legacy path already emits fully static dims. Subgraphs are walked recursively, since an absence claim a node nested in an If body could evade is not an absence claim; no control-flow nodes appear at all. The two Softmax nodes are the attention block's, not a distribution-focal-loss box head's โ€” the DFL-free claim showing up in the emitted graph rather than in a docstring.

The absence of an op proves the graph's shape and nothing about its arithmetic, so the package also runs the exported model under onnxruntime against the checkpoint it came from. That is where the work was, and almost all of it went into discovering that a freshly initialized checkpoint makes the comparison meaningless while appearing to work.

An untrained BatchNorm in eval mode carries running_var=1 and running_mean=0, so it rescales nothing and the signal attenuates through depth: the neck's P3 output measures 4.1e-05 and the classifier's contribution to its own logit lands near 1e-7. The prior-probability bias is -log((1-pi)/pi) = -4.595 (A30), and float32 resolution at 4.595 is 4.8e-07. The contribution is an order of magnitude below the resolution of the number it is added to, so it vanishes: all 1344 class logits become one identical float32 value, torch.unique returns 1, and the top-k over 336 anchors is a 300-way tie. Torch and onnxruntime break that tie differently, both correctly, and a row-by-row comparison duly reported boxes 80 px apart. The head is not broken โ€” the same model in train mode, on batch statistics, gives 1343 distinct logits of 1344 at std 0.372. Only the eval-mode statistics are degenerate, and only until something trains them.

Scaling the classification weights to force separation does not work either: a sweep at 1e2 through 1e5 saturates the sigmoid, and from 1e3 up all 336 confidences collapse to exactly 1.0 โ€” the same degeneracy from the other end. What works is calibrating the BN running statistics with a few forward passes in train mode, then rescaling the stems to a measured logit spread rather than a guessed one.

A second degeneracy was hiding behind the first, and it is the one worth remembering. With the ranking fixed, the segmentation masks compared bit-exact: zero mismatches out of 4,915,200 pixels. They were all False. This head regresses ltrb directly with no DFL and no range cap, so an untrained box stem emits values straddling zero, every decoded box is inverted or empty, and decode_instance_masks crops each mask to nothing. A perfect score on an assertion comparing two empty tensors. Biasing the box stems positive gives 300 boxes with interiors and 5.07% mask foreground across 44 rows, and the same assertion then means something. The test asserts its own preconditions โ€” score span, positive widths, non-empty masks โ€” because the failure mode of this comparison is not a wrong answer, it is a vacuous one.

The oriented head appeared to fail and did not. Row-by-row it diverged by 88 px against 3e-05 for the other two, which reads exactly like a broken rotated decode. The tempting explanation was the A23 canonicalization swap, since w >= h flips discontinuously โ€” and measuring killed it outright: the minimum |w - h| over 300 rows is 4.9e-02, no row within 1e-05 of the branch point. Comparing each column as a multiset instead showed every column agreeing to float32 noise, so the exported graph had been selecting the same detections all along. The fault was the comparison: rows tied on score were ordered by raw cx, which clusters tightly because near-square boxes put cx at the anchor centre and a whole grid column shares one value, so 1.5e-05 of backend noise reordered them. Rounding the ordering keys to three decimals โ€” well above the noise, well below any real difference โ€” pairs the rows identically on both sides. Recorded because the shape of the mistake generalizes: an order-sensitive comparison of a tie-bearing output will accuse the implementation of an error the harness committed.

What the comparison is worth was then measured rather than asserted. Three deliberate corruptions of the baked-in constants โ€” transposed anchor points, an anchor grid built for the wrong canvas, and doubled strides โ€” are each caught on every head, nine of nine, at box deltas between 1.2e+02 and 2.0e+02 against a 1e-3 tolerance. Boxes agree to 4.6e-05, scores to 1.2e-07, class ids exactly, and the masks bit-exactly at both one and the default intra-op thread count.

The anchor grid and strides are baked into the graph as buffers rather than taken as inputs, so the exported model's only input is the image and the graph is specific to one input size. That is the normal ONNX trade, and it is pinned by asserting the graph has exactly one input, so the choice cannot drift into being an accident. onnx and onnxruntime are dev-group dependencies: the export itself is torch.onnx.export, already a runtime dep, so nothing a consumer installs changes.

One thing this package deliberately leaves standing. The decode composition now exists twice โ€” once in predict.py's E2E path and once in the test's graph wrappers โ€” with nothing forcing them to agree. A shared export.py composing deploy and decode would remove the drift risk, and it was not built here on the smallest-change rule; it is the natural home if a later package needs an exportable path it can ship.

WP-109 โ€” a wheel can ship a GPL binary and declare it nowhere

The package exists because of what sourcing WP-067's example turned up. supervision, the obvious drawing library for boxes, masks and rotated boxes, hard-requires av>=14.2. The av wheel declares BSD-3-Clause, ships a licenses/LICENSE.txt in which grep -ci gpl returns 0, and ships av/.dylibs/libx264.165.dylib. x264 is GPL-2.0. Running this repository's own audit functions against a throwaway install of it โ€” never the project venv โ€” returned declared: ['BSD-3-Clause'] | bundled: []: both checks clean, on a wheel carrying a GPL binary. The gate whose entire purpose is seeing this saw nothing, and the reason it saw nothing is that a vendored binary need not be documented anywhere at all. D15 was found by reading a license document the wheel happened to ship; this one had no document to read.

The third check reads the file list, and the honest description of it is that it narrows the hole rather than closing it. Filenames are matched against a named table of copyleft libraries โ€” x264, x265, mp3lame, GEOS, the eight FFmpeg libraries โ€” so a copyleft library the table has never heard of passes exactly as av did before the table existed. The alternative was considered and is not available: torch alone ships hundreds of legitimate native libraries, and a check failing on every unrecognized one would fire on every commit and be switched off within the week. What the table can honestly claim is that no listed library is present, and the module docstring says that rather than implying more.

Two things made the scan work at all, and both were near-misses.

The normalizer has to know what auditwheel does, or the check works on macOS and goes blind on Linux. The libgeos_c.1.19.2.dylib macOS ships verbatim arrives on manylinux as libgeos_c-abcdd5fa.so.1.19.2: the repair tool grafts eight hex characters onto the name. A table keyed on the plain stem matches every local run and nothing on the CI runner โ€” and the failure presents as a green gate, which is the worst shape a licence check can fail in. Verified against all 20 vendored libraries in the shapely and pillow manylinux wheels, the graft is uniformly eight lowercase hex characters; it is stripped before matching, and a live test on shapely fails if the convention ever changes.

The table's licence notes were the part most at risk of being written from memory. Each entry claims a licence and where that licence was read, which is worth exactly as much as the reading actually done. So they were read: VideoLAN's x264 page states x264 is released under the GNU GPL and separately available commercially; x265's own COPYING is the GPL version 2 text; FFmpeg's LICENSE.md states most of FFmpeg is LGPL v2.1-or-later and that --enable-gpl changes its licence to GPL v2+, naming libx264, libx265 and libxvid together as the GPL-v2 externals that flag admits; the LAME page states LGPL and names no version, so the entry names none either. GEOS needed no page at all โ€” shapely's installed LICENSE_GEOS opens License: LGPLv2.1, which is the same exposure D15 recorded reached by a second route, and it is what makes the table's behaviour observable in this environment rather than hypothetical.

The allowlist is keyed on (distribution, library) rather than on the package, which is the one place this check deliberately differs from the bundled-document allowlist beside it. That file's own rule, from D15, is that a package excused for one vendored library must not be excused for every other one it ships โ€” and a package-keyed entry here would break exactly that rule, since allowing shapely's GEOS would also wave through a libx264 it started shipping tomorrow.

The tests are two-sided on purpose: the synthetic av must be caught, and the live environment โ€” torch, numpy, pillow, onnxruntime, matplotlib, shapely, 340 native libraries between them โ€” must stay clean. Either side alone is worthless. A check firing on the current venv gets disabled by the next person; a check firing on nothing is decoration. The positive case is a hand-written .dist-info built through Distribution.at, so the real RECORD parser runs, because av is the one package this tree must never install to test against.

Cost is 0.09 s to 0.24 s on the pre-commit hook, ~0.15 s of it walking 87 wheels' RECORD files. Every distribution in this environment has one; a distribution installed without a RECORD ships nothing as far as this check can see, which is stated where the reader will need it rather than left to be discovered.

supervision is refused on the strength of this (D16) and WP-067's example is drawn with matplotlib, already a dependency (R29). The finding is the more useful artifact than the example would have been.

WP-067 โ€” the drawing library that could not be installed

The example was going to use supervision, which draws boxes, masks and rotated boxes and is Apache-2.0 at its own metadata. It is not installed here, and the reason is WP-109: it hard-requires av>=14.2, whose wheel ships libx264 โ€” GPL-2.0 โ€” while declaring BSD-3-Clause and shipping a licence document that mentions no GPL anywhere. Neither existing licence check saw it (D16). So the example is drawn with matplotlib, which the repository already carries for the training figures (R29), and the refusal is the more durable artifact of the two: the example is a hundred lines anyone could rewrite, and the check that refuses the dependency now runs on every commit.

A dev dependency must not become a runtime one by being convenient. matplotlib is in the dev group and nothing under src/lucid_yolo/ imports it at any level, so a wheel a consumer installs pulls no plotting stack. That is why this is scripts/draw_predictions.py beside plot_training.py rather than a lucid_yolo.draw module, and it is also why the deliverable is a script rather than the notebook the row originally named: a notebook is not reachable by make gate, and an example nothing runs is an example that rots.

The drawing functions take an already-computed prediction and an image array rather than a checkpoint. That is what makes the geometry assertable at all โ€” D14 ships no trained weights, so there is no checkpoint to draw from in a test, and a drawing function that insisted on doing its own inference could only have been tested through one.

The oriented case is the one with a wrong answer that looks right. An A45 row's first four columns are [cx, cy, w, h], which read as an xyxy box without complaint: a [:, :4] slice draws a plausible upright rectangle that is not the object the model reported, at the wrong place and the wrong size, and nothing in the figure says so. The corners come from rboxes_to_polygons, the same function the oriented report and the tile writer state an object with, so a drawn ring and a written ring are the same four points. The test asserts both halves โ€” that the four vertices match the rotated corners, and that the ring is not axis-aligned โ€” because the envelope of a rotated box contains all four of its corners too and would satisfy the first assertion alone.

Two smaller decisions worth the sentence each. Colour is a pure function of the class index, not a palette walked in encounter order, so two figures of the same scene are comparable by eye and a class missing from one image does not shift the colours around it. And masks are drawn one overlay per instance rather than one composited layer, because the question a segmentation figure is read to answer is which instance covers a pixel, and a single layer can only answer that some instance does โ€” boxes and masks are filtered by one row mask computed once, since two filters are how a mask ends up drawn on its neighbour with the counts still agreeing.

What the tests cannot see is whether the figure reads. Artist assertions pin geometry, colour and count; legibility โ€” where the label sits, whether the mask alpha leaves the object visible โ€” was checked by rendering three previews and looking at them. That is stated rather than hidden, because the gate does not cover it and the next person changing the layout should know which half of this is machine-checked.

WP-110 โ€” a grep for a dead command cannot find its dead grammar

WP-096 deprecated lucid-download in 0.3.0 and wrote the removal date into three places: [project.scripts], AGENTS.md sec. 2, and the 0.3.0 changelog. 0.4.0 is that date. The alias existed so published reproduction instructions kept running, which is a real cost to removing it โ€” and the way that cost is paid is not by keeping the alias forever but by making sure every string this repository prints names a command the reader can still run.

The sweep was driven by grepping the alias name, and that method has a hole the grep cannot see. A hint that names lucid-download is findable; a hint that uses the alias's flag grammar without naming it is not. Parse-testing every CLI fragment in the module's own docstrings found one: the checksum policy spelled --sha256 val2017.zip=<hex>, the bare argparse form, which the shipped jsonargparse command rejects outright โ€” error: Parser key "sha256": Expected a <class 'collections.abc.Sequence'>. It needed '[val2017.zip=<hex>]'. Nothing that greps for a name would ever have surfaced it, and it would have shipped as documentation that fails when followed.

The same shape of mistake was in the test that guarded the repair hint: it asserted the hint string appeared on stderr. That assertion would have passed unchanged while the command it named was being deprecated out from under it โ€” it tests presence where the thing worth testing is runnability. It now shell-splits the printed hint and re-parses it through the shipped parser, so a hint that stops parsing fails here rather than in an operator's terminal.

Nothing turned out to be shared. lucid-data download reaches download_dataset through jsonargparse's add_function_arguments, never add_arguments, never main, never an argparse.ArgumentParser โ€” the deleted pair was referenced from exactly two places, each other and the test file. The near-trap was that add_arguments's own docstring claimed it registered "the lucid-data download flags", untrue since WP-096; anyone trusting the docstring over the call graph would have kept it as shared code.

Removing the alias also removed the one exemption to the project's underscore-flag rule, and AGENTS.md sec. 2 was stale on a second count anyway: lucid-predict shipped in WP-089 and the bullet still counted three commands. Both corrected in the same edit, since they are one sentence describing one surface.

Two references are left standing deliberately. The reproduction report's "commands as run" blocks keep their dashed spellings โ€” they record what was executed at the version named, and editing them would falsify the record rather than update it โ€” with the surrounding prose changed to say the alias was removed and that the block must be read through its replacements rather than run as written.

๐Ÿ” Phase 11 โ€” Rolling, toward 0.5.0

WP-112 โ€” two compositions of the same decode, and the one that can't filter

WP-066 needed one importable nn.Module per task so torch.onnx.export had something to trace, and wrote three small wrappers private to the test file to get one. predict.py composes the same pieces โ€” a task's deploy() view plus its E2E decoder โ€” for single-image inference, independently. WP-066's own log entry named the risk and declined to fix it: nothing forced the two compositions to keep agreeing as either side changed, and nothing shipped needed a third caller to justify the extraction. Roadmap 112 is that extraction, now that something does.

src/lucid_yolo/export.py reproduces predict_image/predict_segmentation/predict_oriented's "e2e" compositions call for call โ€” same decoder calls, same argument order, same decode_instance_masks(prototypes, coefficients, boxes, image_size=...) order, same decode_rboxes -> o2o_rotated_topk pairing โ€” so the exported graph and the single-image path can be shown to agree rather than merely resemble each other. predict.py itself is untouched: it calls a raw DetectionLitModule and decodes an unbatched result with confidence filtering, a genuinely different call convention from a traced graph's fixed-shape nn.Module, and unifying the two call conventions was never this row's scope.

One divergence is real and stays, documented rather than engineered away. predict_segmentation drops padding rows (anchor_index == PAD_ANCHOR_INDEX) before gathering mask coefficients, since a single-image caller wants a mask per real object and a boolean filter is free to shrink the output. A traced graph's output shape is fixed at trace time, so SegmentExportGraph gathers every one of its k rows, padding included โ€” which only stays safe because a padding row's index is a valid position in the coefficient tensor exactly when the canvas's anchor count exceeds k. Below that, gathering at the padding sentinel (-1) raises. This is the same constraint the ONNX export test's 128 px canvas (336 anchors, above the 300 cap) was already built to satisfy; export.py's module docstring restates it because the module now has callers that test fixture does not.

WP-113 โ€” an emoji changes the anchor a renderer derives

Decoration with one load-bearing consequence. GitHub and Python-Markdown both slugify a heading by stripping what is not a word character and joining the rest with hyphens, so ## ๐Ÿง  How a modern YOLO works no longer answers to #how-a-modern-yolo-works โ€” the stripped emoji leaves the leading separator behind. The README's own audience table links to four of its H2 sections, and every one of those links would have gone dead in the same commit that made the page prettier, with nothing failing anywhere.

The fix is not to spell the new slug. The two renderers this project publishes through do not have to agree on what a slug becomes, and one that today keeps a leading hyphen is free to trim it tomorrow โ€” so a link written against a derived anchor is a link written against a renderer's current behaviour. Explicit <a id=> tags above the four headings are what the research log already uses for exactly this reason: they are greppable, they survive a retitle, and they mean the same thing to both renderers.

Two gates read heading text and had to stop. test_report_sections pinned four ##-prefixed literals from the reproduction report and test_decisions_carry_all_ids pinned ## ADR-00N; both would have failed on a purely visual edit while reporting a missing section, which is a gate that misdescribes its own finding. They now compare undecorated titles โ€” a lead token holding no ASCII alphanumeric is decoration, anything else is title โ€” so the assertion is about which sections exist, which is what it was always for.

The emoji themselves are chosen per section and reused across files only where the subject is the same: ๐ŸŽฏ for detection, ๐Ÿ–Œ๏ธ for segmentation, ๐Ÿ”„ for oriented detection, in the roadmap phase, the log phase, the model card and the training recipe alike. A palette walked in file order would have been faster to produce and would carry no information.

WP-113b โ€” a README cannot be relative in two places at once

pyproject.toml hands README.md to setuptools as the long description, and PyPI resolves its relative targets against pypi.org: the three training-curve figures render as broken images and every docs/โ€ฆ link 404s. The same file has to stay relative in the git tree, where relative is exactly what works. So the rewrite belongs to the moment of packaging rather than to the file, and it is opt-in per invocation โ€” --ref v0.4.0 or LUCID_YOLO_RELEASE_REF โ€” so an ordinary make build or a pip install . cannot reach it.

Two hosts, because the two link classes fail differently. A figure served through github.com/.../blob renders an entire HTML page inside an <img>, which is a broken image; a document served through raw.githubusercontent.com hands the reader unrendered markdown. Images therefore resolve through raw, documents through blob, and the classifier keys on the markdown image bang or the target's suffix, since an HTML <img src=> carries no bang.

A branch may not be pinned. A released wheel's README is a snapshot of one tree, and a link into main describes whatever that branch holds when a reader clicks it โ€” which is how a page for 0.4.0 ends up documenting code that shipped years later. Only a v0.MINOR.PATCH tag or a full commit sha is accepted.

The slug is read from project.urls.Homepage, and reading it exposed a live defect. The declared homepage was Borda/lit-YOLOs; the repository's canonical full_name is Borda/lucid-YOLO. GitHub redirects a renamed repository's HTML URLs, so the stale slug looks fine in a browser and every blob link would have worked โ€” but raw.githubusercontent.com does not redirect, so precisely the three figures this package exists to publish would have 404'd, while the links beside them resolved. A rename is invisible until something reads the metadata programmatically.

What the tests cannot establish is whether the produced URLs resolve. The repository is private at the time of writing, so every generated link 404s for an anonymous reader regardless of correctness; the URLs become true when the repository is public and the tag exists. That is stated rather than asserted, because a test that fetched them would be a test of the repository's visibility settings.

WP-114 โ€” a default list that vanishes when you name one member

The site publishes what the repository already holds: mkdocs.yml owns no prose beyond docs/index.md, no plugin generates a page from source, and every register stays a plain markdown file a reader can open on GitHub without the toolchain. The work was configuration, and configuration is where a wrong value reports success.

Naming one member of a MkDocs default list replaces the whole list. Supplying plugins: at all drops the implicit search, so a site whose search box finds nothing builds green; supplying markdown_extensions: drops tables, and this repository is wall-to-wall pipe tables โ€” the assumption register, the roadmap, every acceptance table would render as literal pipe characters on a build that reports no error. Both are restated explicitly, and tables is asserted in scripts/_tests/test_audit_docs_site.py rather than trusted to stay listed.

--strict is the whole value of building locally. It fails on a link to a page that does not exist and on a page the nav never lists. A register nobody can navigate to is one nobody reads, and neither failure raises anything without the flag.

A second config file needed a second hook instance, for the same reason docs/ did. Material's mermaid fence is enabled through a !!python/name: tag; MkDocs parses its config with yaml.Loader and constructs it, pre-commit's check-yaml uses safe_load and cannot. So check-yaml is now two instances โ€” the default one excluding mkdocs.yml, and an --unsafe one scoped to it, which still validates the syntax of a 120-line nav without importing anything from it. mdformat split the same way and for the same shape of reason: docs/ is now rendered by a dialect the rest of the tree is not written in, and running the mkdocs plugin over the README or AGENTS.md would apply that dialect to files no site renders.

The audit has to run where the exposure enters. The docs group is deliberately outside make setup and outside dev: no gate imports it, and a contributor who never builds the site never installs the tree. The consequence is that no other workflow's environment contains it, so the licence audit โ€” which scans the installed environment rather than the diff (D15, D16) โ€” runs in the docs job, ahead of the build. Measured over the installed tree: 106 distributions, 343 shipped binaries, no GPL-family licence declared, bundled or shipped. One transitive is not permissive and is named rather than buried: certifi is MPL-2.0, weak file-level copyleft, reached through mkdocs-material โ†’ requests; MPL-2.0 was already present via pathspec, which arrives with mypy.

configure-pages reads by default, and cannot do otherwise with the token CI has. Its enablement input defaults to false, and its own action.yml states that enabling requires a token other than GITHUB_TOKEN. So pages: read is the ceiling that job can use rather than a permission it is being denied, and the step is skipped on pull requests: it fails when Pages is not enabled, which would turn every PR red for a reason no PR can fix.

WP-114b โ€” an unlicensed dependency is not one the audit can see

Building the site prints a notice from the Material team about the upstream MkDocs 2.0 release. One line of it is a licence fact rather than an opinion about the release: "Currently unlicensed โ€“ unsuitable for production use". This project admits permissive licences only, and unlicensed is not a weaker version of permissive โ€” it is stricter than the AGPL the audit bans, because the default with no declared licence is no grant at all.

The audit would not have caught it. scripts/audit_licenses.py matches COPYLEFT_PATTERN โ€” a GPL-family regex โ€” against what each installed distribution declares, and reports only what matches. A distribution that declares nothing matches nothing and passes silently, in the same run that reports the environment clean. The control this project relies on for licence exposure is shaped to catch a forbidden declaration, not an absent one, so the bound has to be a version cap: mkdocs>=1.6,<2, which keeps the resolution from happening rather than detecting it afterwards.

The cap is asserted in scripts/_tests/test_audit_docs_site.py because it is the kind of pin a routine dependency bump lifts without anyone deciding to. The assertion is keyed on the version operator directly after the name, since mkdocs-material shares the prefix and sits two lines away in the same list โ€” without that, a deleted mkdocs pin would move the assertion silently onto its neighbour and keep passing. Resolution after the cap: mkdocs 1.6.1, mkdocs-material 9.7.7, 29 packages. Lift it when 2.x ships a permissive LICENSE, verified from the repository rather than from the notice.

WP-115 โ€” the check that could not see what it was capping

scripts/audit_licenses.py read three surfaces and each of them matched a forbidden pattern against a declaration, which meant a distribution declaring nothing matched nothing and passed โ€” in the same run that printed "license audit clean". WP-114b hit that wall directly: the exposure it wanted to prevent could only be bounded with a version cap, because the audit had no way to report it. The fourth surface is the absence of a licence.

Reading a licence with no field to read it from. One installed distribution declares nothing: faster-coco-eval 1.7.2, PEP 639 metadata with no License, no License-Expression, no License :: classifier, shipping licenses/LICENSE holding the Apache-2.0 text. So the naive rule โ€” no field, no pass โ€” would have failed on a genuinely Apache-2.0 base dependency on its first run, which is how a gate gets turned off. bundled_license_indicators was no help by design: it reads License: ... declaration lines rather than prose, because a bundled LGPL names GPL on dozens of its own lines, and the Apache text carries no such line at all. The recognizer added here identifies a document from a distinctive phrase in its own first 15 lines, and identification is the verdict.

The copyleft pattern is deliberately not run over a recognized text as a second opinion. MPL-2.0's own Secondary-License clause names the GNU General Public License, the GNU Lesser General Public License and the GNU Affero General Public License in consecutive lines. A belt-and-braces re-scan would therefore fail the audit on a licence this environment already carries through certifi and pathspec. The table lists permissive texts only, so the recognizer never has to decide that something is forbidden โ€” an omission costs a false alarm, never a silent pass. The escalation runs the other way: a document that was not recognized and whose header names a GPL-family licence is a failure in any tier, because "no AGPL at any cost" does not care which group reached the package.

Two tiers, and only the fourth check is graded. A package in the [project.dependencies] closure is republished in this project's own wheel metadata and installed by everyone; an unreadable licence there fails. A package reachable only through dev or docs appears in no wheel metadata โ€” PEP 735 groups are not published at all โ€” is imported by src/ never, and is vendored into no artifact; it is printed as a flag and the run passes. Both-tier packages take the stricter attribution, and so do packages the walk cannot attribute, so a hole in the resolver presents as a loud failure rather than as a quiet demotion.

Attributing the tier was the larger half. The audit walks a flat installed environment; the tiers are a property of pyproject.toml, so the closure has to be resolved the way an installer would. The first attempt dropped every requirement carrying an extra marker and left 19 of 105 distributions unattributed. Carrying the requested extras through the walk and evaluating each marker against them closes that: the residue is one distribution, lucid-yolo itself, which no dependency list mentions because it is the project. Final attribution: 56 base, 29 dev, 19 docs. packaging became a declared dev dependency in the process โ€” it was already there transitively through both pytest and mkdocs, and a direct import is not a transitive dependency (R34).

The audit still prints one line on a clean run, and the flag lines only when there are any.

WP-115b โ€” the first thing the new check found was real

WP-115 shipped a check for the absence of a licence and it fired within a day, on a CUDA environment this laptop is not: cuda-toolkit, reported as a base-tier failure. The first question was whether the check was wrong. It is not. The wheel was downloaded and opened: cuda_toolkit-13.3.1.dist-info/ contains METADATA, WHEEL and RECORD, and nothing else. No License, no License-Expression, no License :: classifier, no licence document declared or undeclared. Its every requirement is extras-gated, so a bare install pulls nothing at all. There is genuinely no statement of terms in that distribution to read.

So the resolution is an allowlist, not a weaker check. UNREADABLE_ALLOWLIST is the fourth allowlist in this file and is kept separate from the three beside it for the reason D16 already gave about the third: the exposures differ, and folding them together lets one package's silence excuse another package's declaration. ALLOWLIST excuses a package's own copyleft declaration, BUNDLED_ALLOWLIST and BUNDLED_BINARY_ALLOWLIST excuse a vendored copyleft library, and this one excuses metadata that says nothing. Entries require a DECISIONS.md row first, as all three others do; cuda-toolkit is the only one.

CUDA is admitted as an environment, not as a licence (D17). The NVIDIA CUDA EULA is proprietary, which the permissive-only policy would otherwise refuse outright. What makes it out of scope is that pyproject.toml declares no CUDA package, this project redistributes no part of the toolkit, and it publishes no trained weights (D14) -- so what the EULA covers is the machine an accelerator run happens on rather than anything this repository ships. That is written down rather than left as a silence, because an exemption nobody recorded is indistinguishable from an oversight.

A second, smaller thing the finding exposed. The report printed (base) for two different situations: a package the shipped closure actually reaches, and a package the walk never reached at all, which defaults to the strict tier by design. Both are failures, for opposite reasons, and spelling them identically sends a reader looking for a dependency declaration that does not exist -- which is exactly the wrong first move when the real answer is "nothing requires this; it was installed by hand". The unattributed case now says so in its own reason.

And the suite stopped reading the installed environment. Seven tests and nine doctests asserted over metadata.distributions() -- the whole venv is clean, shapely really does ship GEOS, torch attributes to base -- and none of those is an assertion about this code. A contributor who installs anything can fail them without touching a line of the audit, which is exactly what cuda-toolkit did. Had the allowlist landed on its own, test_the_live_environment_has_no_unreadable_license would have gone green and the next package installed on that box would have turned it red again. The live claim belongs to the pre-commit hook, which runs the audit against the real environment on every commit and is the actual gate; a test's job is that the audit decides correctly, and synthetic distributions answer that without depending on what anyone happens to have installed. The shapes those fixtures are written to were read off real wheels once and recorded in their docstrings -- the cuda_toolkit dist-info listing above is one of them.

Two things that went with it are worth naming. The GEOS true-positive test is gone; what stands in for it is the synthetic manylinux spelling (libgeos_c-abcdd5fa.so.1.19.2 through library_stem) plus the parametrized normalizer cases, so the regression it guarded -- the stem normalizer going blind on Linux, where CI actually runs -- is still caught. And audit = _load_audit() at module level became a session-scoped fixture: loading a non-package script by path is fine, doing it as an import side effect at collection time is not.

WP-116 โ€” a formatter that reads Eq. as the end of a sentence

docformatter joins the three formatters already on the commit hook, configured in [tool.docformatter] and ordered ahead of ruff-format so the docstring body is rewritten first and the quotes and indentation around it normalized second -- the reverse order converges too, but only on the commit after.

Wrapping is off in both axes, and that is the load-bearing setting rather than a timid one. wrap-descriptions = 0 because this project's docstrings carry argued paragraphs, **bold** lead-ins, and Google Args:/Returns: blocks whose indentation is what Napoleon parses; re-flowing them to a column would run those blocks together and rewrite hand-chosen line breaks across the whole tree in a single commit nobody could review. wrap-summaries = 0 for a smaller reason: a summary is one sentence by construction and has nothing to gain from being re-flowed.

What it cost once was 32 summary lines. docformatter decides where the summary ends by finding the first period it reads as a sentence end, and it reads every abbreviation that way. This repository writes the papers' own notation -- R1 Eq. 15, blueprint sec. 5.9, Redmon et al. -- in summary lines, so those summaries were cut mid-citation and everything after them re-indented as continuation text, which is what turns an Args: block into prose Napoleon cannot parse:

"""Initialize the two coarse-level projections required by Eq.

8.
        Args:
            in_channels: Per-level neck channel counts ``(N3, N4, N5)`` in

No option prevents it. --wrap-summaries 0 disables wrapping, not the split; --non-strict governs reST list detection; --docstring-length would only choose which docstrings get mangled. So the source is what changed: Eq. and sec. are spelled out and et al. rephrased, in summary lines only -- the bodies keep the papers' abbreviations, since nothing reads them for sentence boundaries. One summary genuinely held two sentences and was split into a summary and a body paragraph, which is what PEP 257 asked for anyway.

non-cap names 17 identifiers. The formatter capitalizes a summary's first word, which is right for English and wrong for a name: lucid-yolo: became Lucid-yolo:, mAP became MAP, and empty(), backward(), rboxes[i], o2o and the nine subpackage names were all renamed to something that does not exist. Each is listed rather than the check being disabled, so a future summary opening with an ordinary English word is still capitalized.

The hook now reformats nothing, and that is the honest result. Every line in this commit's diff is the one-time rephrase; docformatter's own second pass is empty, because these docstrings were already PEP 257-clean. Its value is prospective -- it is a gate on what gets written next, not a repair of what is there.

WP-116b โ€” a hook pinned to no interpreter in particular

WP-116's docformatter hook carried no language_version, so pre-commit built its isolated environment against whatever python3 resolved first on PATH at hook-creation time. That happened to be a 3.11 install then; a later python3.10 framework install on this machine moved ahead of it in PATH, and the next pre-commit run silently rebuilt the hook's environment against 3.10 -- a version below this project's own requires-python = ">=3.11" floor.

docformatter's own config reader needs tomllib, stdlib only since 3.11, to parse [tool.docformatter] out of pyproject.toml. Under 3.10 that import is simply absent, and the hook failed with NameError: name 'tomllib' is not defined rather than anything naming the real cause. Every other hook in the file that needs a specific interpreter says so explicitly -- mypy and license-audit both pin entry: .venv/bin/python. docformatter is a third-party hook rather than a local one, so it takes language_version: python3.11 instead: the same guarantee, expressed the way pre-commit resolves environments for repos it clones rather than ones this project owns.

This is the same fault as cross-venv above -- an environment silently reconstructed between two commands -- reached through a different door: there it was uv run rebuilding .venv, here it is pre-commit rebuilding one hook's env off unpinned PATH resolution. Neither the tree nor the hook's own config changed; only what interpreter answered to python3 did.

WP-117 โ€” documented and verified are not the same claim

WP-085 wired --doctest-modules over src, scripts and tests, which turns a helper's Examples: block into an executable check the moment that block exists -- but nothing forced it to exist. A fresh ast count over tests/**/test_*.py found 261 non-fixture, non-test_ helper functions, every one already carrying a docstring, and only 1 of the 261 carrying a >>> line --doctest-modules could actually run. Prose describing behaviour a reader had to trust, not a line pytest ever executed. 23 of 678 test_ functions carried no docstring at all, all 23 in one file, tests/data/test_download.py.

Closing the gap by hand, file by file, surfaced real bugs the doctests would otherwise have hidden behind a passing suite: _write_split's and build.convert_split's return values auto-printed inside a with block under doctest's "single" exec mode, needing _ = ... to suppress; AssignResult has no .labels attribute, only .target_labels; Trainer has no .deterministic attribute; _decode_boxes's hand-derived expected output was numerically wrong until computed from a real run rather than worked out on paper. Each was a doctest that failed on first execution -- exactly the outcome WP-085's infrastructure exists to produce, and exactly what a prose-only docstring would never have surfaced.

A second, unrelated inconsistency turned up mid-pass: some docstrings wrote a bare >>> block, others wrapped it in a proper Examples: header matching the Napoleon style predict.py and export.py already use. An ast-based script fixed 166 docstrings across 56 files in one pass -- locate each docstring's span, detect a >>> line with no preceding Examples:, insert the header and re-indent -- verified against a doctest re-run before and after rather than assumed safe.

The "8 groups across 6 files" class-regrouping half of the original scope did not survive contact with measurement: a first-shared-word ast scan returns ~90 candidate groups, because this suite's own descriptive-sentence naming house style is indistinguishable from genuinely flat enumeration by any heuristic tried. Split off into WP-128 rather than guessed at.

WP-118 โ€” one log grew two audiences

RESEARCH_LOG.md had accumulated 85 entries answering two different questions -- what a reproduction claim cost to establish, and what the repo's own tooling cost to build -- under one charter that named only the first. The immediate trigger was smaller: rows 115, 115b and 116 had each grown to a paragraph of debugging narrative in the roadmap's own Scope column, because the log existed but the habit of writing to it first did not.

The split rule: does an entry record what the reproduction claims (model, loss, data protocol, eval numbers, paper ambiguity), or how the repo builds, checks, and publishes itself (CI, hooks, packaging, licence, formatting, CLI, docs tooling)? Most of the 85 sorted cleanly -- the whole of Cross-cutting and Phase 0 turned out to be tooling-kind, the whole of Phase 7 and most of Phase 8/9 stayed fidelity-kind. Five did not sort cleanly, because their finding genuinely was both: WP-066 (export tooling and an architecture-fidelity confirmation), WP-079 (a DataLoader bug and a measured cost to augmentation diversity), WP-083 (a golden-harness design and the frozen numbers it pins), WP-091b (a decoder implementation and an unsourced-threshold provenance investigation), WP-097 (a download-verify bug and what R18's published totals actually describe). Those five were not forced into one bucket; each is cut into two pieces, cross-linked both ways, sized to what each piece actually carries rather than split down the middle.

The mechanical risk in a retroactive split of this size is silent loss -- a paragraph dropped in the move, an anchor a roadmap row still cites that no longer resolves. The move itself was scripted rather than hand-edited per entry, asserting the parsed anchor set equalled the classification set before writing anything, so nothing could be silently skipped or double-counted; scripts/_tests/test_audit_docs_present.py::test_every_log_link_resolves (renamed from its single-file predecessor) now checks both files' anchors against the roadmap's citations of either.

WP-038 moved the experiment configs into src/lucid_yolo/configs as package data, so a wheel-only install could run --config, and kept a repo-root configs -> src/lucid_yolo/configs symlink so the commands already documented at the time -- ones that typed configs/det_smoke.yaml from repo root -- kept working. That was the right call then: a compatibility shim is cheaper than rewriting every example the moment a layout changes underneath them.

What made it safe to drop now is that nothing in the tree still needed it. Every path that resolves the configs directory in code does so package-relatively -- Path(lucid_yolo.__file__).resolve().parent / "configs" in tests/ptl/test_cli.py and scripts/overfit_micro.py, Path(__file__).resolve().parents[1] / "configs" in packaged_config() -- none of which touch the repo root at all. And _resolve_config_args (also WP-038) already does the harder version of what the symlink did: a --config value that names a packaged config by bare filename, with or without .yaml, resolves onto the installed lucid_yolo/configs tree when no such file exists locally, so lucid-yolo fit --config det_smoke.yaml already works identically from a checkout or a wheel install, no path and no symlink required.

The one place the symlink was load-bearing rather than redundant was prose: train.py's own module docstring showed python -m lucid_yolo.cli.train fit --config configs/det_smoke.yaml, a literal root-relative path that only resolved because the symlink put a real directory at configs/. That is now the bare-name form the resolver was actually built for. AGENTS.md's configs/data/*.yaml and two comments in tests/ptl/test_cli.py and scripts/overfit_micro.py that named the bare configs/ directory are reworded to src/lucid_yolo/configs/... or lucid_yolo/configs/... for the same reason -- accurate once nothing at repo root answers to that name. Historical entries elsewhere (ENGINEERING_LOG.md's own WP-099b entry, completed roadmap rows for WP-038 and WP-099c) describe what was true when they were written and are left alone; a backfilled record does not get edited to match a later decision.

WP-127 โ€” a version number is not a run's identity

Lightning's own default checkpoint path is lightning_logs/version_N/checkpoints/epoch=X-step=Y.ckpt -- version_N numbers the run, sequentially, across every task and scale a working tree has ever fit; nothing in the path names what the run actually trained. WP-111's re-scoring of WP-064's OBB-smoke checkpoint hit this directly: version_10 said nothing until cross-referenced against its hparams.yaml's task: obb field, a step that only works because the checkpoint happened to still carry its hyperparameters.

The fix stays inside DetectionCLI.instantiate_trainer (cli/train.py), the same injection seam WP-038 already uses for the progress bar and the default logger pair: unless a config places its own ModelCheckpoint in trainer.callbacks, one is now injected with filename=_checkpoint_filename(self.model.task, variant) -- a new pure helper prefixing {task}_{variant}_ onto Lightning's own {epoch}-{step} template, left as a literal placeholder so ModelCheckpoint's own metric-filling machinery still resolves it. dirpath is left at Lightning's default (<version dir>/checkpoints), so lightning_logs/version_N numbering is untouched and no already-written checkpoint moves or is renamed -- only new runs pick up the prefix. task comes from self.model.task (the instantiated module, already built by the time instantiate_trainer runs) rather than the raw config, since only the module's own default (task="detect") is guaranteed correct once link-computed arguments are in play.

The injection duplicated the progress-bar callback's four-line "append without replacing the config's own list" pattern verbatim, so it moved into a small _add_trainer_default_callback method both call into, rather than being copy-pasted a second time.

WP-128 โ€” a shared prefix is not a shared caller

WP-117's own count -- "8 groups across 6 files" -- was a one-off human read, not a reproducible measurement, and the row it left behind said so: a first-shared-word ast scan over test_ names returns roughly 90 candidate groups, because this suite's house style already writes full descriptive sentences (test_the_boundary_sits_at_the_midpoint_of_the_overlap_band) that share a topic noun with unrelated tests -- indistinguishable from genuinely flat, mechanical enumeration (test_<subject>_<case>) by prefix-matching alone.

The discriminator that breaks the tie: a candidate prefix confirms only if it is a contiguous token subsequence -- prefix, suffix, or infix -- of some ast.Call function-name target actually present in the same file (Attribute.attr or Name.id, underscore-split, leading-underscore tokens dropped). A house-style sentence sharing a topic noun calls nothing that looks like it and never confirms; test_check_data_a/b/c calling check_data(...) does. Confirmed groups need 3+ members to enforce, matching the size floor tests/eval/test_tile_merge.py's own precedent already implied by example. Verified both directions against real cases in this tree: correctly includes check_data, plan_archives, verify_split, configure_optimizers, warmup_decay, on_after_batch_transfer, detections_to_predictions, and rotated_topk (confirmed as an infix of o2o_rotated_topk); correctly excludes deployed_view-style near-misses and the a_named_worker_count_* / an_auto_chosen_* house-style family WP-117's row already flagged as the hard case.

New scripts/lint/audit_flat_test_groups.py owns this rather than extending audit_test_doctests.py (129) -- that script's own docstring disclaims scanning for anything but missing doctest Examples, and stapling an unrelated structural check onto it would have made neither job legible from the file alone. Same shape as every other scripts/lint/ checker: main(argv) -> int CLI, a flat-test-group-audit local hook gated on files: ^(tests/.*/test_[^/]+\.py|scripts/_tests/test_[^/]+\.py)$ (pure repo content, not installed-environment state, so no always_run), and scripts/_tests/test_audit_flat_test_groups.py as the functional core -- synthetic tmp_path fixtures per confirmed/rejected case, plus the two "live tree is currently clean" sanity tests the hook itself depends on staying true.

Nine files regrouped under class Test<Subject>: once the discriminator was run against the whole tests/ and scripts/_tests/ tree rather than continuing WP-117's by-eye survey: test_verify.py, test_download.py, test_coco_eval.py, test_schedule.py, test_dota_parse.py, test_coco.py, test_obb_head.py, test_tiling.py, test_proto.py, test_module.py, and scripts/_tests/test_audit_docs_present.py (all 12 of its check-function pairs, for whole-file consistency, though the discriminator's crude grouping key only flagged 6) and test_audit_docs_site.py. Regrouping moves node ids, not test bodies -- Python resolves a module-level helper name at call time, not at class-body-definition time, so relocating a scattered function into a contiguous class needed no change to whatever module-level fixtures it called. Every citation of a moved node id in docs/ROADMAP.md (rows 044, 051, 110) and docs/ASSUMPTIONS.md (A45) was checked against a grep for the old id and updated; row 044's citation had already drifted stale before this WP touched the file (test_oracle moved under a pre-existing TestOracleRoundTrip by earlier, unrelated work) and was corrected in the same pass since the row was already open.

WP-129 โ€” a check that scans the repo is not a unit test of it

WP-117 landed its enforcement as tests/meta/test_test_suite_quality.py: a pytest test whose body is an ast walk over tests/**/test_*.py, asserting no helper lacks a doctest Example. Functionally that is a lint check -- it scans repo content for a house-style violation, the same shape as audit_licenses.py scanning the installed environment for a licence violation -- wearing a pytest test's clothes because that was the fastest place to put it at the time.

The fix is the split this project already draws for audit_licenses.py and check_commit_trailers.py: the check itself is a scripts/ script with a main(argv) CLI entry point, invoked by a .pre-commit-config.yaml local hook; pytest keeps a meta-test exercising the script's own functions (module_level_helpers, has_doctest_example, find_missing) against synthetic tmp_path fixtures, the way test_commit_trailers.py exercises validate_message against synthetic commit messages rather than real repository history. Pytest stays scoped to the functional core -- does this function do what it claims, on cases chosen to exercise it -- and the hook owns the enforcement gate against the live tree.

Unlike license-audit, the new test-doctest-audit hook is not always_run: true: it scans pure repo content (tests/**/test_*.py), not installed-environment state that can drift without a tracked-file change, so it is gated on files: ^tests/.*/test_.*\.py$ and only runs when a test file is actually part of the commit.

Moving the script surfaced one more inconsistency worth fixing in the same pass rather than leaving for a future WP to trip over: scripts/ had grown to eleven files at its root with no separation between the two things a hook-invoked checker and a golden-regression producer actually are. audit_licenses.py and check_commit_trailers.py -- already hook-invoked -- move into a new scripts/lint/ alongside the new script; audit_licenses.py's own PYPROJECT path, resolved via Path(__file__).resolve().parents[N], needed its N bumped for the extra directory level, caught immediately by the pre-commit hook itself failing on the next run rather than silently reading the wrong file. check_goldens.py, golden_producers.py, shapes_regression.py and the rest of the golden/training/release tooling stay at scripts/'s root: their dotted scripts.<module>:<function> paths are written into checked-in goldens/**/*.json producer fields as data, not just import statements, and rewriting those paths is a fixture migration across files that took minutes-each GPU runs to produce -- a materially different, materially riskier change than moving two files whose only callers are a Makefile line, a CI step and their own meta-tests.


WP-130 โ€” a test of a script belongs beside the script

WP-129 split the doctest-audit check itself out of tests/meta/ but left its test where the whole family had always lived, tests/meta/test_audit_test_doctests.py -- and left the other eight tests/meta/ files, plus tests/scripts/, tests/train/test_shapes_regression.py and tests/integration/test_overfit_micro.py, exactly where they were: every one of them a test whose subject is a scripts/ module, sitting in tests/ anyway because that is where tests conventionally go. The convention was wrong for this shape: src/lucid_yolo/ and tests/unit/ mirror each other by design, and scripts/ had grown its own testable surface without growing its own mirror. Fixed by moving all fifteen files into a new scripts/_tests/, each renamed test_<script_name>.py after the module it exercises -- test_license_audit.py becomes test_audit_licenses.py, test_golden_harness.py becomes test_check_goldens.py -- so the pairing reads directly off the two filenames rather than needing the docstring to say which script a test covers.

The other five tests/meta/ checkers still inline (docs presence, docs site, figure captions, license headers, version single source) convert the same way WP-129 converted the doctest audit: a scripts/lint/<name>.py script plus a .pre-commit-config.yaml hook, scripts/_tests/test_<name>.py left as the functional-core test. release_guard.py and check_goldens.py were already scripts with no hook -- both gain one, stages: [manual] like nothing else in the file, since a release tag and a golden recompute are not per-commit checks. release_guard.py needed one real change to be hook-shaped at all: --tag was required=True, unrunnable from a hook that fires with no arguments on every commit. It now defaults to git describe --tags --exact-match HEAD and no-ops when HEAD isn't exactly a tag -- true for nearly every commit, which is the point.

Every hook's name: field gained a distinct leading emoji chosen for the action it performs (๐Ÿง  mypy, โš–๏ธ license audit, ๐Ÿ“‹ docs presence, ๐Ÿ—บ๏ธ docs site, ๐Ÿ–ผ๏ธ figure captions, ๐Ÿ“„ license headers, ๐Ÿ”ข version single source, ๐Ÿ“ doctest audit, ๐Ÿท๏ธ commit trailers, ๐Ÿฅ‡ golden check, ๐Ÿ›ก๏ธ release guard) -- eleven hooks read a lot faster on pre-commit run --all-files's output when each line starts with a different glyph than when all eleven start with the same bullet.

The move itself broke two things that had never been exercised before, both structural rather than content bugs. mypy: [tool.mypy] scans files = ["src", "scripts"] with no mypy_path entry for the repo root, so a file inside scripts/ got two identities at once -- absolutize_readme from the direct directory walk, scripts.absolutize_readme from scripts/_tests/test_absolutize_readme.py's own from scripts.absolutize_readme import ..., now itself swept into the scripts scan for the first time. explicit_package_bases = true plus mypy_path = "src:." gives both paths the same resolution; scripts/_tests/ itself is excluded from strict checking afterward, matching tests/'s own long-standing exemption for exactly the same fixture-heavy, monkeypatch-typed, dynamically-imported character. pytest: scripts/_tests/conftest.py and tests/conftest.py are both non-package conftest.py files with the bare module name conftest; under the default prepend import mode, loading them from two different top-level collection roots (scripts and tests in the same pytest ... src scripts tests invocation) rather than nested under a shared one raised import file mismatch. --import-mode=importlib fixes it structurally, keying each module's identity on its resolved path rather than its stem, and is now the addopts default for every invocation, not just this one.

scripts/README.md is new: a table per subtree (core, lint/, _tests/) naming every script, what it does, what invokes it, and which test covers it -- the crossroad a developer new to scripts/ reads first, and the thing that makes "no dedicated test, doctests only" for golden_producers.py, plot_training.py, and dump_debug_grid.py a documented decision instead of a silent gap.


WP-130b โ€” an emoji requirement is not the same claim as "every local hook"

WP-130's emoji pass read "every hook" as "every repo: local hook" and stopped at eleven -- the other eleven, all third-party (pre-commit-hooks, mdformat, docformatter, ruff-pre-commit), kept their plain default or custom names. Fixed by giving each of those eleven a name: override where none existed (end-of-file-fixer, check-yaml x1, check-toml, check-added-large-files, ruff-check, ruff-format) or an emoji prefix on the existing custom one (check-yaml's unsafe-tags instance, mdformat x2, docformatter). Twenty-two hooks, twenty-two distinct emoji, chosen per action rather than per category so the same glyph never covers two conceptually different checks.

Separately: pre-commit run --all-files was only ever exercising nineteen of the twenty-two hooks, by design at the time -- commit-trailers runs at commit-msg stage, golden-check/release-guard at stages: [manual], none of which --all-files (a pre-commit-stage run) reaches. Asked to close that gap too. golden-check and release-guard are cheap enough off a release tag (one git describe call, one already-fast golden recompute) that stages: [pre-commit, manual] costs nothing meaningful on every commit -- the accepted trade is make gate now calls check_goldens.py twice, once inside precommit's --all-files and once via the golden target, rather than carrying a second exemption to remember. commit-trailers cannot take the same fix: a pre-commit-stage run has no commit message to validate, since no commit is in progress. Its --all-files-reachable half is a new sibling hook, commit-trailers-history, wired to the same script's pre-existing --range mode (--range origin/main..HEAD) rather than new code -- it re-validates every commit not yet pushed, which is a strictly more useful check than re-reading the newest message alone, and was already there waiting to be pointed at from a hook.


WP-120 โ€” a mirror does not know anatomy

Phase 12 opens on a container change rather than a model change: Targets gains keypoints ((N, K, 2) float32) and keypoint_vis ((N, K) int64), and HorizontalFlip -- the one existing transform this WP's scope covers -- gains the matching mirror. Every other geometric transform (RandomAffine, MosaicAssembly, Mixup/CopyPaste, Letterbox) is untouched; keypoint support there is a later WP's concern, not this one's.

K is a constructor argument the way the class count already is: keypoints/keypoint_vis follow polygons' existing "count is 0 or N" convention (the shared instance axis every box-aligned modality already uses) rather than rboxes' independent-axis-plus-separate-mask one, since a keypoint set belongs to one box the way a polygon ring does, not to a second geometry entirely. concat additionally rejects differing K across items that do carry keypoints -- a check _concat_polygons has no analogue for, since ring length is per-instance there and K is fixed per dataset here, so two items disagreeing on K is corruption, not a valid ragged shape.

The harder decision is what HorizontalFlip does with left/right identity. A mirror reflects every point's x-coordinate unconditionally -- that part is geometry, not anatomy, and needed no new assumption. But swapping which index is now "left knee" after the flip is anatomical, dataset-specific knowledge the transform has no business hardcoding into a K-generic container's own mirror: nothing in this repository's K-point design says K=17 or names a COCO ordering, and baking the pairing into HorizontalFlip would silently narrow "generic K-point task" back down to "COCO human pose" the first time this class ran. keypoint_flip_pairs: list[tuple[int, int]] | None = None is the resolution -- a caller-supplied swap map, None meaning "mirror coordinates, keep column identity", the correct default for a K-point task with no left/right symmetry. A64 records COCO's own 17-point pairing as the value the demo dataset will actually supply, but the row is marked open: WP-121's COCO datamodule is what passes the concrete list in, not this one.

Implementation delegated to codex:codex-rescue --write against a design spec fixing the exact field names, shapes, validation rules, and test scenarios up front, rather than leaving the K-point/anatomy split to be discovered mid-implementation -- reviewed diff-by-diff against that spec before landing; one docstring grammar slip ("A supplied, a dataset keypoint pair map...") was the only correction needed.


WP-121 โ€” a reader is not a wiring

CocoDetectionDataset gains keypoints: bool = False, matching oriented's existing shape exactly: opt-in, defaulting to "leaves the reader exactly as it was". When set, each retained annotation's flat COCO keypoints field ([x, y, v, ...], WP-120's Targets.keypoints/keypoint_vis on the receiving end) is split by a new _parse_keypoints helper into (K, 2) coordinates and (K,) visibility, stacked across an image's retained instances, and threaded into whichever of the axis-aligned or _oriented_targets construction paths _build_targets takes -- both grew optional keypoints/keypoint_vis parameters rather than staying axis-aligned-only, so a caller who sets both oriented=True and keypoints=True does not crash even though this repository's real pose data is never oriented. Crowd and RLE-skipped annotations are excluded from keypoint parsing exactly as they already are from boxes/labels/polygons -- the same retained-annotation loop populates all four, so the axes cannot drift apart. A mismatched K across two instances of one image raises naming the file and both observed counts, the same "cannot describe a rectangular tensor" failure _oriented_targets already raises for a non-quadrilateral ring.

WP-120's own log entry above states "WP-121's COCO datamodule is what passes the concrete [flip-pair] list in" -- that was wrong when written, not merely superseded: this row's DoD names only tests/data/test_coco.py, and the roadmap row's Scope never promised datamodule or HorizontalFlip wiring. Left standing in WP-120's own entry per this log's convention of not editing a historical paragraph to match a later correction, but A64 in docs/ASSUMPTIONS.md is corrected in this WP's own commit, since the assumption row itself (not a narrative entry) still needs to be accurate going forward: it now states that no roadmap row currently owns constructing HorizontalFlip(keypoint_flip_pairs=...) for a pose task, rather than pointing at a WP whose actual scope never included it.

Delegated to codex:codex-rescue --write against a fixed design spec, as WP-120 was. One environment-only test failure surfaced during codex's own verification pass (test_worker_loader_augments_differently_each_epoch, a pre-existing, unrelated DataLoader-worker test failing because codex's sandbox blocks torch_shm_manager's multiprocessing calls) -- root-caused correctly by codex itself (failure occurs during worker process startup, before any WP-121 code runs; the same 62-test file otherwise passed) and left for the lead's full-access environment rather than patched around. Confirmed independently: the same test file passes 63/63 outside the sandbox, and the full make gate (2282 passed, 27/27 goldens) is green.


WP-122 โ€” a stem is architecture, its sigma is not

DualDetectionHead gains a third opt-in stem set, num_keypoints, following num_coeffs'/predict_angle's exact shape: absent by default, so the accepted detection/segmentation/oriented module trees are untouched byte-for-byte. _build_keypoint_stem reuses _stem_width (A28's channels // 3) rather than a bespoke width the way _build_coeff_stem already does -- unlike the angle stem, no R1 Table S11 or any allowlisted source measures a keypoint task at all, R14 being registered "future pose milestone only", so there is nothing to fit a dedicated width against. The final 1x1 emits 4 * K raw channels in point-major order (x, y, sigma_x, sigma_y per point) and nothing follows it -- coordinates are unbounded offsets exactly like the angle branch, and per the scope boundary below, sigma is equally raw.

The scope line that mattered most here was what this WP explicitly does not decide: R14's RLE loss needs sigma > 0 for its flow's density, but the specific function that guarantees that (softplus, exp, a scaled sigmoid) is tied to the loss equations themselves, and AGENTS.md sec. 6 reserves loss/assignment/optimizer/rotated-geometry modules for hand-written implementation from the equations, not generated code. A65 records the split explicitly: DualDetectionHead and the new decode_keypoints (heads/keypoint.py, decode_ltrb's anchor-centre/stride convention generalized from a four-value ltrb offset to a direct two-value per-point offset) both leave sigma completely untouched, and WP-123 is where the mapping is decided, from R14 directly. The codex spec for this WP said so in as many words -- "if you find yourself reaching for such a transform, stop" -- and the delegated diff held the line: heads/keypoint.py's decode function does not even accept a sigma argument, and the test suite's test_sigma_output_remains_raw_unbounded_and_point_major drives the stem's output convolution to explicit large-magnitude values and asserts they survive unchanged, which would fail against any accidentally-added activation.

Delegated to codex:codex-rescue --write as WP-120/121 were, but this one surfaced a real regression its own sandboxed test run correctly caught and correctly declined to fix: _DetectionBranch.forward's return tuple grew from four elements to six (cls, box, coeff, angle to ..., keypoints, keypoint_sigma), and three call sites in models/build.py (Detector, OrientedDetector, Segmenter) unpack that tuple positionally rather than through DualHeadOutput's named fields -- cls, box, _, _ = self.o2o(...) and its two siblings. The spec had excluded build.py from scope entirely, which was the actual mistake: those three lines are a direct, unavoidable consequence of widening a tuple those call sites already depend on, not a new feature crossing a WP boundary. Codex's own gate ran tests/models/ as a regression check, found 22 failures and 6 errors all tracing to those three lines, correctly refused to touch the excluded file, and reported the gap rather than guessing. Fixed by hand in three one-line edits (widening each unpack to six positions, matching the new tuple width) -- mechanical enough, and small enough, not to warrant a second delegation round; make gate (2290 passed, 27/27 goldens, including all four params_flops_* goldens the widened tuple had been silently breaking) confirms nothing else depended on the old width.


WP-123 โ€” a loss written by hand, from equations fetched three times

Not delegated. AGENTS.md sec. 6 reserves loss/assignment/optimizer/rotated-geometry modules for hand-written implementation from the equations -- the highest AGPL-regurgitation-risk surface -- and R14's RLE loss is squarely a loss module, the first one this project's Phase 12 work has touched. Every other WP in this phase (120/121/122) went to codex:codex-rescue; this one did not, and no part of it was delegated except (implicitly) nothing -- the module, its RealNVP flow, and its test suite are all written directly against R14's own equations, fetched from arXiv's HTML rendering (the allowlisted primary-source endpoint) across three separate targeted WebFetch passes rather than one broad read, specifically to cross-corroborate the one detail secondary summarization is most likely to get subtly wrong: which direction the flow runs and what sits at its latent end. All three fetches agreed on Eq. 8 verbatim, R14 sec. 4's K=6/Lfc=3/Nn=64/Leaky-ReLU conditioner config, and Eq. 12's affine coupling formula; the third fetch additionally recovered R14 sec. 3.2's z_bar ~ N(0, I) latent statement and sec. 3.3's sigma_hat sigmoid sentence -- resolving A65, which WP-122 had deliberately left open rather than guessed at. No detection-repository code, including R14's own official implementation, was opened at any point (AGENTS.md sec. 7).

The loss (Eq. 8, log s dropped per R14's own stated implementation choice): L_rle = -log Q(x_bar) - log G_phi(x_bar) + log sigma_hat, where x_bar = (mu_g - mu_hat) / sigma_hat is the standardized residual, Q a fixed unit Laplace (no parameters, R14's "preset density"), and G_phi the density the RealNVP flow induces over x_bar via the standard change-of-variables identity. Q's gradient reaching mu_hat/sigma_hat independently of the flow is R14's own stated "gradient shortcut" motivation for the additive split (sec. 3.2) rather than an incidental property -- test_gradient_shortcut_reaches_mu_and_sigma_with_the_flow_frozen freezes every flow parameter and confirms the regression head still receives nonzero gradient, a direct test of that specific paper claim rather than a generic backprop smoke test.

The flow (Eq. 12, Appendix A): six stacked affine coupling layers over the 2-D residual, each holding one coordinate fixed and transforming the other by a scale-and-shift pair from a 3-layer/64-unit/Leaky-ReLU conditioner. The paper states the coupling formula but not that the fixed/transformed partition alternates between layers -- that is RealNVP's own standard construction (Dinh et al., which R14 cites rather than restates), and without it one of the two coordinates could never move at all under the whole stack, making it degenerate rather than a valid bijection over R^2. Implemented as synthesize (the generative z -> x direction) and invert (x -> z, RealNVP's defining closed-form property, needed for density evaluation) as two directions of the same module rather than one direction plus a solved inverse.

What a unit gate can and cannot prove here: the mechanism-claim validation this row's roadmap text (and R14 itself) cares about -- RLE beating an OKS-only ablation -- needs the [GPU][PRINCIPAL]-gated WP-125 tier run this session cannot execute. Every property a unit test can pin was therefore treated as load-bearing rather than incidental: test_flow_density_integrates_to_one numerically integrates the flow's induced density over a 161x161 grid and checks it lands within 0.05 of 1.0 -- the single most discriminating check of change-of-variables bookkeeping, since a base-density term without its accompanying log-determinant (or vice versa) passes every other test in the file but does not integrate to a proper probability density. test_log_determinant_matches_autograd_jacobian compares the analytic per-layer log-determinant against torch.autograd.functional.jacobian's own determinant, independently of invert entirely -- self-consistency between synthesize and invert alone could not catch a sign error that happened to cancel between the two, and this check cannot make that mistake since it never calls invert.

Design decisions this row records rather than silently makes (A65, A66): sigma's positivity mapping (deferred by WP-122/A65) resolves here as torch.sigmoid, applied in the loss rather than the head, per R14 sec. 3.3's own stated sentence -- not chosen freehand. Visibility masking (A66, new): COCO v=0 is excluded unconditionally (no ground truth exists to train against), v=1/v=2 both contribute equally, since COCO's own OKS protocol already scores against v>=1 regardless of occlusion and training against a narrower set than the metric evaluates against would be the inconsistent choice, not the safe one.

A structural fix along the way: every other loss in this repo (instance_mask_loss, ciou_loss, probabilistic_iou, ...) is a parameter-free pure function over already-produced network outputs. RLE's flow is the first loss component in this project with trainable weights of its own, and a first draft reached for a lazily-built module-level global singleton to hold it -- convenient for a bare function's signature, but a violation of this project's own "no global mutable state" rule, undiscoverable by an optimizer's .parameters(), and unable to move devices or checkpoint the way a registered submodule does. Corrected before landing: RLELoss is an nn.Module owning the flow as self.flow, the shape every other stateful component in this codebase (DualDetectionHead, ProtoNet) already takes.

WP-124 โ€” a premise checked against the installed package, not the roadmap text

Delegated to codex:codex-rescue, --write, scoped to src/lucid_yolo/eval/coco_eval.py and a new tests/eval/test_keypoint_eval.py only -- no decoder, model, CLI, roadmap, or assumption edits, no commit, no full make gate inside the sandbox. Before dispatch this row's own Scope text was verified against the installed environment rather than trusted: torchmetrics==1.9.0's MeanAveragePrecision.__init__ accepts iou_type values of only "bbox"/"segm" (checked via direct signature/source inspection, not memory), so the row's premise -- "keypoint mAP wired the way WP-069 wired box mAP" -- was factually wrong for what is actually installed. This did not rise to an AGENTS.md sec. 4 escalation: the row's actual intent, a COCO-OKS-faithful engine wired against WP-121's ground truth and WP-122's decode, is still satisfiable, just through a different already-a-dependency library (faster_coco_eval, R23) called directly instead of through torchmetrics's wrapper. The spec handed to codex stated the corrected mechanism up front and explicitly instructed it to re-verify the premise itself against the installed package before writing any code, rather than trust the prompt.

What codex built: keypoints_to_predictions adapts a fixed-size (B, N, K, 2) decode-shaped batch into COCO prediction dicts, filtering the score-zero padding rows a fixed decoder shape produces and remapping contiguous class labels back to original COCO category ids -- the same shape of adapter detections_to_predictions already provides for boxes, not a new pattern. evaluate_keypoints builds a minimal in-memory COCO ground-truth/result document pair from WP-121's keypoints/keypoint_vis tensor convention directly (v == 0 excluded from both OKS matching and the per-instance bounding-box area, v >= 1 included -- A66 applied here exactly as WP-123 defined it, not re-decided) and drives faster_coco_eval.COCOeval_faster(iouType="keypoints", kpt_oks_sigmas=...) through its native evaluate() / accumulate() / summarize() sequence, one-shot rather than DualPathEvaluator-streaming since no keypoint decode pipeline exists yet for a streaming caller to feed. COCO_KEYPOINT_OKS_SIGMAS names R12's 17-point sigma table as a module constant rather than leaving it to the library's own Params.setKpParams default -- the values are identical today, but a project-owned constant keeps the number this project reports against from silently moving if the dependency's own default ever changes.

Independent re-verification (lead, outside the sandbox): tests/eval/test_keypoint_eval.py -- 5 passed (padding-drop/category-remap adapter contract; a perfect-match OKS oracle scoring near-1.0 AP; a far-miss oracle scoring exactly 0.0 AP, ruling out a category-only match; the empty-input all-zero ten-key contract, avoiding a degenerate call into COCO.loadRes's empty-list case; corrupting only a v=0 target coordinate and asserting the ten stats are bit-for-bit unchanged, reusing WP-123's own TestVisibilityMasking pattern). coco_eval.py's module doctests -- 6 passed, 1 pre-existing skip. mypy on the file -- clean. Full tests/eval/ regression -- 216 passed (211 pre-existing + 5 new, matching codex's own reported delta exactly). Full make gate -- 2319 passed, 37 skipped, 3 deselected, 27/27 goldens, run twice to confirm no stray failure line in the fuller output.

No new assumption recorded. The spec instructed codex not to add an ASSUMPTIONS.md row itself and to flag anything it found instead; nothing surfaced that qualifies -- the sigma-table caution above is a documented fragility comment, not an open gap, since the value is pinned as a citable constant rather than inherited implicitly either way.

WP-121b โ€” a segmentation ring only an oriented reading actually needs

Surfaced during Phase 12's own dogfooding rather than invented: validating a fuse-augmentations Task.KEYPOINTS COCO export against WP-121's reader (an offline check of the user's own upstream package, ahead of wiring a fixture from it) found that every annotation in the export parsed to zero instances. Root cause traced two layers deep. The proximate one was upstream -- fuse-augmentations' CocoWriter never emitted a segmentation field for Task.KEYPOINTS, even though the outline polygon it would need was already computed for every shape regardless of task -- and was fixed there, by the user, not here (AGENTS.md sec. 6/7: no fix to a dependency's own source from this repo). The deeper one was this repo's own: _parse_annotation (WP-014's original reader) required a parseable segmentation ring to retain any annotation at all, in every mode -- plain, oriented, and WP-121's own keypoints reading alike -- so the upstream bug's fix alone was not sufficient; a keypoints-only or plain-detect-only export genuinely has no ring to give, by design, and the reader treated that as identical to a crowd or RLE annotation it should legitimately drop.

The fix, scoped precisely. Two kinds of "no ring" are not the same claim. A segmentation value that is present but unusable -- an RLE crowd dict, an empty list, a ring under three points -- is never a real countable instance, in any mode; that half of WP-014's original policy is untouched, and it is exactly what TestKeypointParsing's own existing fixture already exercises (one RLE annotation, no iscrowd flag set, expected to still be dropped) -- a first draft that relaxed on "ring is None" alone broke that test immediately (KeyError reading ann["keypoints"] off an annotation the keypoints branch was never meant to see), which is what forced the narrower raw_segmentation is not None or self._oriented condition actually landed. A segmentation key that is absent entirely is the other case, and it is fatal only for oriented=True, which has no other source for the rotated box (_oriented_targets derives both boxes and rboxes from the same quadrilateral ring); a plain or keypoints reading now keeps the box instead. _build_targets collapses the whole image's polygons to [] the moment any one retained instance lacks a ring, rather than leaving a ragged per-instance mix -- Targets.polygons's own documented contract is "0 (no masks) or N," never in between.

Why this does not reopen the silent-empty-mask hole. instance_mask_targets already raises ValueError when len(target.polygons) != len(target.boxes) -- a guard written for exactly this shape of mismatch, already in place before this fix, for a different reason (WP-099b's YOLO-layout path, which never carries rings at all). Relaxing the parse-time gate does not need a new guard; it needs this existing one to still be reachable, and it is: requesting mask_targets=True against a ring-less image raises there precisely as it does for a genuinely maskless dataset.

Verification. tests/data/test_coco.py::TestRinglessAnnotations -- a plain reading keeps both ringless boxes with polygons == []; an oriented reading against the same file still returns zero instances, locking in that oriented mode's requirement did not loosen; instance_mask_targets against the same ringless targets still raises, naming "detection-only annotation cannot supervise masks." Every existing CocoDetectionDataset caller re-run as a regression sweep -- test_coco.py, test_coco_eval.py, test_annotations.py, test_build_dota_tiles.py, test_overfit_batch.py, test_datamodule.py -- 175 passed. coco.py's own module doctests and mypy --strict both clean. Full make gate green (2322 passed, 37 skipped, 27/27 goldens), run against the project's actually-pinned fuse-augmentations wheel -- deliberately not the newer local checkout the upstream fix and the keypoints-fixture wiring both need, since that checkout's widened Shape vocabulary moves class_names(ClassMode.SHAPE)'s span for every task (det/obb/seg included) and would silently drag the frozen *_num_categories goldens along with it. That vocabulary-scoping gap is a separate, still-open finding, reported back rather than worked around: class_names() has no parameter to restrict its span to a shape family, so det/obb/seg cannot currently pin "geometric shapes only" against a fuse-augmentations version new enough to carry keypoints at all. The keypoints-fixture wiring this fix unblocks (tests/fixtures/synthetic.py::generate_keypoints_fixtures, a small fixed animal subset for an overfit-friendly category count) is validated end-to-end against a local dev-only override but held uncommitted pending that upstream resolution and the resulting pin bump.

WP-131 โ€” a dependency fixed upstream, checked rather than trusted

The upstream half of WP-121b's "still-open finding" landed same-day: class_names(class_mode, shapes=...) (opt-in, defaulting to the old full-vocabulary span when omitted, so every existing caller is unaffected), a mirrored SyntheticConfig.colors field, and the Task.KEYPOINTS segmentation-emission fix WP-121b already depended on. Verified rather than assumed fixed: the first check found the new parameter existed and its own tests passed, but generate_dataset -- the convenience function this project's fixtures actually call, not the primitive directly -- still called the bare unscoped class_names(config.class_mode) at its one writer-construction call site, so generate_dataset(..., shapes=DEFAULT_SHAPES, ...) kept emitting all 16 categories regardless. Reported back with the exact line and the one-line fix it needed; landed within the hour as an amended commit (74af985), re-verified empirically both ways -- shapes=DEFAULT_SHAPES now yields exactly ['circle', 'rectangle', 'square', 'triangle'], shapes=animal_shapes(2) yields exactly ['duck', 'elephant'] -- before trusting it for anything downstream.

Pinned by commit, not by version (R21). fuse-augmentations' version string had not moved (0.10.0.dev0) across every commit in this saga, and PyPI forbids re-uploading that string, so a bare uv lock re-resolve would have kept silently re-pinning the stale 2026-08-02 wheel. pyproject.toml's dependency became a git+commit-SHA pin instead (git+https://...@74af985...) -- matching R21's own provenance row, which already cited a fixed commit rather than a floating version, closer to A26's original intent than the PyPI-registry pin the project had drifted to. uv lock's re-resolution incidentally also picked up onnx/onnxruntime into the lock for the first time; both had been declared in the dev dependency group but were absent from uv.lock entirely, a pre-existing drift unrelated to this row that a plain uv sync --frozen had exposed by removing the two packages someone had evidently pip installed by hand outside the lock.

Goldens verified unchanged, not re-frozen. generate_detseg_fixtures/generate_obb_fixtures gained an explicit shapes=DEFAULT_SHAPES argument -- defensive, not corrective, since that was already the implicit default -- so the fix restores exactly the 4-category vocabulary the frozen goldens were built against. Full make gate after the pin bump: 2325 passed (2322 plus the two new keypoints-fixture tests plus one doctest), 27/27 goldens passed with no --freeze needed, confirming the restored parity rather than assuming it.

generate_keypoints_fixtures. 12 images, animal_shapes(2) -- duck and elephant, not fuse-augmentations' full 12-animal vocabulary -- specifically to keep an eventual overfit-style milestone run's category count low; more animal classes means a wider vocabulary a memorization-style run has to fit before its own metric means anything. Mirrors generate_detseg_fixtures/generate_obb_fixtures's existing shape exactly: idempotent, seeded (KEYPOINTS_SEED), a matching keypoints_fixture_dir session fixture in conftest.py. test_keypoints_set_loads pins every annotation's landmark table at fuse-augmentations' fixed 16-point animal schema and the category count at or under KEYPOINTS_ANIMAL_COUNT; test_keypoints_generation_is_deterministic mirrors the existing det/obb determinism tests byte-for-byte.

WP-132 โ€” every part built, nothing connected

Phase 12 shipped five consecutive rows of keypoint work and never once ran a keypoint. WP-120 added the target channels, WP-121 the COCO reader, WP-122 the head stem, WP-123 the RLE loss, WP-124 the OKS scorer -- each landed green, each with its own passing unit suite, and the composition none of them owned did not exist. DetectionLitModule's task enumeration still read detect/segment/obb, so the keypoint stem was never constructed by anything; RLELoss was exported from losses/__init__.py and had zero call sites outside the module that defined it; keypoint appeared nowhere in ptl/module.py, ptl/datamodule.py, data/collate.py or models/build.py. The suite was green because every test was a component test, and a component test cannot fail for a wire that was never run.

The trigger for finding it was a question about running the overfit gate, not a failing test -- which is the part worth recording. Nothing would have surfaced this until someone tried to train, because "wire absent" and "wire correct" are indistinguishable to a test suite that only ever exercises the parts. WP-087 and WP-088 exist for exactly this reason and are the precedent this row follows: each is a training path row that carries its own overfit-100 golden as DoD, rather than a component row trusting that a later integration will notice. The gate is the thing that makes a wire testable at all.

Additive, not substitutive. The RLE term enters the detection total at keypoint_gain (A68) alongside the box and class terms, which makes this WP-087's structural shape rather than WP-088's -- the oriented path is the one task that replaces terms, zeroing box_gain/l1_gain inside the dual loss and respending them on rotated equivalents. Keypoints add a channel of supervision without contesting any existing one, so the arrangement is the simpler one and the keypoint_gain=0 bit-exactness test is what proves it: with the gain at zero the detection total is bit-identical to a detect run, so the term is genuinely additive and the gain genuinely consulted rather than folded in somewhere upstream.

The whole transform layer, after a scoping that was wrong twice. Every geometric transform rebuilds Targets(...) from scratch, and all of them dropped the keypoint fields on the floor -- letterbox.py, mosaic.py, affine.py, mixup.py. The first scoping fixed Letterbox alone and argued the boundary was principled: _warp_targets is a pure forward affine that resizes and pads, never crops, so no point can leave the canvas and there is no policy to decide, where mosaic/affine/mixup crop and would force a clamp-vs-demote decision an augmentation-off gate could not settle honestly. Every sentence of that is true and the conclusion was still wrong, because the premise it rested on was never checked: the claim "augmentation is off" was verified against the validation loader, which is letterbox-only, and generalized to the training loader, which is not. _TrainPipeline runs mosaic, FusedAffineLetterbox, mixup, copy-paste, HSV and flip unconditionally. The gate died on pad_keypoints' instance-count mismatch on its first batch, and the fix was the whole layer -- which is what made A70 this row's decision instead of WP-125's.

The loud bug was hiding a silent one. Re-reading the transform path after that failure -- rather than only the line that raised -- turned up HorizontalFlip constructed at datamodule.py:767 with no keypoint_flip_pairs argument at all. The flip is K-generic by design and cannot know which index mirrors which, so the omission did not mean "no swap needed", it meant every mirrored sample supervised flank_left toward the location of flank_right. That trains, converges to a left/right-confused head, and reports nothing: no shape check fails, no loss value looks wrong, and the overfit gate would very likely still have passed. Worse, the first draft of A64 asserted the pairing was being threaded through -- a documentation claim written from what the code should have done rather than from what it did, and corrected only because the affine's hard error forced a second reading of the same path. A64 now resolves by reading the pairing off the dataset's own COCO category names (left_eye, flank_left), which is where the anatomy is actually stated.

Symbols over animals, for reasons that outrank "simpler". R21's SymbolShape family carries K=7 against the animals' K=16, and its landmarks are placed analytically -- three-quarters of the way from each outline's area centroid toward a named vertex -- rather than traced anatomically. Two consequences beyond the cheaper stem. Only 5 of 7 slots are populated on most symbols, so a symbol run carries v=0 points natively and exercises A66's visibility exclusion inside a real training loop instead of only in test_rle_loss.py. And because the points are analytic, R12's OKS sigma table -- 17 values derived from measured annotator standard deviations on human anatomy -- describes a quantity that does not exist here, which is what A67 records: a uniform sigma is not a simplification of the real table but the only honest reading, since any per-point vector this project invented would fabricate a structure the data does not have.

The pin bump was not fixture-neutral, and the frozen tree could not absorb it. Reaching SymbolShape meant advancing R21 from 74af985 to 0a0cc64, a commit that also redefines GeomShape.TRIANGLE from equilateral to obtuse-scalene and re-centres every outline on its area centroid. Both moved the existing fixtures substantially -- obb_num_annotations 44 to 61 (+38.6%), data_checksums.bbox_area_sum -45.2%, image_std_sum -35.5% -- and eight of 27 goldens went red. Six of those eight were in goldens/frozen/0.2|0.3|0.4, the per-release snapshots whose whole purpose (check_goldens.py: "a release's frozen goldens staying green is the frozen-golden regression") is that current code still satisfies every value a past release pinned. Re-freezing those would have rewritten what v0.2.0 actually produced, so the question went to the principal rather than being resolved by the row that raised it. What decided it was that the failures partitioned exactly: every pure-code frozen golden (assignment_cases, optim_toy, params_flops_*) still passed, and the only two that failed were data_checksums and fixture_checksums -- the two whose producers render images through an external package. A generator-derived metric can never satisfy "current code must still satisfy every past value" once the generator changes, so those two were never really freezable; they were a category error in what got snapshotted, not a regression. They were removed from the frozen release tree (recoverable in history) and the two current copies re-frozen, leaving 21/21 green and the frozen-golden regression still meaning something for everything it can actually test.

One flow or two, and a gate that cannot tell. Wiring the RLE term forced a choice neither R14 nor R1 speaks to: the dual head has two branches, and a normalizing flow is the only trainable-weight loss in this project, so the term either shares one density across both branches or fits one per branch. One shared flow was taken (A69) on two arguments that are each weak alone -- it is what R14 actually describes, one density over one regressor's residuals, where two would assert the branches' errors differ in kind rather than merely in schedule; and it is the smaller parameter count. What matters more than the choice is that this row's own gate cannot check it: an overfit-100 run drives both branches to the same near-zero residual, which is precisely the regime where one flow and two are least distinguishable. So A69 ships open with WP-125 named as the first run whose branches disagree enough to tell, rather than being quietly ratified by a green gate that was never sensitive to it. The same caution applies to A68's keypoint_gain = 1.0: a memorization run will pass at almost any gain, so passing is not evidence the weight is right.

Two numerical faults that no component test could have caught. With the wiring done and the transforms carrying points, the gate still would not run -- it went non-finite inside the RLE flow, and the two causes are the clearest evidence this row could offer for why a training path is its own work package.

The first is a frame mismatch (A71). decode_keypoints returns absolute input pixels, deliberately and correctly -- it is the deployed decode. R14 forms the residual as (mu_g - mu_hat) / sigma_hat and bounds sigma_hat into (0, 1) with a sigmoid, which it can do because it regresses inside a top-down person crop: the crop is the normalization, so the paper never names a frame and this project had no reason to notice one was missing. Composed, the two make a residual whose smallest expressible value for a 40 px error is 40. Measured on the first batch, the keypoint term entered at 1300 of a 1342 total loss -- three orders above the box and class terms it is supposed to sit beside -- and the flow overflowed on the second step. Normalizing both point tensors into the assigned box's frame first brings the median residual to 1.28. Nothing about this is visible to test_rle_loss.py, which evaluates the loss at O(1) residuals because that is the regime the equations describe; the loss was never wrong, its input frame was unstated.

The second is a missing stabilization (A72). WP-123 built the coupling conditioner from R14's stated recipe -- 3 fully-connected layers, 64 units, Leaky-ReLU after each -- and emitted its raw linear output as the log-scale. R14 cites RealNVP for the coupling construction rather than restating it, and the parameterization of s is stated only in Dinh et al.: "To compute the scaling functions s, we use a hyperbolic tangent function multiplied by a learned scale", given there explicitly as a stability measure. A raw linear log-scale is not a simplification of that, it is a different layer, and the stack multiplies exp(log_scale) six times so the difference compounds. Measured after the frame was fixed: a residual of 27 arrived at the latent as 4e11 and at the loss as 7e22. The general lesson is narrower than "read the citations": WP-123 read R14 closely and implemented what R14 states, and what bit it was a detail R14 delegates -- the one place a paper's own citation is load-bearing rather than contextual.

The gate's number is small and that is the ruler, not the model. With both faults fixed the gate runs its 100 epochs clean and scores OKS AP 0.3357 over 548 instances -- next to the detection gate's 0.995, the oriented gate's 0.939 and the mask gate's 0.815, a number that reads like a failure. It was checked before it was believed, and the check says otherwise. Feeding the ground truth back through the scoring path as the prediction returns exactly 1.0, so the harness, the sigma vector and the label space are all wired correctly and the ceiling is real. Displacing every point of every instance by a uniform offset then measures the slope: +1 px scores 0.934, +3 px scores 0.269, +8 px scores 0.000. On symbols a few dozen pixels across, scored at A67's uniform sigma, OKS AP is a cliff rather than a slope, and 0.3357 is where a landmark error of roughly 3 px lands. The floor was set from that measurement -- 0.30, with the same 0.05 tolerance the oriented gate uses -- rather than from the 0.75 that had been guessed before any number existed. One honest limit on the reading: the probe displaces every point uniformly, so it bounds the interpretation without proving it; the same AP is also consistent with a mixture of exact and badly-missed instances, which only a per-instance breakdown would separate.

Both faults were found by running the gate and neither by any test in the suite, which is the same shape as the wiring gap this row opened with. What is deliberately not claimed here is that the numerics are now healthy: the residual tail still spikes into the thousands on this data, the A70 off-canvas points meeting a flow whose base density is quadratic in the residual. The tanh removes the compounding feedback path; it does not make the tail bounded, and A72 says so rather than letting a green gate imply otherwise.

WP-133 โ€” an Unreleased section, and the rule it follows

CHANGELOG.md had no ## [Unreleased] section at all. 27 commits had landed since the 0.4.0 release commit (0a3ef8b) -- the dev-version bump to 0.5.0.dev0 and WP-132's own keypoint training path among them -- and every one of them carried zero changelog entries, so the file read as though nothing had shipped since 0.4.0. Surfaced by a direct question, not a check: nothing in make gate or the pre-commit hooks reads CHANGELOG.md's content (release_guard.py checks a tag has a matching entry, not that intervening commits do), so the gap had no way to fail loud.

Backfilled from git log 0a3ef8b..HEAD, one bullet per user-visible change, grouped Added/Changed/Fixed to match the existing release sections' own convention. Internal-only commits -- test reorganization, roadmap bookkeeping, CI hook wiring, docstring formatting -- are omitted by the same judgment the existing sections already apply; none of the four shipped 0.x sections carries an entry for its own test suite. The keypoint task's five component WPs (120-124) plus its training-path composition (132) and the ringless-annotation fix (121b) collapse into one Added bullet rather than six, since a changelog reader wants "the keypoint task landed and what it cost to get right," not a WP-by-WP replay -- that granularity already exists in this table and in RESEARCH_LOG.md.

The section states its own closing rule inline, per the standing instruction this row also establishes: a commit that sets __version__ to a plain, non-dev, non-rc value closes the ## [Unreleased] section and opens the next one. Going forward, a WP's own commit should add its own ## [Unreleased] bullet rather than the whole set being reconstructed after the fact the way this row had to -- the reconstruction is legible from git history exactly once; a habit of writing it live is not.

Two pre-existing date mismatches surfaced and are recorded rather than silently corrected. [0.2.0] - 2026-08-10 against release commit eb81caa's 2026-08-11; [0.4.0] - 2026-08-15 against 0a3ef8b's 2026-08-16. Both one day earlier than the commit, both the same direction -- consistent with the date recorded being the day a run was accepted rather than the day the release commit landed, not a typo in either single case. [0.3.0] - 2026-08-14 matches its commit exactly, so the convention is not applied consistently even under that reading. Left as a known discrepancy: correcting two dates nobody had complained about is a scope this row did not need, and the pattern is now written down for whoever next touches a release date to decide.

configs/pose_smoke.yaml folded into this same commit, at the user's direction, rather than opening a separate WP. WP-125 needed a launch config before a Colab session already underway could proceed; writing one is mechanical against the det_smoke.yaml/seg_smoke.yaml precedent (task: keypoints, num_keypoints: 17 for COCO person_keypoints, keypoint_gain: 1.0 per A68's un-tuned default, explicit train_ann_file/val_ann_file since lucid_yolo.data.layout only ever resolves the instances_ spelling and person_keypoints_{split}2017.json sits beside it in the same annotations archive under a different name). Verified by dry-parse only -- tests/ptl/test_cli.py::test_config_dry_parses[pose_smoke.yaml], the same DoD every other shipped config carries -- not by a training run. This is explicitly not WP-125's acceptance: that row is [GPU][PRINCIPAL] and its criterion is RLE's mechanism claim against an OKS-only ablation, which a config file cannot settle and this entry does not claim to.

WP-134 โ€” a checkpoint reported at one eightieth of its own score

WP-124 built the OKS scorer. WP-132 built the training path. Nothing ever joined them: evaluate_keypoints and keypoints_to_predictions had no caller anywhere outside their own module and its tests, and cli/eval.py branched on three tasks. WP-125's acceptance metric -- OKS keypoint mAP on COCO human pose -- was therefore not producible by any shipped command at all, which is the same shape of gap WP-132 opened with and the reason that row's own log ends where it does.

The 0.006288 was not a decode fault, and finding that out first is what kept this row small. A keypoints checkpoint run through lucid-eval fell through to detect_eval and reported map=0.006288 against a training loop that had logged val/mAP=0.48 at the same epoch. That reads as a broken decode, a broken un-letterbox, or a broken assignment, and any of the three would have made this a much larger row. It is none of them. detect_eval.run hardcodes annotations/instances_val2017.json regardless of what the checkpoint's task or class count is, and COCO's mAP averages average precision over every category the ground truth contains. A person-only checkpoint predicts contiguous class 0 and nothing else, so the other 79 categories each hold ground truth, receive no prediction, and score AP=0. The arithmetic closes: 0.006288 * 80 = 0.503, against the 0.48 the epoch metric logged. Confirmed independently on a synthetic three-category case through torchmetrics directly -- perfect predictions in one category of three give map=0.333 and map_per_class=[1.0, 0.0, 0.0] -- before any code here changed.

So the fix is the ground-truth file, not the mathematics. pose_eval.run reads person_keypoints_val2017.json, which names one category, and the dilution disappears as a side effect of scoring the protocol the checkpoint was trained for. detect_eval's behaviour is untouched: for a detect or segment checkpoint the GT category count matches the model's own, and its average is over exactly the right set.

Which row of the report compares. Training's val/mAP is logged from the o2o branch alone -- _val_decoder.decode_with_indices(head_out.o2o_cls, head_out.o2o_box, ...) against the datamodule's already single-class targets. The comparable figure in a new report is the e2e entry, and the nms entry is the dense branch's own answer, expected to sit above it by R1 sec. 4.4's deficit. The runner prints that in words, because a report that does not say which figure it quotes is how the oriented tier confused two of them for a whole release.

The keypoint mode enters DualPathEvaluator exactly where masks did, and differs where the pieces force it to. Structural detection off o2o_keypoints/o2m_keypoints rather than a flag, so a detect, segment or obb checkpoint provably takes the path it already took -- those fields are None on its head output. Per-branch and per-path, so the E2E path reads the one-to-one branch's points at the E2E decoder's own anchor indices and the dense path reads its own. Two differences from the mask case, both dictated rather than chosen: decode_keypoints composes the raw offsets with the whole (A, 2) anchor grid and has no per-detection indexing, so this path decodes the dense batch and then gathers, where the mask path gathers coefficients and then decodes; and COCO's keypoint protocol is one matching over one document with no incremental update, so the points are accumulated for the split and scored once where the boxes stream. The second is affordable only because the sizes are opposite -- a split of masks is hundreds of gigabytes and a split of points is a few megabytes.

The PAD_ANCHOR_INDEX sentinel is -1, which Tensor.gather accepts and answers with the last anchor's row. It is clamped to a valid position and the result zeroed, the same two steps _gather_coefficients takes, and those rows carry score 0 and are dropped regardless -- belt and braces on a failure whose symptom would be a plausible pose at a correct box.

Points invert the letterbox through Letterbox.inverse_map, which is already point-shaped: to_letterboxed_original is that same call plus the score and class columns of a detection tuple. Reaching for the box function would carry two columns onto coordinates, and writing a second affine would be a second definition of what a letterbox is, which is the rule WP-053a set.

The ground truth was the part that was quietly wrong, and it was wrong before this row. evaluate_keypoints reconstructed each instance's bbox and area from the extent of its visible keypoints and forced iscrowd=0 and num_keypoints to a recount. Every one of those four is a supplied field in COCO's protocol and each does real work: area is what OKS divides by and what draws the medium/large bucket boundary, and for a person it is the segmented area rather than the hull of the labeled joints; iscrowd and a declared num_keypoints == 0 are the two flags the backend marks an instance ignored on, verified by reading COCOeval_faster._prepare in the installed faster_coco_eval 1.7.2 rather than recalling pycocotools' behaviour. Reconstructing them scores a different protocol under the same name. They are now read from the annotation when the target carries them, annotations_to_target(with_keypoints=True) carries them, and the reconstruction stays as the fallback -- so WP-124's one-shot callers, its doctests and every synthetic fixture keep the exact numbers they had.

The regression test makes the two readings disagree on purpose: an instance whose visible points span 2500 pxยฒ and whose annotation declares 20000 belongs to the medium bucket under one reading and the large bucket under the other, and exactly one of the two can be populated. Reconstruction reports AP_medium=1.0, AP_large=-1.0; the annotation reports the reverse.

A K that is not 17 is refused rather than scored. This protocol's ground truth, its point ordering and its sigma table are all COCO's person schema. A checkpoint predicting some other K has no correspondence to it -- point i of the prediction is not point i of the annotation -- and R12's sigmas measure annotator variance on joints that are not the ones being predicted. That is A67's argument read from the other side: A67 already refuses to fabricate a per-point table for analytically-placed synthetic landmarks and scores them on its own uniform sigma in its own gate, so this row needs no new assumption and invents no table.

Validation is synthetic, and says so. There is no COCO checkout and no GPU on this machine, so nothing here ran against real data. Each claim is separated by construction instead: the two branches carry offsets of opposite sign and each anchor a different magnitude, so of the four combinations of branch and index the evaluator could take, only the right one scores OKS 1.0. Both mutations were checked to fail -- crossing the branches and dropping the padding clamp each turn the suite red. What that does not establish is the figure a real pose checkpoint will report; the first honest reading of this instrument is WP-125's, on real data, and the arithmetic above is a prediction of what it should show rather than a measurement of it.

One promotion rode along: _parse_keypoints in data/coco.py becomes parse_coco_keypoints, because the evaluation reader now parses the same field off the same file format and a second copy would be free to disagree about the triplet stride or the visibility dtype -- after which a training run and its own acceptance score would read different ground truth from one file.

WP-135 โ€” a control arm, because one number states no direction

WP-125's acceptance is not a pose figure. It is RLE's mechanism claim: that the learned residual density is what buys the improvement, rather than the reparameterization, the sigmoid-bounded per-point scale, or the log sigma_hat Jacobian that come with it. A claim of that shape cannot be settled by a run, however good the number, because one figure is equally consistent with the flow doing all the work, none of it, and two terms cancelling. It needs a paired run whose only difference is the mechanism under test -- and the tier had no second arm to pair against. This row is that arm, and nothing else: it builds the instrument and does not read it.

The comparison is R14's own, not this project's invention. R14 Table 7 ablates exactly this: "Laplace, learnable variance" -- the NLL over a learned per-point scale with no normalizing flow -- scores 67.4 AP against full RLE's 70.5 AP on COCO. Verified this session against the paper itself (arXiv:2107.11291, read through the ar5iv HTML rendering), not recalled. So the direction of effect is already published, and what a paired run here measures is whether it reproduces at this scale on this recipe -- a much smaller question than the one it would be if the baseline were invented. That is also why no assumption id accompanies this row: an assumption records an open project decision made without a source, and this one has a source with numbers in it.

The loss is RLELoss's formula with one term deleted. R14 Eq. 8 (with the log s normalization R14's own implementation drops) is -log Q(x_bar) - log G_phi(x_bar) + log sigma_hat over the standardized residual x_bar = (mu_g - mu_hat) / sigma_hat. LaplaceNLLLoss is that expression without G_phi. Everything else is held: the residual is formed the same way, sigma_hat comes through the same R14 sec. 3.3 sigmoid (A65), the log sigma_hat term is the same reparameterization log-Jacobian, the sum is over the same two axes, A66's visibility mask is the same mask, and the zero-positives reduction is the same one. Holding all of it fixed is the entire point -- a control that differed in a second place would make the result unattributable, and every one of those clauses is a place it could have differed silently.

The identity is asserted rather than argued: test_is_the_rle_loss_with_exactly_the_flow_term_removed scores both losses on shared inputs and checks the difference equals the flow's own log-density term to 1e-5. A drifted residual, a different sigma activation or a different reduction in either module turns that test red, and nothing else in either suite would have reported it.

"Learnable variance" names what is kept. sigma_hat is still a head output and still trained -- the log sigma_hat term is what gives the likelihood a reason to prefer a small scale where the model is accurate, and test_gradient_reaches_both_the_location_and_the_learned_scale pins that both mu_hat and sigma_raw receive gradient. What the ablation removes is the learned shape of the error distribution. The further-degenerate baseline with a fixed scale too is not this row, is not built here, and is just L1; R14 lists it separately.

Zero parameters is the structural difference, and it is load-bearing twice. RLELoss is the one loss in this repository carrying weights -- 42 flow tensors on the tiny test module -- which is why it is held as a submodule at all. LaplaceNLLLoss has none: .parameters() is empty, state_dict() is empty, and construction draws no RNG. The second consequence is the useful one. The module docstring's construction-order rule exists because the flow's Linear layers consume RNG and would perturb every parameter drawn after them; an ablation module draws nothing, so its weights are bit-for-bit a same-seed detection module's, and test_the_ablation_adds_no_parameters_to_the_model pins its state dict as a detection module's plus the point stems and no third thing.

The attribute keeps the name rle_loss, deliberately. It is an nn.Module attribute, so it prefixes the loss's state-dict keys, and renaming it to something task-neutral would move rle_loss.flow.* to a new prefix and stop every keypoints checkpoint produced before this row from loading -- including the one behind the frozen goldens/gpu/overfit_micro_kp.json. That is the same key-stability rule WP-087 established and this module keeps its stages flat for, arriving from a direction it had not been tested from. The name is now slightly wrong under one of two arms; a broken checkpoint is worse than a slightly wrong name, and the attribute says so in a comment rather than being quietly left to look like an oversight.

Selection is additive, and the default is what makes it so. DetectionLitModule(keypoint_loss=...) defaults to "rle", so every shipped config, every checkpoint on disk and every accepted figure describes exactly what it described before. The two names are validated against one mapping that also builds them, mirroring how task and rotated_iou_form are validated in the same constructor -- one dict rather than a name tuple beside a constructor branch, so the accepted set and the thing each name builds cannot drift apart. An unrecognized value raises at construction, which matters more here than the pattern usually does: a silent fallback would produce an "ablation" run that trained the very objective it was meant to control for, and a comparison whose two arms are the same arm looks exactly like a null result.

pose_smoke_ablation.yaml is pose_smoke.yaml with one line added. Seed, variant, epochs, callbacks, every gain and the data placeholders are identical, keypoint_gain included -- it stays at A68's un-tuned 1.0 not because that is right but because moving it would make the arms differ twice. Verified by dry-parse only (tests/ptl/test_cli.py::test_config_dry_parses[pose_smoke_ablation.yaml]), the same DoD every shipped config carries.

One claim this row makes about numerics, and its limit. The ablation degrades more gently than RLE outside the O(1) residual range A71 normalizes into: the unit Laplace's log-density is linear in |x_bar|, where the flow's latent is exponential in a compounding log-scale and its base density quadratic in that latent -- A72's non-finite run. test_a_far_out_residual_stays_finite_rather_than_overflowing measures it at a residual two orders out rather than leaving it as an argument. What that does not say is that the ablation is safe unnormalized: a loss dominated by a handful of far-out points is still training the wrong thing, and both arms take the same A71 box frame for the same reason.

Nothing here was run against real data. There is no COCO checkout and no GPU on this machine, so this row ships a tool and a set of unit gates, not a result. The 67.4-vs-70.5 figures are R14's measurement quoted, not this project's; the arithmetic of the comparison is a prediction of what a paired run should show, and the first honest reading of it is WP-125's, which is [GPU][PRINCIPAL]-tagged and belongs to the user's machine.


WP-136 โ€” a filename is not a scratch space for whatever axis is on your mind that day

src/lucid_yolo/configs/ had grown eight files under what read as one two-token scheme (<task>_<tier>.yaml), but a closer look found three separate inconsistencies rather than one: two files used a third token to mean something no sibling file's name carried at all, one word (ablation) meant two structurally different things across two files, and the scheme itself never named the one axis every config actually sets differently -- variant. User direction (mid-session, superseding this row's own first draft) was to stop patching the two collisions and instead put every config on one explicit scheme: <task>_<variant>_<tier>[_<detail>].yaml, variant spelled out in full rather than left as the bare letter the CLI and each file's own variant: field use.

The full rename, driven by what each file's own variant: line already said (checked, not assumed):

old new why
det_smoke.yaml det_nano_smoke.yaml variant: n, task truncated as it already was
seg_smoke.yaml seg_nano_smoke.yaml variant: n
obb_smoke.yaml obb_nano_smoke.yaml variant: n
pose_smoke.yaml pose_nano_smoke.yaml variant: n; pose kept as task: keypoints's established domain term (roadmap's own "Pose-smoke tier" language, not a choice this row introduced)
det_ablations.yaml det_small_ablations.yaml variant: s -- the one config in the directory that is not n, previously invisible in its own filename
overfit_100.yaml det_nano_overfit_100.yaml had no task prefix at all, relying on task's implicit "detect" default; variant: n; 100 kept as WP-040's own detail (image count, not a tier word)
det_yolo_smoke.yaml dropped see below
pose_smoke_ablation.yaml pose_nano_smoke_laplace_nll.yaml ablation collided with det_ablations.yaml's different sense (a whole blueprint tier, sec. 10 tier B, ~120-epoch paired runs, its own sign-matching acceptance against R1 Tables 2/3/5/6) where this file is a same-budget arm of the smoke tier; laplace_nll names the mechanism swapped in, matching how the loss class and the DetectionLitModule docstring already refer to the arm

det_yolo_smoke.yaml was dropped, not renamed. It was first renamed det_smoke_layout.yaml (naming the --data.layout axis, not the one value it took), then reconsidered a second time against det_nano_smoke.yaml sitting beside it in a listing -- "layout" alone read as no more specific than the word it replaced, so the candidate became det_nano_smoke_yolo.yaml, spelling the actual value. Diffing it against det_nano_smoke.yaml at that point (checked, not assumed) found the two differ in exactly three things: the data: block (a YOLO root and its layout: yolo), model.num_classes (3 for the demo root vs COCO's 80 -- a property of the demoed root, not an independent axis), and header prose. Everything else -- schedule, gains, optimizer, variant: n -- is identical. A whole config file whose only content beyond a data override and its dependent class count is header prose is not a ninth point in the scheme; it is three CLI flags on det_nano_smoke.yaml that never needed a file of their own. The prose itself was not lost: docs/DATASETS.md's "Dataset formats" section already carries the deeper mechanics (layout vs format, YOLO_CANDIDATES, data.yaml priority, why segment is unavailable), so the file's comments were largely restating what that section already says more completely. docs/TRAINING.md gains a "A YOLO-format root instead of COCO's" section instead: the three-flag override, why --data.layout yolo is stated rather than probed (A63's undecidable-tie case), the num_classes coupling to the root's own data.yaml, and the segment refusal, each in one paragraph rather than a whole file's worth of header comments.

Variant spelled out, deliberately creating a second vocabulary. Every config's own variant: field and the --variant CLI flag stay bare letters -- that is the real API and does not change here. The filename now spells nano/small for readability at the cost of a reader having to know nano == n when connecting a filename to the config's own content or an override. Confirmed with the user rather than assumed, since nothing else in this codebase (code, docs, or CLI) has ever spelled these out.

What that scheme cannot promise, stated rather than left implicit. A filename's variant token is the config's default; --variant s on det_nano_smoke.yaml still runs an s-scale job under an n-named file, exactly as keypoint_gain 1.0 in a filename would not survive a --model.keypoint_gain override. The name documents the shipped default, not a runtime guarantee -- the same caveat every config's own header comment already states about variant ("this file's default scale, not a claim about the run").

What did not move: the historical record. docs/REPRODUCTION_REPORT.md's rows are explicit about being "the exact commands as they were run at the time" (docs/TRAINING.md), and the ROADMAP.md/ENGINEERING_LOG.md rows for WP-040, WP-099c, and WP-135 describe what those WPs built and named then. WP-129 already set this project's precedent for exactly this situation: moving audit_licenses.py/check_commit_trailers.py into scripts/lint/ did not rewrite WP-084's, WP-109's or WP-115's rows, which still cite the pre-move path. None of those earlier rows are touched here; this section is where all eight old names are cited for the last time.

What did move: everything a reader or the tool itself would act on. README.md, docs/TRAINING.md, AGENTS.md, docs/DATASETS.md (live usage docs, not a historical ledger -- a stale name there is just a broken command); scripts/overfit_micro.py's and scripts/shapes_regression.py's _RECIPE_PATH (functional, not prose -- the old name pointed at a file that no longer exists under it); src/lucid_yolo/cli/train.py's _DEFAULT_CONFIG and its own doctests; tests/ptl/test_cli.py's hardcoded test_configs_dir_is_non_empty set and every parametrized config-name literal; and every config's own internal header comment that named a sibling file. CHANGELOG.md's still-open ## [Unreleased] section also updated, on the same live-vs-historical reasoning -- it describes what the next release will ship, not what a past WP called something at the time, so a stale name there would mislead exactly the reader it exists for.

No test needed a structural change: tests/ptl/test_cli.py's _CONFIG_PATHS is _CONFIGS_DIR.glob("*.yaml"), so all eight renamed files are discovered and dry-parsed under their new names automatically. The one hardcoded list (test_configs_dir_is_non_empty's membership assertion) and the several tests parametrized on a literal config name still needed the literals updated by hand, since a glob does not know what a test author meant to assert about.

WP-137 โ€” a signal that only ever existed downstream of the run it should have warned about

Started as a request to prototype hotcoco (github.com/derekallman/hotcoco), a Rust-backed reimplementation of pycocotools's COCOeval, as a faster replacement for faster_coco_eval. It ended somewhere else, and the two findings that moved it there are both worth keeping.

hotcoco cannot be a torchmetrics backend, and the reason is structural rather than a version gap. torchmetrics.detection.MeanAveragePrecision's CocoBackend builds an empty COCO(), assigns a plain dict to its .dataset attribute, and calls .createIndex() -- a construction contract shared by pycocotools and faster_coco_eval alike. Installed and inspected directly (Tier-1 evidence, not the README's own drop-in claim): hotcoco's COCO.dataset is a read-only Rust-bound attribute (AttributeError: attribute 'dataset' of 'builtins.COCO' objects is not writable), and no createIndex/create_index method exists at all -- confirmed even after calling hotcoco's own init_as_pycocotools() monkeypatch, which does make from pycocotools.coco import COCO resolve to hotcoco's class (verified via sys.modules inspection) but returns an object that still fails both calls. A working hotcoco integration exists -- COCO(full_dict) -> .loadRes(results) -> COCOeval(gt, dt, iou_type=...) -> .evaluate()/.accumulate()/.summarize(), verified end to end with correct numbers -- but it is the same hand-driven shape evaluate_keypoints already uses for faster_coco_eval, not a backend= value, and it costs two things a subclass does not: hotcoco's dict schema requires height/width per image unconditionally (torchmetrics' own bbox-only dataset builder never produces them), and hotcoco offers no incremental .update() -- the whole dataset must be built in memory before scoring. For segm that reintroduces exactly what _StreamingScorer's own docstring exists to prevent: a full split's masks held at once, "hundreds of gigabytes" in this module's own words.

Benchmarked anyway, since a real cost needs a real number rather than a plausible one. A synthetic bbox-only benchmark at COCO val2017 scale (5000 images, 34,227 instances, matched to REPRODUCTION_REPORT.md's own cited count) measured faster_coco_eval (via evaluate_bbox) at 1.160 s and a hand-driven hotcoco path at 0.089 s -- roughly 13x, and the two engines agreed to three decimals (AP 0.888 both). But MeanAveragePrecision.update()/.compute() runs once per validation epoch (module.py's self._val_map), inside a training run whose own per-epoch wall-clock is measured in minutes; ~1.07 s saved per epoch over a 50-epoch run is roughly a minute total against hours, not a bottleneck a training loop notices. Separately, roboflow/rf-detr's own PR #1375 ("consolidate COCO mAP evaluation", 3.28x CPU speedup measured in its own PR body) turned out not to apply here either, for a reason worth stating precisely rather than waved past: that PR fixes a cost specific to class_metrics=True on torchmetrics <1.9, and this project is on torchmetrics>=1.9 with class_metrics never set -- already the fast path that PR exists to reach. Conclusion: hotcoco is rejected (A73) -- not for lacking a benefit, but because the benefit it has does not clear the cost of reaching it.

What the research surfaced instead was a real gap, one already named earlier this session and left open. A task="keypoints" module's validation_step fell into the if self.task != "obb": branch and called self._val_map.update(...) with plain box/label dicts -- the same call a plain detection module makes. Nothing scored keypoints during training validation at all; the only place OKS was ever computed was the standalone lucid-eval report, run by hand, after the fact. A user direction mid-turn reframed the whole question around this: not "should det/seg's eval get faster," but "should keypoints and det/seg have one eval experience" -- and roboflow/rf-detr's own PR #1183 (keypoint_oks.py's MetricKeypointOKS, read in full via gh pr diff) turned out to be a real precedent, but one that validates the shape this project already had for keypoints rather than motivating a change to it: a hand-rolled reset/update/compute facade over a faster_coco_eval-backed evaluator, built for the identical reason -- their own docstring names it, citing upstream torchmetrics PR #3348 as the eventual real fix rather than attempting one themselves.

So the unification landed as: give keypoints the same per-epoch validation monitoring det/seg/obb already had, not a new abstraction to hold it in. DetectionLitModule already carries this exact pattern for "obb"'s WP-063 rotated mAP -- self._val_rotated_preds/self._val_rotated_targets as plain lists, accumulated per batch and scored once at epoch end, because "mAP is not a per-batch quantity that can be averaged" (the module's own words) and there is no torchmetrics.Metric shape to hold an epoch-whole matching. Keypoints' OKS is the identical case, so self._val_keypoint_preds/self._val_keypoint_targets follow it exactly rather than reaching for a wrapper class: _update_val_keypoints decodes the one-to-one branch's raw point offsets (decode_keypoints) and gathers each kept detection's own set by the anchor index the same top-k selection already returned (gather_keypoints -- coco_eval.py's _gather_keypoints, made public, since DualPathEvaluator and this module now both need the identical padding-row-zeroing contract and a second hand-copied version of it is exactly the drift WP-109's "three strides, two of them private" lesson warns against); _log_oks_map scores the epoch once via the existing, unmodified evaluate_keypoints and logs val/oks_mAP.

Unlike "obb", this sits beside val/mAP, not instead of it (WP-102's own distinction, restated for the new case): a keypoints module's box branch is a real person detector, orthogonal to its pose quality, the same relationship val/segm_mAP already has to val/mAP. The sigma vector is picked by point count rather than stated once: COCO_KEYPOINT_OKS_SIGMAS only when num_keypoints == 17 matches COCO's own schema exactly, A67's uniform SYMBOL_KEYPOINT_OKS_SIGMA for every other count -- the same choice scripts/overfit_micro.py's synthetic gate already made for its own fixture, now read from one place (coco_eval.py) instead of two independently-hardcoded 0.072 literals that could have drifted apart.

Verification. tests/ptl/test_keypoint_training.py::TestKeypointValidation -- one validation step populates both buffers and on_validation_epoch_end logs a finite val/oks_mAP and clears them; val/mAP and val/oks_mAP are both present, confirming the "beside" relationship; a monkeypatched evaluate_keypoints confirms the sigma ternary picks COCO_KEYPOINT_OKS_SIGMAS only at num_keypoints == 17 and SYMBOL_KEYPOINT_OKS_SIGMA otherwise. Full regression sweep (test_keypoint_training.py, test_coco_eval.py, test_module.py, scripts/_tests/test_overfit_micro.py -- the last one exercising SYMBOL_KEYPOINT_OKS_SIGMA from its new location) -- 75 passed. make gate green.

WP-138 -- a claim was wrong, and the fix it justified had already shipped

WP-137's own text claimed something false, and this row exists to say so plainly rather than quietly patch it. That entry rejected a hand-driven hotcoco path for segmentation on the grounds that it "would cost back the memory-bounded streaming _StreamingScorer exists for" -- an assumption never checked against the code it was reasoning about. Checked here, against torchmetrics' actual _get_safe_item_values source (the function _StreamingScorer's own docstring already cites): it RLE-encodes every mask immediately inside update() and discards the dense tensor the moment the batch is scored. Nothing about that is exclusive to torchmetrics' own machinery -- a hand-driven adapter can do the identical thing, encode-then-discard per batch, and only assemble the small already-encoded document at compute() time. The memory bound is about dense masks, not about "whole document at once," and conflating the two was the error. docs/ASSUMPTIONS.md A73 is revised in place rather than left standing, per this project's own rule that a wrong claim gets corrected where it was made, not silently worked around.

The correction changed the answer, not just the reasoning. With the memory objection gone, the remaining question was purely a feasibility one -- can hotcoco's actual API be driven correctly -- and the user asked for it reassessed with a harder constraint: even accepting a MeanAveragePrecision subclass if that is what it takes. Two things closed that question before any subclass was needed. First, roboflow/rf-detr merged PR #1402 in the interim (after WP-137's own research agent scanned develop's merged commits, which is why it was missed then) -- a real, working _HotCocoBackend(CocoBackend) subclass: constructed under the accepted "faster_coco_eval" string (passes CocoBackend's own validation), its coco/cocoeval/mask_utils properties overridden to return hotcoco's objects, and _get_coco_datasets (the assign-.dataset-then-createIndex() method hotcoco cannot support) skipped entirely on that path -- confirming a subclass is buildable, contradicting WP-137's blanket "cannot be a torchmetrics backend" framing. Their own measured verdict, independent of this project's: "not a training-throughput change... under 2% of an epoch," even with an array-fast-path optimization this project did not attempt. Second, this project has no DDP anywhere (grep across cli/train.py, module.py, docs/TRAINING.md -- nothing), which is Metric's other real selling point beyond what a hand-driven scorer already gets for free by copying the same encode-then-discard pattern. Between a training-loop win both this project's own benchmark and rf-detr's independently measure as negligible, and a subclass whose main other benefit does not apply here, the subclass (WP B) stays deferred rather than built.

What landed instead: hotcoco behind lucid-eval's own --eval_backend knob, where rf-detr's own PR says it actually matters -- "standalone evaluation of a checkpoint, large validation sets, sweeps that evaluate far more often than they train" is lucid-eval's exact shape, not module.py's per-epoch training loop, which stays on faster_coco_eval untouched. Default "auto" prefers hotcoco -- its wheels cover this project's real target platforms (cp39-abi3, macOS/Linux/Windows) -- and falls back to faster_coco_eval only if a runtime probe (hotcoco_available(): import, then construct an empty document, since a partially-broken compiled extension imports fine but cannot build anything) finds it unusable, recording the reason in the report's own info dict rather than only printing it. An explicit --eval_backend hotcoco is a stated requirement and raises rather than silently substituting -- the same "explicit override wins" shape --data.layout already has (A63). --eval_backend faster_coco_eval stays a fully-tested rollback, no reinstall, matching rf-detr's own choice to ship theirs the same way.

Five real hotcoco compatibility traps, each verified directly against this project's own installed package rather than taken from rf-detr's PR on faith (all five match their documented list exactly, which is itself worth recording -- independent verification, not a citation):

  1. params is copy-on-read. ev.params.recThrs = [...] is a silent no-op; the whole object has to be reassigned (p = ev.params; p.recThrs = ...; ev.params = p). Confirmed directly: a mutate-in-place maxDets change did not survive, the reassignment did.
  2. dataset is copy-on-read too, which is why _HotcocoStreamingScorer builds two independent ground-truth/result documents for a combined bbox+segm model (one box-area, one mask-area) rather than mutating one loaded document's area field between passes the way torchmetrics' own internal combined path does.
  3. RLE counts comes back bytes from faster_coco_eval's own mask.encode, and hotcoco's constructor decodes only the str form -- silently reading a bytes payload as an empty mask, no exception. Verified directly: a 9-pixel mask round-tripped through bytes scored 0 pixels; through str (.decode("utf-8")), 9. This is now centralized in one function (_encode_mask_rle) specifically so the fix cannot be half-applied at some call site that builds a record by hand.
  4. mask.encode accepts uint8 only, not the boolean arrays this project's mask tensors already are -- cast before encoding.
  5. hotcoco writes its own stdout (a summary table) and stderr (one warning per evaluator parameter differing from COCO's defaults -- this project overrides recThrs and maxDets, so every call would otherwise warn) directly from Rust. Verified directly, the same way rf-detr's PR states it: wrapping a hotcoco summarize() call in contextlib.redirect_stdout left the table on the real terminal and an empty capture buffer. Fixed with descriptor-level os.dup2 redirection (_redirect_native_output), scoped tightly around the three evaluator calls so a genuine failure -- hotcoco raises Python exceptions on error, never merely writes to descriptor 2 -- still surfaces normally.

The acceptance instrument is TestHotcocoParity, not a benchmark. Bit-for-bit (abs=1e-6, float32 precision noise) parity between _StreamingScorer and the new _HotcocoStreamingScorer, fed the identical accumulated predictions and targets: all 12 bbox statistics, all 24 bbox+segm statistics, the empty-preds all-zero case, and a full end-to-end DualPathEvaluator run through the real decode/letterbox pipeline on the detseg fixture (test_dual_path_report_matches_across_backends) -- the last one specifically to catch anything that only surfaces once real decoded detections, not a hand-built dict, reach the RLE/height-width/params machinery. hotcoco>=0.5,<0.6 joins pyproject.toml's core dependencies (a tight pre-1.0 ceiling, deliberate: three of the five traps above fail silently rather than raising, so each minor needs re-verifying against this same parity suite before it is trusted) -- faster-coco-eval stays required alongside it, since keypoints and module.py both still call it directly. make gate green, full suite plus the new parity class.

WP-126 โ€” the fourth task existed everywhere except where a reader would look

The tier's own artifacts landed cleanly: a model card, a 0.5.0 reproduction-report section, a fourth panel on plot_training.py for the keypoint loss term. What did not land, and was not noticed until the naming of one file was questioned, is that every general-audience document still described a three-task project. README.md said "Three tasks, one trunk" and listed three; docs/index.md had a heading reading "The three tasks"; docs/TRAINING.md carried a launch section for detection, segmentation and oriented detection and none for keypoints, and its wiring-gate block listed three of the four overfit_micro.py tasks. None of that is a gate failure โ€” every register check, link check and section check passed throughout, because each of them asks whether a named thing exists, and no gate asks whether a newly named thing has been added everywhere its three siblings appear.

The naming question that surfaced it is itself the more useful finding. The card shipped as model_cards/pose.md, describing a "pose detector", which is precisely backwards: this project's keypoint task is K-generic by construction โ€” num_keypoints is a constructor argument the way the class count is, nothing in the head, the loss or the decode path reads a point's meaning, and the wiring gate deliberately runs a 7-point synthetic symbol schema rather than a human one (A67 exists only because that schema has no annotator variance to derive sigmas from). Human pose is one instantiation, distinguished by having a published sigma table and a ground-truth file, not by being what the architecture is for. The file is model_cards/keypoints.md, and the general docs say keypoints and then name pose as the instantiation the shipped checkpoint trained on.

The task-specific prose stays in pose terms wherever it is actually describing the shipped checkpoint โ€” its training data is person_keypoints, its ethical-considerations section is about predicting human skeletons, and softening those into schema-neutral language would make the card describe something safer than what was trained. The distinction the rename enforces is between the architecture (keypoints) and this checkpoint (pose), not a blanket find-and-replace.

Three stale figures were corrected in passing, each found by reading a line rather than by any check: the README claimed 63 registered assumptions against an actual 73, and 20 frozen golden files against an actual 14 (the number had been read off a total that included the seven live sets); and it described roadmap 111's whole-image oriented figure as not yet measured, which had stopped being true when that row closed and the reproduction report gained the number. A document that quotes a count is a document that goes stale silently, and none of the three would have been caught by anything in make gate.

The general lesson for the next task family, whenever there is one: adding a task is not done when its own artifacts exist. The check worth running by hand is to grep for the previous task's name across the tree and ask, at each hit, whether the new one belongs beside it.

WP-139 โ€” the same blindness, one layer down

WP-126 above closes with a lesson, and this row is what running that lesson found. Greping for the previous tasks' names across src/ rather than across docs/ turns up a structural gap the documentation sweep could not see: detection, segmentation and oriented detection each ship a composite nn.Module, a build_* factory, a .deploy() NMS-free inference view and a frozen params/FLOPs golden. Keypoints shipped none of the four. The keypoint model existed only as an inline build_detection_stages(..., num_keypoints=K) call inside DetectionLitModule.__init__ โ€” enough to train, evaluate and report, which is exactly why nothing complained.

The golden harness cannot notice a golden that was never written. check_goldens.py discovers its work by globbing goldens/*.json and goldens/frozen/**/*.json, so every golden present is checked and no count is asserted anywhere. Nothing in the repository enumerates "one size golden per task" and compares that list to the tasks that exist. This is the identical shape of blindness WP-126 recorded one layer up โ€” a check that asks whether a named thing is intact, never whether a newly named thing was added beside its siblings โ€” and it would have shipped a 0.5.0 whose headline feature was the one task absent from the release's own regression contract.

What the measurement says. The golden is taken at detection's own protocol โ€” a 640-pixel input over 80 classes โ€” rather than at a protocol of its own, specifically so that params_flops_kp.json minus params_flops_det.json is the point stems and nothing else. At n that difference is +110,652 parameters and +0.1238 GFLOPs, and the asymmetry between those two numbers is the A29 convention made visible: parameters count the whole checkpoint, so both head branches' stems are in the tally, while FLOPs count only the deployed one-to-one branch, so one branch's stems are. The per-branch stem cost is therefore half the parameter figure, and the two numbers are consistent rather than in tension.

No published table, and that absence is recorded rather than filled. R14 (RLE) specifies a loss and an OKS evaluation protocol and no architecture at all, so unlike detection (R1 Table 7), segmentation (Table S9) and oriented detection (Table S11), there is no size table in the literature to hold this model against. The three siblings each pair their golden with a published-table gate; this one has a golden alone, and tests/models/test_param_flops.py says so in a comment where a fourth _TABLE_* constant would otherwise be reached for. Inventing one โ€” from a sibling table, from a third-party pose model, or from this project's own measurements โ€” would fabricate a published claim, which is the failure mode the whole register system exists to prevent.

num_keypoints is keyword-only and required on both the class and the factory, with no default, matching the refusal DetectionLitModule already makes: K is a property of the dataset's annotation schema, so a default would be a silent claim about data the model has never seen. The shipped nano checkpoint's own figures in docs/model_cards/keypoints.md stay where they are and stay different โ€” they are measured over COCO's single person category, which is a different question from the architecture's size.

Verification. make gate green: 2458 passed, 39 skipped, 3 deselected, 22/22 goldens where 21 passed before. The new composite gates in tests/models/test_keypoint_head.py::TestKeypointDetector assert what the golden cannot โ€” that the stems are run rather than merely built, that the deployed view holds no one-to-many parameter and shares rather than copies the rest, that its returned triple feeds decode_keypoints unadapted, and that omitting num_keypoints raises.

WP-140 โ€” a release is a boundary, not an achievement

0.5.0 closes the keypoint phase. __version__ moves from 0.5.0.dev2 to a plain 0.5.0, which is the whole of the version change โ€” pyproject.toml declares the version dynamic and reads the module attribute โ€” and the ## [Unreleased] section WP-133 opened closes as ## [0.5.0], on the schedule its own opening paragraph wrote down.

The frozen goldens are the release's regression contract, and this one is a set larger than the last. make freeze-goldens MINOR=0.5 copies the eight live metric sets into goldens/frozen/0.5/, where 0.4.0 froze seven; the extra set is WP-139's keypoint size golden, which did not exist a commit earlier. That is the difference between freezing what happens to be lying around and freezing what the release actually claims, and it is only a difference because the missing golden was found before the freeze rather than after it.

What this release does not claim. The keypoint tier's acceptance is a direction of effect against a control โ€” RLE beat WP-135's Laplace-NLL arm at e2e OKS AP 0.2738 against 0.2527 on real COCO human-pose data, the same sign as R14 Table 7's own 70.5-against-67.4 โ€” and it is not a pose figure comparable to anything published. R14's own numbers come from a different backbone, a different schedule and a different scale; this project's smoke tier is fifty epochs at n. Three keypoint assumptions stay open and each names an experiment nobody has run: A68's tuned gain adequacy (the mechanism-failure bar cleared, no sweep run), A69's one shared flow against one per branch (untested against the alternative, and the overfit gate structurally cannot tell), A70's off-canvas point policy (exercised live in both arms, its frequency and effect unmeasured). The oriented figures are unchanged from 0.4.0's caveat only in that WP-111 has since measured a whole-image number; every other limit that release recorded still stands, and no trained weights ship (D14).

The fourth task trains, validates and evaluates; it does not yet deploy. tests/models/test_onnx_export.py still runs three tasks, and predict.py still ships three functions. That is parity work for a later phase, named here rather than left for a reader to infer from an absence.

Two CI failures the offline gate structurally cannot see, both found on the release's own run and folded in here. The first is a four-character bug with a document-wide blast radius: docs/ENGINEERING_LOG.md and docs/ROADMAP.md both wrote the placeholder test_ followed by an angle-bracketed script, and Python-Markdown's HTML block pass runs before inline code spans are recognized, so the backticks protect nothing โ€” an angle-bracketed script opens a raw-text element that swallows the rest of the document, and every <a id="wp-NNN"></a> anchor after it vanishes from the element tree mkdocs build --strict reads. Eighteen roadmap links broke at once, every one of them from the WP-130 paragraph onward, and the anchors were all still present in the rendered HTML โ€” the post-processing step restores stashed raw HTML, so the page looked correct while the validator that never sees that step was right to fail. Renamed to test_<script_name>.py, which is not an HTML tag. The second is scripts/lint/audit_docs_site.py's # type: ignore[no-untyped-call] on add_multi_constructor: whether that call is typed depends on the resolved types-PyYAML, which the lockfile does not pin, so the ignore is load-bearing locally and dead on CI's newer stubs, where warn_unused_ignores then fails the run. unused-ignore now rides along in the same bracket, correct under either version without pinning a stub package for one comment.

The general shape is worth keeping: make gate is an offline gate and does not build the docs site, so a docs-site failure is invisible until CI, and a lint that depends on an unpinned transitive stub version is invisible until CI resolves a different one. Neither is a gate to add โ€” the docs build is genuinely CI's job and pinning stubs to satisfy one comment is worse than the comment โ€” but both are reasons the gate being green is not the same claim as the branch being green.

No tag is cut here and no distribution is published, as at 0.4.0. release.yml fires on a pushed v0.* tag and re-runs the guard, which refuses a tag whose changelog section is missing or whose gate is red; both are the operator's to trigger. v0.4.0 is also still uncut, so the train has now shipped two release commits ahead of its own tags โ€” a gap worth stating plainly, since a reader who looks for v0.5.0 will find neither it nor its predecessor.

WP-140b โ€” the contract names a role, and says when it ends

Lettered off 140: governance work that follows the release rather than any package. Three things land together because each is the reason for the next.

[HUMAN] becomes [PRINCIPAL]. The tag never meant a person rather than a machine; it meant the party who drives the project rather than the party who executes it, and every use of it is a call that comes from intent, ownership or risk appetite โ€” a release, a push, a GPU budget, an acceptance. [OPERATOR] was tried first and rejected on reading: an operator is one who operates the machine, which names the executing half, exactly the half the tag does not mean. principal is the term of art for the party whose intent an agent serves, and it survives the human/bot question permanently, because a principal can itself be an agent one level up. The rename is governance-sense only. Anatomical uses stay untouched โ€” "COCO human pose", "human anatomy", "human-readable", "human-drawn polygons" โ€” as do the pre-existing uses of operator meaning whoever runs the command, which now read as the useful contrast rather than as a competing sense of one word: the principal decides, the operator executes.

The WP contract is a reproduction instrument, and instruments end (D18). One WP, one commit, one roadmap row exists so that a from-scratch reproduction of three papers is auditable row by row. Once the reproduction report carries all four accepted tiers that purpose is served, and continuing to demand a tracked row for a typo fix would be ceremony rather than evidence. What relaxes is only the tracking: a change altering no shipped behaviour, no public symbol, no golden and no documented assumption becomes an ordinary gated commit, and the gate itself never relaxes. What restarts it is a new task โ€” a fifth head after detection, segmentation, oriented detection and keypoints โ€” because a new task is a new reproduction claim and D2's standard applies to it exactly as it applied to the first four.

Admission is three layers, because a signature is not a detector. The exposure is not one vendor. It is any source this project cannot redistribute under Apache-2.0: copyleft, source-available, paid, proprietary, or with a licence that cannot be read. Ultralytics is the loudest instance of that class, not the class itself, and today's pull-request template names it where the rule is D13's whole allowlist. No attestation detects such code arriving, so the layers are picked for how differently they fail โ€” a DCO sign-off records who submitted it and under what claim of right, a generalized attestation fixes what that claim covers, and a provenance trailer on every contributor commit is the only one of the three that yields something reviewable: an id resolvable against docs/PROVENANCE.md, whose absence on a change to a loss or an assigner is visible before release rather than after. Enforcement is a check in this repository rather than a third-party app, because a survey on 2026-09-02 found every free hosted CLA service either archived, years stale, or a two-star project, and an admission record should not depend on someone else's uptime.

Phases 13 and 14 are written down. The augmentation stack leaves for fuse-augmentations โ€” the plan of record is kept outside the tree, and only its roadmap rows land here. The split into two phases is not cosmetic: the guard that authorizes a swap is published in 0.6.0 before anything it guards is allowed to move in 0.7.0, so a swap is measured against a released version rather than a working tree. The guard is deliberately not an old-versus-new comparison, which would enshrine legacy behaviour as the definition of correct and keep the code alive as its own reference, blocking the deletion the check exists to authorize. Keypoint ONNX export and predict_keypoints join Phase 13 because 0.5.0's own release entry names them as the parity a later phase owes.

WP-145 โ€” a boundary is cheaper to write than to discover

ADR-005 records an agreement between two repositories before either acts on it. No code moves here, and that is the point: every row in Phase 14 deletes a local implementation, and a deletion argued one call site at a time produces a boundary nobody ever stated and everybody assumed differently.

What the boundary actually divides is convention from policy. The first draft of the split was a list โ€” fuse gets warping, letterboxing, the instance filter; this project keeps Targets, tiling, the recipe. A list settles the cases it enumerates and nothing else, and the cases that matter are the ones nobody thought of. The rule underneath it does settle them: fuse owns how a matrix is built, composed and sampled, and this project owns which numbers go into it. Read forward, that rule says a specification pushing an R1 Table S3 constant upstream is written wrong, and it says so without anyone having to have anticipated that specification.

Three capabilities argued their way to staying local, and the arguments are not interchangeable. Multi-image assemblies stay because they do not resample at all โ€” mosaic pastes, mixup blends, copy-paste composites โ€” so a package whose whole subject is collapsing repeated interpolation gains nothing by holding them. Polygon rings stay for an unrelated reason: they are ragged, clipping one at a canvas edge adds vertices, and every target the upstream package transports today is fixed-shape, so moving them would force a padded-tensor or list-of-tensors design onto the package to serve one consumer. Rotated boxes move, on the mirror of the ring argument โ€” five numbers, fixed-shape, batch-leading, exactly the shape already transported, and oriented detection has its own literature rather than being a YOLO idiom. Three capabilities, three different reasons, and a list would have recorded only the verdicts.

The rings decision came with a condition that is worth more than the decision. Rings stay local because the composed matrix the upstream pipeline already returns is the same mapping it applied to the image, so applying that matrix to rings here makes the alignment invariant โ€” a derived box equals a separately transported box on an unclipped instance โ€” hold by construction rather than by agreement between two implementations. That holds only while the geometric run is a single fused segment. A pointwise barrier splitting it hands back one segment's matrix instead of the chain's, and the rings would then be warped by a partial transform with no error, no exception and no visible symptom beyond geometry that is quietly wrong. It is written into Phase 14 as a tier-C invariant for that reason: the failure mode is silence, and silence is what an invariant is for.

The battle-test contract is stated because neither side would otherwise hold it. This project's smoke tiers, goldens and frozen expectations are the real-project evidence the upstream package is tested against, and that evidence stays here. Written down, the exchange is symmetric โ€” an engine with its own unit acceptance against four tasks, two datasets and published-number fidelity gates exercising it end to end. Unwritten, it is the thing each side assumes the other is doing.

Condition 1 of four is not this project's to satisfy, and is recorded as unconfirmed. A runtime dependency needs real SemVer releases on PyPI, because a git-URL requirement is legal in a dependency group and illegal in [project].dependencies โ€” so a SHA pin cannot ship. That is an upstream commitment; this side can state the requirement and cannot meet it. An ADR that recorded it as settled would be recording a hope. The consequence of a refusal is bounded and worth saying: the augmentation stack stays local, which is where it is today, rather than the project becoming unpublishable.

The floor moved with the register. audit_docs_present.py ratchets on the decision count and asserts each ADR section by name, and both are deliberate edits rather than chores โ€” the count catches a deleted last row that contiguity alone would find perfectly contiguous, and the name list is what makes a new ADR enforced rather than merely present. The floor had been left at D17 when D18 landed, so raising it to D19 here catches up one release of drift as well.

WP-146 โ€” the pin was right and the register was wrong

This row was written expecting to advance a pin. It advanced nothing. pyproject.toml has read fedde0c1a482ac1add313fd823f6bc18e7b47436 since WP-132, and WP-132's own commit trailer cites that same commit; what WP-132 wrote into docs/PROVENANCE.md was 0a0cc64072b854936f3a15c36c21b5ed37012891, one upstream commit behind the thing it installed. The register and the manifest disagreed for eight work packages and one release, and nothing could have noticed: no gate reads the register, and both hashes name real commits of the right repository.

A pin bump that spans two upstream commits needs both named. WP-132 wanted SymbolShape for the pose overfit gate's kite slice, and SymbolShape is 0a0cc640 โ€” so the register's prose is about the right feature while its hash is about the wrong commit, which is the failure mode that makes this kind of drift survive review. Reading either field alone gives a coherent story. Reading them together against pyproject.toml is what does not.

What LetterShape did to the fixtures: nothing, measured. The generator was installed at 0a0cc640, both generator-derived producers were run, and every value came back identical to the frozen goldens โ€” 90 detection annotations, 895 segmentation points, 55131.455 box-area sum, 61 oriented annotations, and the ten data_pipeline_metrics values including the image mean and std sums. The generator was then restored to fedde0c1 and the producers re-run to confirm the restoration. So the whole of WP-132's re-freeze is attributable to 0a0cc640 โ€” the obtuse-scalene TRIANGLE and the area-centroid re-centring, exactly as its register prose says โ€” and none of it to the fourth shape family. No golden moves here, which is the correct outcome for a reconciliation row and would have been a defect in a bump row.

Neutrality was worth measuring rather than arguing. fedde0c1 widens the Shape union to 49 members and changes how class_id_of resolves ids against a narrowed category list, both of which are exactly the kind of change that reaches a fixture's category block. The argument for neutrality โ€” that this project draws only DEFAULT_SHAPES, whose ids are a stable prefix โ€” is a good argument and is not evidence. Installing the other commit and reading the numbers costs two installs.

The fixture module was describing behaviour it does not have. tests/fixtures/synthetic.py claimed the emitted COCO categories list "still names every animal fuse-augmentations ships even though none is ever drawn here". It does not, and did not at any pin this project has used: the detection fixture emits four categories, square, rectangle, triangle, circle. The claim is corrected here rather than left for a later row, because this row's whole subject is a register describing R21 wrongly, and a second document describing R21 wrongly a directory away is the same defect. Its cause is not LetterShape โ€” the golden frozen at 74af9855 already carried four categories โ€” so this is a claim that was never checked rather than one that went stale, which is the more useful thing for a later reader to know.

The general lesson is narrow and worth keeping: a register row carrying a hash is a manifest with no gate behind it, and the hash is the field that drifts, because prose gets re-read and a forty-character hex string does not. Where the same fact lives in two files and only one is executable, the executable one is the one to trust and the other is the one to check.

WP-147 โ€” a seam is a move, not a rewrite

Every stochastic transform did two separable things inside one __call__: it drew parameters from a generator, then applied them. Only the second is the contract that has to survive the augmentation stack moving upstream, because the upstream sampler will not draw the same numbers from the same seed โ€” different call order, different distributions, different consumption of the stream. Freezing an expectation against a seed would therefore break at the swap for reasons that have nothing to do with correctness, and the only available repair would be a re-freeze at exactly the moment the guard was supposed to hold. So the seam comes first, and everything frozen afterwards is keyed to stated parameters.

Seven transforms now carry it: RandomAffine (which had AffineParams and a private _sample already, and is the precedent the other six are modelled on), FusedAffineLetterbox, HSVJitter with a new HSVParams, HorizontalFlip with FlipParams, MosaicAssembly with MosaicParams, Mixup with MixupParams and CopyPaste with CopyPasteParams. Each gains a public sample() that is the only method touching generator, and a public apply() that draws nothing; __call__ is now literally self.apply(inputs, self.sample(...)). Letterbox needed nothing โ€” it is deterministic and its forward_affine / warp_targets / inverse_map have been public since WP-009.

The draw code was moved, not rewritten. That is the whole discipline of this package. Every sample() body is the original draw sequence lifted verbatim: same helper, same order, same count. The guard for it is a temporary same-codebase equivalence test in tests/data/test_aug_seam.py โ€” record the generator state, run __call__, rewind, run sample() then apply(), assert the two agree bit for bit on the image and on every target modality. Rewinding rather than re-seeding is deliberate: a path that consumed a different number of draws fails immediately instead of drifting into some later transform's numbers. That file is old-against-old inside one repository with no upstream package involved, so it is not the old-versus-new comparison the plan rules out, and WP-149 deletes it once the frozen tiers make it redundant.

Two params types are two-valued on purpose. MixupParams.lam is float | None and CopyPasteParams.selected is a possibly-empty tuple, because for both transforms not acting is a sampled outcome rather than an absence of parameters. A MixupParams that could only express a blend would make the pass-through branch unreachable through the seam, and the pass-through is the branch that must consume the trigger uniform and nothing else โ€” consuming a blend factor there would desynchronise every later draw in the pipeline. Both branches are pinned by their own test.

CopyPaste was the one transform whose loop genuinely interleaved. It drew per candidate and pasted inside the same loop, so splitting it meant establishing that the draw count depends on the candidate count and the max_paste cap alone, never on pixels. It does, so sample(candidate_count) reproduces the loop's stream exactly and apply rasterises the named rings afterwards. The cap is what makes this worth a test of its own: a seam that drew once per candidate regardless of the cap would consume more of the stream than the original and pass every assertion about the pasted image.

One ordering did change, and only on an error path. Validation that used to sit between the draw and the application now sits after both โ€” RandomAffine's rotated-pairing check, Mixup's and CopyPaste's same-size-images check. For any input that does not raise, the stream and the result are identical, which is what the equivalence test asserts. For an input that raises, the generator has advanced where it previously had not. That is a real difference and worth writing down rather than discovering later; it is accepted because a raising sample is an aborted sample, and no caller resumes a pipeline from the RNG state left behind by an exception.

The last_params / last_matrix / last_gains / last_center / last_lam / last_flipped attributes all still exist and are set by apply, not by sample. Putting them on the application half keeps them meaning what they always meant โ€” what the most recent call did โ€” including when a caller supplies parameters instead of drawing them.

WP-148 โ€” the tier that proves rather than pins

tests/data/test_aug_contract.py holds twenty-two cases across two tiers and not one characterization value, which is the property that makes it reviewable: every expectation in it is derivable on paper from the transform's own definition, so a reader checks the arithmetic rather than trusting that a previous run printed this.

Tier A is the only tier that catches a bug rather than a change. A frozen output pins whatever the code does today, including whatever it does wrong; a derived expectation says what the code should do. Every case that geometry allows was written into this tier rather than left for tier B: an identity affine returns its input, an integer-pixel translation is an exact shifted copy, a quarter turn sends the box [1,2,3,6] to [2,1,6,3], a scale of two about the centre sends [2,2,4,4] to [0,0,4,4], a 10ร—20 source letterboxes at ratio 0.8 with four rows of padding, forward-then-inverse returns the points exactly, two mirrors compose to the identity including the keypoint permutation, a stated mosaic centre puts one box at four derived offsets, mixup at lam=0.25 on constants is exactly 0.25.

The quarter-turn case is worth the arithmetic it took. On an eight-pixel canvas the centre is (4, 4), and the rotation maps (x, y) to (4 - (y - 4), 4 + (x - 4)), so the corners (1, 2) and (3, 6) land on (6, 1) and (2, 3) and the envelope is [2, 1, 6, 3]. The image under the same rotation is not torch.rot90 of the input โ€” it is rot90 in the other direction, rolled by one column, because an even canvas has its centre on a pixel corner rather than a pixel centre. That asymmetry is real and correct and would have been easy to write down wrongly as an expectation; the box, whose mapping is a matrix and not a resampling, is the honest thing to assert. Pixels stay in the cases where they are analytically forced.

Tier C constrains the modalities against each other, which is where a shape-compatible wrong answer hides. A translation that pushes one of two instances off the canvas must drop it from boxes, labels, polygons, keypoints and visibilities together; dropping it from boxes alone leaves a keypoint set supervising the surviving box with the departed instance's points, and every shape check still passes. The alignment invariant โ€” a box re-derived from a transported ring equals the box transported separately โ€” is the property the whole ring route will rest on once the warp moves upstream, and it holds only while both ride one matrix. Rotated boxes leave every transform in canonical long-edge form, because that is a task convention rather than a resampling property and has to be re-established after each warp. Out-of-canvas pixels are exactly the grey fill, not zero. Mixup concatenates both label sets unweighted, since the blend factor weighs pixels and never labels.

The keypoint invariant is the one that ties two transports together. A point marking a bright pixel must still mark it after the warp: an integer translation moves the pixel and the point by the same three-and-two, and the assertion reads the warped image at the warped point. A keypoint path using a different sign or a different centre convention from the image path would move the point off its feature while both outputs stayed well inside the canvas and every shape matched.

One case needed its parameters adjusted rather than its expectation: the first draft translated by fourteen pixels to strand one of two instances, and stranded both โ€” the surviving box clipped to a one-pixel width and fell under the default min_box_size of two. Ten pixels leaves one instance intact. Worth recording because the invariant itself passed either way: with both instances dropped, every modality still agreed on zero, and the test was green while checking nothing. An invariant over an empty set is a case that cannot fail, which is the most expensive kind of passing test.

Coverage reaches every transform, including the ones staying local โ€” HSVJitter, the assemblies, and A11's half-open rasterisation, whose ring spanning [1,4] covers three pixels rather than four. A transform that stays is still refactored around, and a guard written after the refactor guards nothing.

WP-149 โ€” what a frozen value is allowed to mean

The guard's third tier is the one that pins rather than proves, and the discipline it needs is entirely about honesty. tests/data/test_aug_frozen.py holds nine cases, each stating explicit parameters, asserting a literal, and naming in its own docstring why no closed form was available. Where a closed form does exist the case is not here โ€” it is in WP-148's contract file, and every case that could be moved there was.

The tier-A/tier-B boundary is a real line, not a filing preference. A rotation is a similarity: it maps a rectangle to a rectangle, so a rotated box re-fits exactly and its expectation is derivable, which is why the quarter-turn rbox case sits in tier A. A shear is not: it maps the rectangle to a parallelogram, and the re-fit is then a choice of enclosing rectangle rather than a recovery of the original. That choice is the implementation's, which makes the sheared case tier B and the rotated case tier A, from what looks like the same operation on the same modality. The letterbox split is the same shape of argument in a different place: the ratio and the padding total are arithmetic, but a leftover of seven and a half rows resolving to an offset of three rather than three and three quarters is a convention, and conventions are pinned rather than derived.

A tier-B docstring that cannot name its reason is a tier-A case in the wrong file. Writing the reasons out caught three candidates and removed them: a mixup blend at a stated factor on constant images is arithmetic, a ring clipped against a canvas edge is a clip, and a mirrored rotated box is a reflection with a negated angle. All three were computed, found derivable, and either moved to tier A or dropped. What survived is a composite affine's boxes and ring, a fused affine-and-letterbox, an off-centre mosaic where the clip meets the instance filter, a hue rotation across an RGB sector boundary, a sheared rbox, keypoints through a flip-pair swap composed with the warp, an odd letterbox split, and a rasterised triangle.

The triangle case carries its own arithmetic as a witness. The ring (2,2)-(10,3)-(6,11) has a shoelace area of exactly 34 and rasterises to 32 pixels. Neither number derives the other โ€” the gap is the boundary rule, resolved one row at a time against the pixel grid โ€” but writing both down means a reader can see the frozen count is close to the area rather than merely take it on trust, and a future failure at, say, 41 is obviously wrong rather than merely different.

goldens/aug_invariants.json takes what a literal would express badly. Fifteen scalar aggregates over the same stated-parameter runs: surviving instance counts and written pixel counts pinned exactly, image means and polygon areas pinned within a band. The split follows what each store is good at โ€” the golden harness compares dict[str, float], so a box array flattened into box_0_x1-style keys would be a worse version of a pytest literal, while an image mean sitting in a test file would be a number with no tolerance and no home. Image expectations stay aggregate on purpose: the upstream engine samples at a different half-pixel convention, may execute through a different backend, and collapses several warps into one resample, so a byte-exact pixel expectation would fail at swap time for a correct implementation and the only escape would be a re-freeze under swap pressure.

Golden discovery was verified positively, not inferred. WP-139's lesson is that the harness globs, so it cannot notice a golden that was never written; a green run therefore proves nothing about a file's presence. check_goldens.py was run directly and printed PASS aug_invariants.json โ€” 15 metric(s) and 31/31, up from 30. That is the check, not the green gate around it.

tests/data/test_aug_seam.py is deleted here as WP-147 said it would be. It existed to witness that __call__ equals sample() then apply() while the frozen tiers did not yet exist; now that tiers A, B and C all call apply with stated parameters, the seam is exercised by every case in all three files, and a witness that outlived its gap would just be a slower way of asserting the same thing.

WP-150 โ€” the tier that is allowed to move

Six determinism assertions were scattered across five test files, each sitting beside the behaviour assertions for its own transform: TestDeterminism in test_affine.py, test_fused_warp.py and test_mosaic.py, TestMixupDeterminism and TestCopyPasteDeterminism in test_mixup_copypaste.py, and two seeded cases inside test_photometric.py's jitter and flip classes. All six now live in tests/data/test_aug_determinism.py, labelled tier D.

The move is not tidying. It is about what a failure will mean. Every assertion in the file is keyed to a seed, and a seed pins whichever sampler happens to be installed. A replacement engine will not draw the same numbers from the same seed โ€” different call order, different distributions, different consumption of the stream โ€” so these values moving at an implementation swap is expected and says nothing at all about correctness. Tiers A, B and C moving at a swap is a defect. Those are opposite verdicts from the same red test run, and the only thing that tells them apart is which file the failure is in.

Mixed in, the two verdicts share a keystroke. A tier-D failure at swap time reads as a regression, and the natural response โ€” re-freeze it, the values were always going to move โ€” is exactly the response that would silently destroy a tier-B guard sitting three classes above it in the same file. Separated, the response follows from the filename. This is the same argument the plan makes for never re-freezing an expectation in the same commit as a call-site swap, applied one level up: keep the thing that is allowed to move away from the thing that is not.

Two counterparts were added rather than only moved. Tier D is a two-sided property โ€” same seed gives the same output, different seeds give different output โ€” and only the first half existed. The second half is what catches a transform that ignores its generator and reads the global RNG: such a transform passes the equal-seeds case whenever the global state happens to line up, which under a seeded test suite is most of the time. That is not hypothetical here; WP-079 was opened by an augmentation RNG collapse. The flip case gained a related assertion, that sixteen draws at p=0.5 contain both outcomes, so a trigger stuck on one branch fails rather than agreeing with itself.

test_letterbox.py's TestDeterminism stayed where it is, and that is the useful distinction. It asserts that two calls on the same input return the same output โ€” purity, not reproducibility. Letterbox has no generator, nothing about it is keyed to a seed, and it will not be re-frozen at any swap. A rescope that swept every class named TestDeterminism into the tier-D file would have moved it, and the name is the only thing the two have in common.

No value in the tier-D file appears in goldens/, and none should: reproducibility is a property of a run rather than a number worth carrying across releases.

WP-151 โ€” the released task that could not leave PyTorch

0.5.0 shipped keypoints as the fourth task and shipped no way to export it. Detection, segmentation and oriented detection each had a graph class in lucid_yolo/export.py and a case in the ONNX gate; keypoints had neither, so it was the only released task whose model could not leave PyTorch at all. KeypointExportGraph closes that, and the gate now runs four tasks where it ran three.

The composition follows segmentation rather than detection, and the reason is structural. A detection graph calls the decoder's plain forward and is done. The point stem, like the mask-coefficient stem, is dense over anchors, so decoding it needs the source anchor of each detection row โ€” which decode_with_indices returns and forward discards. That single fact decides the whole shape of the class: decode_with_indices for the detections and their anchor indices, decode_keypoints over the entire dense tensor, then a gather. Decode first, gather second, which is the order the OBB angle branch established at WP-062 and the training module's own val/oks_mAP already uses. The reverse order is not merely slower; decode_keypoints needs the whole anchor grid and offers no per-detection indexing, so gathering first would mean reconstructing each kept row's anchor and stride by hand โ€” a second implementation of the thing the index already names.

Padding is where the two dense tasks part. A segmentation graph raises on a padding row: gathering mask coefficients at index -1 is an error, so that class documents a real precondition that the canvas carry more anchors than the detection cap. The keypoint gather does not have that precondition, because gather_keypoints clamps a padding index to a valid position and then zeroes the row, so a padding row yields the origin rather than the last anchor's pose. Reusing that function rather than writing a fresh gather is the point: WP-109's three-strides lesson was that a hand-copied padding rule drifts, and this is the third caller of the same rule rather than a third version of it.

The gate was extended, not duplicated. The keypoint case rides the existing fixture โ€” the same doctored checkpoint, the same 128-pixel canvas, the same onnxruntime comparison against an eager decode of the checkpoint-loaded module โ€” and adds one output-shape assertion and one parity assertion. The parity assertion carries a vacuity guard of its own, in the shape the existing mask comparison established: a gather at the wrong anchor still returns finite, in-canvas coordinates, so the point sets must be shown to differ from one another before comparing two orderings of them proves anything. Without it, a graph that returned the same pose for all 300 rows would match a reference that did the same.

K is deliberately three rather than seventeen. The point count is a constructor argument and the architecture is generic in it (A64); COCO's seventeen is one instantiation. A gate that only ever ran at seventeen would pass a shape accidentally baked to that number, which is exactly the class of defect an export gate is for. Three is small, is not COCO's, and puts (1, 300, 3, 2) in the asserted static output shape where a hardcoded seventeen would show up immediately.

WP-152 โ€” the fall-through that answered plausibly

lucid-predict dispatched on the checkpoint's own task through three branches and a final else. That else was written as detection, with a comment explaining why it was not an elif task == "detect": an unknown task must be refused, and the refusal belongs to the library, which names the task the checkpoint actually carries. Sound reasoning, and it stopped being true the moment a fourth task existed. A keypoints checkpoint reached that branch, predict_image accepted it โ€” the module's task was checked against "detect" and raised โ€” so the command failed with a message about detection rather than silently answering. The failure mode was loud, then, but the capability was simply absent: 0.5.0 released a task with no shipped way to run one of its checkpoints on an image.

predict_keypoints closes it, and the composition is predict_segmentation's rather than predict_image's for the reason WP-151 gives for the export graph: the point stem is dense over anchors, so the selected decode path has to report the source anchor of every surviving row. _decode_with_points is _decode_with_coefficients with the point stem substituted, including the rule that matters most โ€” detections, indices and the dense tensor all come from one branch, chosen in one place, so pairing the E2E decode's rows with the dense branch's points is unrepresentable rather than merely unlikely. That pairing would produce a plausible pose of a different object.

The letterbox inverse is applied once to a flattened point list. inverse_map takes (N, 2), and the points are (N, K, 2), so the instance and point axes are folded together for the call and restored after. Folding rather than looping per instance is not a performance choice: one call means one mapping, so no row can take a different inverse from its neighbour. This is also why the fourth entry point landing before the augmentation swap matters โ€” Letterbox.inverse_map now has four consumers rather than three, so the delegation Phase 14 performs on that class is validated against four rather than re-validated later.

K is read from the tensor, everywhere. KeypointPrediction.num_keypoints reports keypoints.shape[1], the report writes it into info, and the empty-prediction path returns an empty stack that still carries the point axis โ€” so a caller reading K off an image with nothing in it still learns the schema. The head's point count is a constructor argument (A64) and COCO's 17 is one instantiation; a function that assumed 17 would silently mis-shape a 15-point letter schema or a 12-point animal one.

The report gains a key rather than changing shape. keypoints: "xy-pairs" appears exactly when the records carry a point set, in the way boxes: "obb-longedge-rad" already appears only on oriented reports, so the three shipped report shapes stay byte-identical. The layout is named because COCO's own keypoint field is a flat triplet list carrying a visibility flag, and this one is neither flat nor triplets: nothing in this project predicts visibility, so writing a third column would be inventing a number. The stdout line prints the point count rather than the points โ€” at K = 17 that is 34 coordinates on one terminal line, which nobody reads.

The skeleton is a caller argument with no default, and that is the honest answer. draw_keypoints draws each detection's box, its markers, and any edges the caller names. Which points connect to which is dataset metadata, exactly as the left/right flip pairs are: the head predicts K points and knows nothing about what they mean. Baking COCO's human topology in would make a 15-point letter skeleton or a 12-point animal one confidently, visibly wrong โ€” a figure that looks authoritative and is not. With no skeleton the points are drawn alone, which is the correct figure for a schema nobody has named. The marker radius is fixed rather than scaled to the box, because a pose figure is read by whether a point sits on its feature, and a marker that grew with the object would hide exactly the small-object error worth seeing.

The dispatch test is parametrised over all four tasks with all four entry points stubbed, so a wrong dispatch shows up as another stub recording the call. Its keypoint case is the one that did not exist before this row, and it is the test that would have caught the gap a release earlier had the parametrisation been written to the task list rather than to the three branches that happened to be there.

WP-152b โ€” thirteen rows become six, and the ratchet earns its keep

Phase 14 was written as thirteen rows because that is how the migration plan decomposes: one row per upstream capability absorbed, one per class deleted, one per closing sweep. Regrouped to six at the principal's direction โ€” five work packages and the release.

What merging rows changes, and what it does not. It changes what one commit contains: a row is now "this much of the engine moved" rather than "this one function moved", so each commit is larger and each groups swaps sharing an upstream capability and a test surface. It changes nothing about the FA-n dependencies. Those name releases the other repository has to cut before anything here can move, and merging two local rows does not merge two upstream releases โ€” WP-154 still cannot start before FA-1, FA-2 and FA-3 have all shipped, where the old WP-154 needed only FA-1. The cost is real and worth naming: a coarser row waits on more upstream work before it can begin, so the phase has fewer places to make partial progress.

The one rule that constrained the grouping. A frozen value may never move in the same commit as a call-site swap, because that combination is indistinguishable from adjusting the test until the new code passes. Thirteen rows kept the re-freeze naturally alone. Six could easily not have: the obvious grouping โ€” "swaps" and "sweep" โ€” puts the export-list collapse, the docs rewrite and the golden re-freeze together, which is fine, but a tempting alternative folds the re-freeze into the last swap row to save a commit. It is not folded. WP-158 holds the re-freeze and no swap at all, which is the property that made the four-tier guard worth building.

Y-n codes are unchanged and now map several to a row. The plan file stays the plan of record and its codes stay stable across this renumbering, which is exactly what ยง12 #6 wrote them down for: a stable cross-reference survives a roadmap that moves. The mapping is no longer one-to-one, and that is a property of the roadmap rather than a defect in the plan.

The floor is why this edit is legible at all. _WP_FLOOR asserts the roadmap never shrinks, on the reasoning that dropping the last row leaves the remainder perfectly contiguous โ€” so contiguity cannot notice a deletion, and only a count can. The roadmap shrank from 166 numbered rows to 159 here, which is precisely the event the ratchet exists to force into the open, and it did: the floor had to be raised to 159 as a deliberate edit before the gate would pass. Worth recording that the floor had been sitting at 129 since long before Phases 13 and 14 added their rows, so it had a great deal of slack and would not in fact have caught this shrink. It has none now.

The export-count correction rides here rather than in the release, because this commit rewrites the cell that carried it: data/__init__.py re-exports 55 symbols, not the 51 the old row 163 claimed. The real count was 50 when that row was drafted โ€” a miscount of one โ€” and WP-147's five parameter dataclasses took it to 55.

WP-153 โ€” the guard is published before anything it guards moves

0.6.0 releases a regression guard and a task's deployment parity. It adds no model, no loss and no evaluation protocol, which makes it the first release on this train whose subject is entirely the ability to detect a future defect.

The release order is the whole argument for the phase split. Every row in Phase 14 deletes a local implementation and replaces it with an upstream one, and what authorizes each of those deletions is the four-tier expectation suite frozen here. A guard that shipped in the same release as the swaps it authorizes would be a guard whose own correctness was only ever demonstrated against the code it was written to replace. Publishing it first means the version a swap is measured against is a released one, reachable by tag rather than by commit range.

What this release does not claim. The contributor-admission layers are not built: WP-141 through WP-144 stand at โฌœ, WP-141 being a principal row with three preconditions outside the roadmap โ€” O3 cleared, the repository made public, WP-125 accepted. So the phase ships its release with four rows trailing, which is not new; 0.5.0 did the same with WP-125. A68, A69 and A70 remain open and each still names an experiment nobody has run. No trained weights ship (D14), and the guard is offline arithmetic on synthetic geometry rather than any statement about accuracy.

The freeze is larger than 0.5.0's by exactly one file. make freeze-goldens MINOR=0.6 copies the live metric sets into goldens/frozen/0.6/; the new one is aug_invariants.json, whose first freeze this is. It is worth naming which of the four tiers reaches the frozen set and which does not: the golden carries tier-B scalar aggregates, tiers A and C live in pytest literals and are frozen by being written down, and tier D deliberately reaches neither โ€” its values pin whichever sampler is installed and are re-frozen at every swap, so freezing them into a release snapshot would state a cross-release regression contract over exactly the numbers that are expected to move.

One stale claim corrected at the source. README.md said the export gate asserts the NMS-free property "on all three tasks". It runs four as of WP-151, and the sentence is the project's own headline architectural claim, so it is the one place where an off-by-one count is a claim about the deliverable rather than about the documentation. The keypoint model card needed nothing: its "the other three tasks" phrasings are comparisons, and comparisons of one task against three others stay correct at four.

No tag is cut here and no distribution is published, as at 0.4.0 and 0.5.0. release.yml fires on a pushed v0.* tag and re-runs the guard, which refuses a tag whose changelog section is missing or whose gate is red; both are the operator's to trigger. v0.4.0 and v0.5.0 are also both still uncut, so the train has now shipped three release commits ahead of its own tags.

WP-153b โ€” the phase is unblocked, and three of its rows described upstream wrongly

fuse 0.12 landed every capability Phase 14 waits on: FA-1 through FA-5, FA-7 and FA-8, with FA-6 and FA-9 declined exactly as the plan agreed. The phase is unblocked. Validating it against the rows that consume it turned up more than a green light, which is the reason to validate before implementing rather than during.

Two capabilities arrived better than they were specified. FA-7 imposes no canonical form on a rotated box โ€” no w >= h swap, no angle range โ€” and takes canonicalize= as a caller callback instead, on the reasoning that the long-edge convention belongs with the assigner, loss and evaluation kernel that share it. That is A22's argument, reached independently upstream, and it is what keeps WP-157's design intact. FA-8 decides when to permute keypoint pairs off the sign of the composed matrix's determinant rather than off whether a flip appears in the pipeline, and applies it on every routing path including the two that never build a warp grid. Two mirrors compose back to a rotation that must not swap, and a rotation past 90 degrees looks like a flip while being none; the local unconditional swap gets both wrong, so this is a capability that arrives stronger than the code it replaces.

Three rows named upstream mechanisms that do not exist. WP-154 planned to pin align_corners=False in the frozen suite, "which fuse currently does not" use. 0.12 declined to add the flag at all and measured the equivalence instead: it samples with align_corners=True against the normalization sandwich derived for that same flag, so the convention cancels and the pixel-space map is the one an align_corners=False implementation produces for the same matrix, verified against an independently constructed reference to atol=1e-5. There is nothing left to pin, and the residual that does survive is a different quantity โ€” the composition centre, ((W-1)/2, (H-1)/2) upstream against (W/2, H/2) here. WP-156 attributed geometric fusion to clip_policy="final"; clip_policy decides when a fused colour chain clamps and shares nothing with geometry, a confusion upstream anticipated well enough to warn against by name in its own docstring. Fusion is adjacency-driven, so a colour operation between two geometric ones splits the run and return_matrix then reports the last segment alone, silently โ€” which makes that row's tier-C single-segment invariant more load-bearing than when it was written, not less. WP-157 planned to delete rotated_aug.py; upstream refuses to clip a rotated box on the grounds that the clipped shape is a polygon and not a rotated box, so clip_rboxes_to_canvas has nowhere to go, and check_rotated_pairing validates a local Targets invariant that was never a resampling concern. The module shrinks and does not vanish.

What a green gate would not have caught. None of the three errors is detectable by any check this repository runs. They are prose in a scope cell, describing a repository the gate never reads, and each would have surfaced as an implementing agent reaching for a parameter that is not there โ€” the same failure mode as WP-139's golden that was never written and WP-146's register that named the wrong commit. The pattern is consistent enough to name: what the gate reads is safe, and what it does not read drifts silently.

Three decisions the measurement forced, taken by the principal. The first is the pin. fuse has no tags, local or remote, and PyPI's latest is 0.10.0.dev0, so 0.12 exists as a pushed bump commit rather than a release; the phase proceeds on a commit pin, and v0.12.0 is tagged from that same commit once the roll-out here is clean. That inverts ADR-005's step order and is deliberate โ€” this project is fuse's first real consumer, so the tag follows the integration that proves it, which is the reciprocal battle-test contract rather than an exception to it. The second is the letterbox. The geometry matches exactly, verified in both directions on integer arithmetic, but the pixels do not: this project resizes with an antialiased bilinear interpolate, upstream runs one grid_sample with antialias off by default and a Gaussian mipmap prefilter when on, so any downscale differs and Letterbox is the inference preprocessor for six evaluation and prediction call sites. The delegation proceeds, with the eval delta measured first and re-frozen in a row of its own; keeping the local resize would have kept a second implementation of the package's whole subject, which is what the migration exists to stop. The third is the mirror axis: upstream reflects about (width - 1) / 2 and this project about width / 2, so every mirrored centre moves a pixel. Upstream's axis is adopted and the convention change recorded, on the reasoning that one convention across image and coordinate transport is worth a re-freeze and two conventions inside one pipeline are worth nothing.

What this row deliberately does not do. No DoD moves and no row splits โ€” WP-153c does both. WP-154 needs splitting โ€” the pin bump moves the synthetic fixtures, because upstream redesigned the triangle from obtuse-scalene to equilateral and replaced the minimum-area OBB derivation with an upright-frame one, and triangle is 25 of 90 detseg and 17 of 61 obb annotations against goldens whose tolerance is zero โ€” so a re-freeze rides with that bump and must not ride with a swap. WP-155 and WP-157 need their DoDs rewritten to match the two decisions above. All of that is a row edit rather than a correction, and it follows rather than accompanies the correction of what was simply wrong.

WP-153c โ€” the rows split so that no commit both delegates and re-freezes

WP-153b corrected what the Phase 14 rows said about fuse and deliberately left the structure alone, because the structure depended on decisions that were still open. They closed, so this row does the splitting. Six rows become eight, and the single rule that produced every split is the one ยง6.5 already stated: a frozen value must never move in the same commit as a call-site swap, because the two are indistinguishable afterwards from adjusting the test until the new code passes.

Applying that rule strictly turns out to require moving conventions early rather than late. Two of the frozen values Phase 14 touches move for an honest reason โ€” this project adopts upstream's composition centre and upstream's mirror axis, so literals that encode (W/2, H/2) and width / 2 are simply describing a convention the project no longer holds. The tempting arrangement is to let them move with the delegation that motivates them, which is exactly the arrangement the rule forbids. The workable one is to change the convention first, in the local implementation, where the literals move alongside a one-line arithmetic edit that anyone can read, and to let the delegation that follows move nothing at all. WP-154b is that row. It buys something beyond bookkeeping: after it lands, WP-155, WP-156 and WP-157 can be held to the strict reading โ€” any tier-A, B or C value that moves inside one of them is a defect, and the row that moved it is where the defect is โ€” which is a claim the earlier structure could not have made about WP-157.

WP-154 splits along the same seam. The pin bump is not a swap and its re-freeze is not a choice: upstream rebuilt the synthetic fixtures, and the goldens they feed have zero tolerance. WP-154 carries the bump, the two loud breaks it causes, and that re-freeze, with nothing delegated; WP-154b carries the conventions and the FA-1, FA-2 and FA-3 assertions. The loud breaks are worth naming because they are the pleasant kind. tests/fixtures/synthetic.py imports animal_shapes, which upstream's restructure removed, so the pin bump fails at collection rather than at some later assertion; the replacement is tuple(AnimalShape)[:KEYPOINTS_ANIMAL_COUNT], which is what the helper did, and the other three imports from that package are untouched. The fixture drift is louder still โ€” a golden with zero tolerance cannot drift quietly. The discipline the row owes is not detection but tracing: every changed value is matched to a named upstream change before it is re-frozen, and one that cannot be matched is a regression rather than re-freeze fodder.

The letterbox gets a row because its failure is the quiet kind. Decision 6 delegates the resize and asks for the eval delta to be measured in a row of its own, which WP-155b is. The geometry matches exactly and the resampling does not, so every coordinate round-trip test stays green while image-derived metrics move on the evaluation path across six call sites. A delta that hides behind a green suite has to be measured on purpose or it is not measured at all; the row records the number whether or not it is zero, since "nothing moved" is a finding and an absent measurement is not.

Three DoD lines were added for traps that fail silently, each from ยง8.12's measurement. Upstream's instance_keep_mask defaults are 0.0 and 0.0 against this project's 2.0 and 0.1 โ€” deliberately, on the reasoning that a library should not encode a plausible-looking minimum on the caller's behalf โ€” so an omitted argument at any call site keeps every instance including ones pushed entirely off canvas, with no exception and no shape change. WP-155 now asserts both thresholds at every call site. return_matrix is None for exact-only and letterbox-only pipelines, not merely partial for split ones, so WP-156's consumer check is two conditions rather than one. And permute_keypoint_pairs moves coordinates without touching visibility flags, so WP-157 keeps the local keypoint_vis permutation instead of deleting _mirror_targets outright; dropping it would leave every mirrored sample with a visible point marked occluded and its partner marked visible, shapes identical throughout.

One claim was retired rather than moved. WP-158 said it re-freezes the tier-D determinism values against the now-upstream implementations. Tier D has no values: the module asserts same-seed-equal and different-seed-differ and nothing else, holds no literal, and appears in no golden โ€” which was the point of separating it, since a seed-dependent frozen number would break at every swap for reasons unrelated to correctness and invite precisely the re-freeze-under-pressure the guard exists to prevent. So there is nothing there to re-freeze, and the row now says so. The sentence had been carrying an implication the design had already ruled out.

And the scope prose moved out of the table. Phase 14's cells had grown to three times the register's median length โ€” around 1,500 characters where 374 is typical โ€” because each row carries a boundary that is easy to cross by accident, and stating where a large swap stops takes paragraphs rather than a clause. A roadmap table is a register, not a specification, and prose that wide stops being readable as either. The full text now lives in Phase 14 โ€” what each row does, and where its boundary is, one anchored entry per row, and each cell keeps a summary and a link. Nothing was dropped in the move; the entries are the cells' own words with room to breathe.

What this row does not do. It writes no code and moves no value. It also does not reorder the phase's dependencies beyond threading the new rows into the existing chain, and it does not revisit either decision it implements โ€” both were the principal's, and this row is their mechanical consequence.

WP-154 โ€” every delta traced, and the frozen tree could not absorb it again

R21 advances from fedde0c1 to a5a276a, and the two breaks WP-153c predicted both arrived exactly as described. The import break is the pleasant kind: tests/fixtures/synthetic.py imported animal_shapes from fuse_augmentations.data.animals, upstream's restructure removed it, and collection fails immediately rather than at some later assertion. The replacement is tuple(AnimalShape)[:KEYPOINTS_ANIMAL_COUNT], and its equivalence is read from upstream's own source at the old pin rather than assumed from the name: animal_shapes(count) was tuple(AnimalShape)[:count] with a range check, so the selection is identical and the range check is the only thing lost โ€” on a constant of 2 against a 12-member enum. The other three imports from that package all still resolve, checked one at a time rather than inferred from the module importing. AnimalShape declaration order still opens on duck and elephant, which is what makes the constant's own comment still true; had the restructure reordered the enum, the keypoints fixture would have silently changed categories with no golden anywhere to notice.

The trace is a two-commit partition, and it is bit-exact. Sixteen values moved across the two generator-derived goldens, and the discipline the row owes is matching each to a named upstream change rather than merely observing that they moved. Only two of the fifteen commits between the pins touch src/fuse_augmentations/data/ at all โ€” 964fd07 (registry, SVG assets, Fill type) and abbf1f2 (upright OBB, triangle, docs split) โ€” so the generator was installed at four commits in turn (fedde0c1, 964fd07, abbf1f2, a5a276a) and both producers run at each, the method WP-146 established. The partition came back clean. fedde0c1 reproduces all 22 stored values exactly, which is what certifies the harness and the import edit as behaviour-neutral before anything is attributed. 964fd07 moves nothing at all, despite being the commit that removed animal_shapes and rebuilt the animal family around the packaged SVG zoo โ€” a restructure that changed how the assets are stored and not what they are. Every one of the sixteen moved values moves at abbf1f2, and nothing moves after it: the entire augmentation-engine half of the diff, eleven commits of letterbox, keep-mask, rotated-box and keypoint-swap work, is fixture-neutral, as a change to the transform stack rather than the generator should be.

The values do not merely move; they return. abbf1f2 makes PrimitiveShape.TRIANGLE equilateral again, undoing the obtuse-scalene redesign 0a0cc640 introduced and WP-132 absorbed, and it re-derives polygon_to_obb in the shape's own upright frame instead of by minimum-area calipers. The strongest evidence that this is the whole cause is that all sixteen values land bit-exactly on the figures this repository itself froze before 0a0cc640 โ€” 93 detection annotations, 964 segmentation points, 66380.64 box-area sum, 44 oriented annotations, 176 oriented segmentation points, and the ten data_pipeline_metrics values including image_std_sum at 1.0084 and bbox_area_sum at 32923.115, every one of them recoverable from the goldens/frozen/0.2/ snapshot WP-132 removed. WP-132's log recorded obb_num_annotations going 44 to 61 and image_std_sum falling 35.5% when the triangle became obtuse-scalene; both walk back to the digit. A trace that lands on a number the project already wrote down is a different quality of evidence from one that merely sounds plausible, and it leaves zero untraced deltas โ€” so nothing here is a regression being frozen over.

The gate went red on four files for a reason no work in this row could fix. Every hook passes, 2534 tests pass, and 36 of 40 goldens pass. The four that fail are fixture_checksums.json and data_checksums.json under goldens/frozen/0.5/ and goldens/frozen/0.6/, and they fail for a reason no work in this row can fix: their producers render images through an external package, so once that package changes they can never satisfy "current code must still satisfy every value a past release pinned". This is not a new discovery. WP-132 hit the identical wall with the identical partition โ€” every pure-code frozen golden green, only the two generator-derived ones red โ€” took the question to the principal rather than resolving it in the row that raised it, and the decision was that those two "were never really freezable; they were a category error in what got snapshotted, not a regression". They were removed from the frozen tree and the live copies re-frozen. The row therefore stopped at the same place, under escalation trigger 4, rather than editing release history on a precedent it had not been given. The principal authorized the same remedy: the four files are removed here โ€” recoverable in history, and the live copies of both metrics were already re-frozen correctly in this row โ€” leaving 36 of 36 goldens green and the frozen-golden regression still meaning what it means for every metric it can actually test.

What is worth naming is why the wall came back. WP-132's removal was correct and did not survive, because make freeze-goldens copies whatever live goldens exist into the new release directory with no notion that two of them are generator-derived. WP-140 froze them into 0.5 and WP-153 into 0.6, each re-committing the category error the principal had already ruled on, and neither could have noticed: the freeze is a copy, and a copy of a green golden is green until the generator moves. So the remedy the principal authorized once needs to be applied to two more directories, and the mechanism needs to stop reintroducing them, so WP-154c is opened to stop the mechanism reintroducing them rather than to remove them a third time โ€” without it 0.7 freezes them again and WP-159 meets this same wall. The general shape is WP-153b's own lesson in a new place โ€” what the gate reads is safe, and what it does not read drifts silently. Here the gate does read the frozen tree; what nothing reads is the question of which metrics belong in it.

WP-154c โ€” the mechanism stops reintroducing what it was already told to remove

WP-154 closed by naming why WP-132's wall came back: make freeze-goldens was a blind cp goldens/*.json goldens/frozen/$(MINOR)/, with no notion that fixture_checksums.json and data_checksums.json are generator-derived โ€” their producers render synthetic images through fuse-augmentations, so once that package's generator moves, no future code change can satisfy "the frozen copy still holds". WP-132 removed the two files that wall produced; WP-140 and WP-153 each re-froze them anyway, because a copy of a green golden is green until the generator moves, and nothing in the copy path could tell the two categories apart. Removing the files a third time at WP-159 would only restart the same clock.

The fix is a schema field rather than a human checklist item. fixture_checksums.json and data_checksums.json now carry "freezable": false; every other live golden is freezable by omission, which keeps the six pure-code goldens' files untouched and states the rule as "false is the exception, not true". scripts/freeze_goldens.py replaces the Makefile's inline cp: it reads each live golden's freezable field and copies only the eligible ones, reporting what it skipped rather than skipping silently. Makefile's freeze-goldens target calls it. scripts/check_goldens.py's _parse_golden reads the same field, and check_golden rejects โ€” with a named error, not a silent skip โ€” any golden it finds under goldens/frozen/ whose freezable is false; this should never happen once the freeze path is fixed, but a manual copy or a retroactively-flagged old snapshot must still be caught by the gate that reads the frozen tree, not assumed away.

Two things this row does not do. It does not touch either goldens/frozen/ directory โ€” WP-154 already removed what needed removing, and this row's job is only to stop the mechanism, not to re-litigate the removal. And it does not re-freeze anything: no live golden's values changed, so goldens/fixture_checksums.json and goldens/data_checksums.json gain one field and nothing else.

WP-154b โ€” two conventions given up, and one frozen golden overridden to hold them

Two local geometric conventions move to match fuse-augmentations, both before any delegation row touches these files. AffineParams.matrix (data/affine.py) composes rotation, scale and shear about ((W-1)/2, (H-1)/2) rather than (W/2, H/2). mirror_rboxes (data/rotated_aug.py) and HorizontalFlip._mirror_targets (data/augment.py) mirror about (width-1)/2 rather than width/2. Neither file imports fuse_augmentations; this row is the convention alone, not the delegation.

Every dependent literal traced before it moved, never pasted from output. goldens/aug_invariants.json's producer was re-run with the centre reverted and nothing else; all fifteen stored values reproduced bit-exactly, certifying that the whole movement in the live re-freeze is the centre change and nothing else. Seven metrics moved โ€” affine_image_mean, affine_bbox_coord_sum, affine_polygon_area, fused_image_mean, fused_bbox_coord_sum past tolerance, mixup_image_mean and rotated_rbox_area within it but re-frozen anyway for the same traced cause. The mirror axis is not exercised by this producer (no HorizontalFlip or mirror_rboxes call inside it), and none of its metrics moved from that change, exactly as predicted. goldens/data_checksums.json ("freezable": false since WP-154c, no frozen copy) moved from both conventions at once โ€” the producer runs the whole WP-015 pipeline including the flip โ€” and a four-way attribution run (both old, centre-only, mirror-only, both new) split bbox_coord_sum's move into -242.000 from the mirror (every one of 242 drawn x-coordinates shifts by exactly one pixel โ€” a mirror is an isometry, so bbox_area_sum is untouched by it) and +13.363 from the centre, with the both-old reconstruction reproducing the stored values bit-exactly as the control.

The brief's rule for tier-A/C contract tests needed a correction, not a waiver. The expectation going in was that invariant cases (an involution is an involution under either pixel convention) need no changes and any break there is a real bug โ€” that held. What it missed: a point-anchored derived case states the old convention in its own closed-form arithmetic, so five cases in test_aug_contract.py, test_photometric.py and test_rotated_aug.py needed re-derivation, not correction-by-code-output. Each was worked by hand from the new convention โ€” a quarter turn about (3.5, 3.5) sending (1,2)โ†’(5,1) under the new centre, a mirror axis at 2.5 on a six-pixel canvas, and so on โ€” and only then checked against the running code, so a hand-derivation error would have surfaced as a test failure rather than silently demoting a tier-A derivation to a tier-B characterization. Every one matched. test_aug_frozen.py's five affected cases (test_boxes_land_on_frozen_coordinates, test_a_polygon_ring_lands_on_frozen_vertices, test_boxes_land_on_frozen_output_canvas_coordinates, test_a_sheared_rotated_box_lands_on_frozen_parameters, test_mirrored_then_warped_keypoints_land_on_frozen_coordinates) were recomputed from the code path directly, being tier B by definition. The rotated-box case's own signature confirmed the mechanism: w, h and theta held to six decimals and only the centre coordinates moved, which is exactly what a translation of the composed map should do to a shape's position and not to its extent.

One frozen golden could not be satisfied by any resolution the brief was authorized to make. goldens/frozen/0.6/aug_invariants.json โ€” a pure-code snapshot the v0.6.0 release (22739e1) wrote โ€” pins the same five metrics past their tolerance, and AGENTS.md ยง7 lists modifying a frozen golden as a standing prohibition, not one of the escalation triggers a ruling can waive case by case. This is not WP-132/WP-154's category: those two goldens were generator-derived and could never again be satisfied by any code change, a category error in what got snapshotted rather than a real guard; this one is pure local code and was a genuine guard for 0.6.0's behaviour, broken here by a deliberate, traced convention change rather than by drift. An entry went to docs/ESCALATION.md (2026-09-03) with three options โ€” remove the file on the WP-132/WP-154 precedent, update its five values in place (which ยง7 forbids outright), or re-scope the row to 0.7 and block every remaining Phase 14 row on that wait, all of which depend on this one landing. Presented with the no-rule-override alternative, the principal chose the override: the file's seven traced metrics are updated in place to match the live golden exactly, on record in docs/ESCALATION.md, rather than removed. ยง7's prohibition otherwise stands; this is one dated exception, not a precedent for the next one.

Left untouched by design: rotated_geom.py's canonicalize, every invariant-based contract case, goldens/frozen/0.5/ and every other 0.6/ file, and no fuse_augmentations import anywhere in this row โ€” the delegation itself is WP-155/156/157's work.

WP-155 and WP-155b โ€” the letterbox and the keep mask delegate, and the one pixel metric that moved

The two rows land as one commit. They were split so a delegation swap would never travel with a frozen value (ยง6.5), and the split cannot be honoured here: the swap moves a value, so WP-155 alone cannot pass make gate and WP-155b alone has nothing to measure. The choice was between a commit that mixes them and a red main between two commits; the principal took the mixed commit. What ยง6.5 buys โ€” the ability to name the row that moved a value โ€” is preserved by the measurement below rather than by the commit boundary.

What delegates. Letterbox (data/letterbox.py) drops _LetterboxGeom, _resolve_geometry and _resize_pad. The fit comes from letterbox_geometry, the forward affine from letterbox_matrix, and the image from a letterbox-only Compose.from_params(letterbox=..., allow_upscale=..., fill=..., padding_mode="zeros") built once at construction โ€” one grid_sample from the source canvas straight to the letterboxed one instead of an F.interpolate followed by an F.pad. inverse_map and forward_affine keep their signatures and their semantics, so the six evaluation and prediction call sites are untouched. The inverse stays local and closed-form (inv_r, -pad * inv_r) rather than being obtained by inverting the forward matrix numerically, because A10 requires the round trip to be exact and a numerical inversion leaves a residual in it. RandomAffine and MosaicAssembly drop their two byte-identical _keep_mask copies for upstream's instance_keep_mask at all six call sites.

The threshold trap, made loud. Upstream defaults min_size and min_visibility to 0.0 โ€” deliberately, so the package holds no opinion about a training recipe's numbers โ€” against this project's 2.0 and 0.1. Both are passed explicitly at every one of the six call sites, and TestKeepThresholdsReachUpstream in test_affine.py and test_mosaic.py covers each of the three routing sites per class with a size case, a visibility case and a control. The mosaic control states the cost of an omission exactly: at 0.0/0.0 not only the 4%-visible sliver survives but the three instances the placement clips away to nothing survive too, a zero-width box being >= 0.0 on both rules โ€” three empty instances carried down the pipeline with every shape lining up and nothing raising.

The measurement WP-155b owns. Geometry is bit-unchanged: letterbox_pad_fraction holds at 0.4, every coordinate round-trip case passes, goldens/data_checksums.json does not move, and 35 of 36 goldens pass untouched. Exactly one value moved โ€” aug_invariants' letterbox_image_mean, 0.5024 โ†’ 0.4994, against a 0.002 tolerance, so 1.5ร— tolerance and no more. That is the whole eval-path cost of the resampling swap, and it is one number rather than a class of drift because the geometry did not move with it. Upstream's opt-in antialias=True was measured rather than assumed: it recovers nothing here (still 0.4994), its Gaussian mipmap prefilter not engaging at this producer's downscale, so keeping the antialiased filter was not available short of keeping the local implementation. A32 is revised rather than contradicted โ€” non-antialiased resampling is now the whole pipeline's filter rather than the train path's deviation from the eval path, and the two paths differ only in warp count.

The frozen golden, overridden a second time. goldens/frozen/0.6/aug_invariants.json pins that same metric, so the same ยง7 wall as WP-154b, on the same file, eight commits later. The alternatives were put again โ€” remove the file on the WP-132/WP-154 precedent, or narrow the row to the keep mask and keep the local resample, running two resample implementations and deferring the delegation to 0.7 โ€” and the principal chose the override again: letterbox_image_mean updated in place to 0.4994 in both the live and the frozen copy. docs/ESCALATION.md (2026-09-03, second entry) carries it. That this is the second override of the same file in one session is the reason it is recorded separately rather than folded into the first: two dated exceptions, still not a standing waiver, and the next one is a fresh decision rather than a precedent already set.

WP-156 โ€” the warp delegates, the decomposition does not

The row was scoped as "the affine reduces to ranges over one fuse segment", and the obvious reading of that โ€” hand R1 Table S3's ranges to Compose.from_params and let upstream sample and compose them โ€” is wrong, for a reason nothing in Phase 14 had measured.

A third convention, found by building against it. AffineParams.matrix composes one combined shear [[1, tan sx], [tan sy, 1]] before rotate-and-scale; upstream's _DirectParamAdapter composes two sequential shears T @ Sy @ Sx @ S @ R after it. Two independent causes inside the shear โ€” Sy @ Sx differs from the combined form in the yy entry by aยทb, and shear-before-rotate does not commute with shear-after-rotate. Measured max-abs matrix delta on a 32ร—48 canvas: 1.1e-06 at zero shear (float32 noise), 0.1498 for shear_x and shear_y together, 0.9716 for rotation plus shear_x, and 0.4984 for the goldens' own COMPOSITE (angle 0.35, shear 0.12/-0.08, scale 1.15). WP-153b and WP-154b measured and moved exactly two conventions, the composition centre and the mirror axis; this is a third, and it was never on the list.

Production would not have noticed: datamodule.py builds the affine with scale and translate only, leaving degrees and shear at 0.0, where the two agree to 1e-6. The goldens would have โ€” affine_bbox_coord_sum, affine_polygon_area, fused_bbox_coord_sum and rotated_rbox_area all move, with test_aug_frozen.py's tier-B literals behind them. ยง6.5 calls a tier-A/B/C value moving inside a delegation row a defect that names its own row, so the parameter route was not available without first opening a WP-154b-style convention row and a third frozen-golden override.

The seam that makes the convention question moot. Compose.from_params is a convenience over a lower layer that takes an adapter: build_segments(transforms, adapter, ...), and TransformAdapter is a public @runtime_checkable Protocol whose build_matrix(transform, params, height, width) -> (B, 3, 3) is exactly the seam a caller with its own parameterisation needs. _DirectParamAdapter's decomposition is one implementation's choice rather than the package's convention, which is why the package publishes the Protocol at all. _StatedGeometryAdapter is this project's implementation: build_matrix answers with AffineParams.matrix unchanged, so the matrix reaching the pixels is the matrix reaching the boxes, the decomposition never leaves this project, and nothing needs harmonising. The warp, the fusion, the inversion, the sampling grid and the constant fill are upstream's.

What the tier-C invariant became. The scope specified not None AND single segment, read off return_matrix. return_matrix turns out to be unreachable from a hand-built segment list โ€” it is a Compose-level flag and FusedCompose.__init__ rejects non-backend transforms โ€” so the composed matrix is build_matrix's own output and the condition is restated in the terms this route can actually check: exactly one segment, and that segment a FusedAffineSegment. An exact or crop-resize segment carries no composed affine, which is the same case upstream reports None for, so the two readings agree on what they forbid. RandomAffine._segment raises on anything else and test_aug_contract.py asserts both shapes, plus protocol conformance and a fused-versus-two-step coordinate equality that pins the composition order rather than only the result.

Two smaller things worth the record. Upstream's segment draws a per-sample activation gate for every transform it holds even at prob = 1.0, where the draw is made and discarded; that draw goes to a deliberately anonymous, never-seeded RandomAffine._gate_stream, because taking it from the caller's generator would shift the caller's sequence โ€” the property WP-079 was opened by losing โ€” and taking it from the global stream would touch every other transform. And class X(TransformAdapter) fails mypy strict against an untyped package, so conformance is structural plus a runtime isinstance assertion rather than a # type: ignore.

Nothing moved, and that is the evidence. 36/36 goldens pass with the producers re-run and diffed at zero tolerance: aug_invariants 15/15 and data_checksums 10/10 bit-identical, git diff -- goldens/ empty. data_checksums runs the whole training pipeline under a fixed seed, so its ten bit-identical values are also the determinism proof โ€” the caller's generator consuming the same draws in the same order as before the swap. Deleted: FusedAffineLetterbox, _warp_image, _theta_from_pixel_matrix, _transform_box_corners (upstream's transform_bbox_xyxy now), and warp_to/apply_to, which served only the fused class and take an arbitrary post-matrix the segment API does not express. A70's cell in docs/ASSUMPTIONS.md named both FusedAffineLetterbox and _keep_mask as the mechanism manufacturing its case; both are gone and the case is not, so the cell now says so rather than naming what no longer exists.

WP-157 โ€” a convention passed in, and the one test that can see it

Four functions in rotated_aug.py become adapters โ€” mirror_rboxes, shift_rboxes, warp_rboxes and rbox_envelopes over upstream's mirror_rboxes, shift_rboxes, transform_rboxes and rbox_envelopes โ€” and two bodies stay: clip_rboxes_to_canvas with its per-instance helper, because clipping a rotated box yields a polygon and upstream declines the operation by design, and check_rotated_pairing, which validates a local Targets invariant and was never geometry. rotated_geom.py is untouched. rbox_envelopes was not on the scope's list of three; it delegates bit-identically and the row's principle is "shrink to what upstream declines to own", so it went with them.

The callback is the whole row, and only one test can tell whether it was passed. Upstream imposes no angle convention and its canonicalize argument defaults to None, returning the box exactly as the arithmetic left it. Its mirror returns pi - theta where WP-058 returned -theta โ€” the same rectangle, since the two differ by the half turn a rectangle is invariant under. Passing this project's canonicalizer collapses them, and the residual is the rounding of the wrap alone: measured max-abs 2.98e-07 on the angle column across 512 swept boxes with centre and extents bit-identical, against the 1e-4 test_aug_frozen.py's GEOM compares geometric expectations at. Omitting the callback is the dangerous case and it is invisible to almost everything: on the same sweep the un-canonicalized angles run to 3.9195, well past 3*pi/4, while the corner sets the two forms produce are bit-identical. No corner, coordinate, envelope or IoU assertion can distinguish them โ€” TestCanonicalOutput's range assertions are the entire safety net for this row, which is worth stating because the natural instinct at a delegation is to trust the geometry tests.

The fit moved from float32 to float64, and the evidence that this is harmless is not a coordinate tolerance. The corner round trip now runs in the matrix's dtype, float64 throughout this package, where it previously narrowed to the box dtype first: max-abs 1.91e-06 under a similarity and 9.54e-07 under shear. The golden that bounds it directly, rotated_rbox_area at 96.1934, has a tolerance of 0.5 โ€” wide enough to have hidden a flipped keep decision. What actually rules that out is the pair of zero-tolerance counts, affine_kept_instances 2.0 and mosaic_kept_instances 8.0, matching exactly. The narrowing back to the box dtype happens inside the callback rather than after it, because a canonical float64 angle sitting a hair inside the half-open [-pi/4, 3*pi/4) can round out of that range on the way to float32, and the range is a postcondition the assigner and the OBB head read in the dtype Targets actually carries.

The keypoint table is a shape conversion this project owns. A dataset publishes the pairs it wants swapped (A64 keeps that the public surface, so K stays generic); permute_keypoint_pairs takes a full-length (K,) index in which slot i takes its value from slot index[i], identity slots included. Applying the pairs in list order to range(K) reproduces the sequential per-pair swap this transform used to perform, overlapping pairs included โ€” [(0, 1), (1, 2)] composes to the same three-cycle either way. The bounds check and its error string stay on this side, since a bad pair is a caller error whichever side permutes. Upstream moves coordinates and says so, so the matching keypoint_vis permutation stays local; WP-153c's ยง8.12 measurement already named the trap, and the case that pins it โ€” test_mirrored_visibility_flags_follow_their_own_points โ€” asserts the pairing of each mirrored point with its own flag rather than the two columns separately, with an unpaired third slot to pin that an identity slot neither moves nor loses its flag. Without it a mirrored sample carries a visible point marked occluded and its partner marked visible, at identical shapes with every coordinate assertion passing.

One device hazard, closed because no test here can catch it. The flip index is built on the CPU from a Python list. Upstream moves it to the keypoint device itself before gathering; the local visibility gather now does the same explicitly, since the loop it replaced was device-agnostic for free and a CPU index reaching index_select on an MPS tensor raises. The gate runs on the CPU, so nothing in it would have failed.

No golden moved: 36/36 with git status goldens/ empty. Deleted with the bodies they served: _POINT_DIM, _QUAD_CORNERS, and the apply_affine_to_points import.

WP-158 โ€” what "reduced to what survives" was measured as

The scope asked for the export list to be "reduced to what survives, so the public surface states what this project still implements", and read literally that instruction does nothing: exactly one name did not survive Phase 14, FusedAffineLetterbox, and WP-156 already removed it. So the row's first job was to choose what the phrase is measured as, and to say so rather than leave the choice implicit in a diff.

The measure taken. A name stays if some consumer reaches for it through the package โ€” from lucid_yolo.data import X from outside the module that defines it โ€” or if living-doc prose names it. Measured by AST walk over src/, tests/ and scripts/, 30 of 54 names have a package-level importer and 24 have none, and every one of those 24 is consumed through its defining submodule instead (from lucid_yolo.data.coco import CocoDetectionDataset, and so on). There is no runtime attribute access on the package anywhere; src/ and scripts/ never import from the package at all, only tests/ do. The living-doc prong rescued six the import census alone would have dropped, build_scale_policy most pointedly โ€” ADR-005 names it as a thing lucid-yolo owns, which is the sentence the whole row is trying to make the surface say. The list goes 54 to 37, and every submodule still declares its own __all__, so nothing published became unpublished: the package list is an index, and the module list is the declaration. The docstring now says that, so the next reader does not re-add names out of habit.

Two readings rejected, both defensible. The stricter one observes that every doc naming a rescued symbol cites its submodule path alongside it and never the package surface, so no document rescues anything and the list lands at 30. The other runs on ownership rather than use โ€” publish what this project still implements, drop the four rotated transports that are now adapters โ€” and lands near 45. It was rejected on its own example: warp_rboxes is not a thin adapter, it carries this row's narrow-inside-the-callback decision, so "drop what upstream owns" splits the transport family on a judgement no firmer than "who imports it", and it would publish zero-consumer names on ownership grounds alone. Use is the measure, and the docstring states that membership tracks use rather than ownership so the list is not misread as the boundary.

One name added back against the measure. HSVJitter has no package-level importer and the census removed it, which left six of the pipeline's seven transforms published and one missing โ€” while the docstring introducing the list named the transforms as a group. An index whose description contradicts its contents teaches worse than an index with one arguable member, so the group is published whole. That is the single place in this row where the stated measure was overridden, and it is recorded rather than absorbed.

What each document needed, including the ones that needed nothing. A10 was a bare "Letterbox, aspect-preserving" and now records what delegated and what did not โ€” the inverse stays closed-form because A10's own round trip has to be exact and a numerical inversion leaves a residual in it โ€” closing on the boundary's actual content: pixels moved, geometry did not. A26 stops describing a test-only dependency: one pin now moves fixtures and training-time behaviour together, and what keeps them apart is freezable: false on the two generator-derived goldens rather than care. docs/PROVENANCE.md's R21 cell gains the second role in acquisition order, with the clean-room line restated โ€” a permissive general-purpose package admitted as a dependency, never as a source to read a method from โ€” and its per-commit fixture trace untouched. A21 needed nothing, evidenced: data/tiling.py is not in Phase 14's diff at all. The four model cards needed nothing, evidenced three ways: they are dated records of runs trained before the swap and retro-editing their procedure tables would falsify how those checkpoints were produced; "fused affine+letterbox" is still a true recipe description, since WP-156 preserved the fusion rather than dissolving it; and they contain zero references to any symbol Phase 14 deleted.

A11 was left alone, and why is worth recording. The scope paragraph calls A11 "the half-open rasterisation rule". A11's own row is anchor centre placement, (i+0.5)*stride; the half-open pixel-centre membership rule is cited as A11 from A37(iii). So the register carries one id for two rules, which the scope inherited. Nothing in A11 concerns the augmentation engine either way, so the row's outcome is no change โ€” but the inconsistency is named here rather than silently reinterpreted, since the next reader to follow that citation will hit it too.

Nothing was left to re-freeze, which is the outcome the three delegation rows predicted: 36/36 with git status goldens/ empty. WP-155 moved letterbox_image_mean and re-froze it inside its own row; WP-156 and WP-157 moved nothing. Tiers A through D are untouched, tier D having no literal and no golden to re-freeze by design. README.md gained the decision it was missing โ€” ADR-005/D19 removed a whole subsystem and was not among the six shaping choices the README enumerates, now seven โ€” and one stale count was corrected in passing, 63 numbered assumptions against the register's actual 73. Its Current release: 0.4.0 is two releases behind __version__ and is left for the release row, which is what moves it.

WP-159 โ€” the release that cannot be published, and why that was the better half

__version__ moves 0.6.0 to 0.7.0, which is the whole of the version change โ€” pyproject.toml declares the version dynamic and reads the module attribute. [Unreleased] closes as [0.7.0]. make freeze-goldens MINOR=0.7 copies seven live metric sets into goldens/frozen/0.7/, the same seven 0.6 holds since WP-154c taught the mechanism to skip the two generator-derived goldens, and 43 goldens pass where 36 did before the freeze.

The fourth clause could not be executed as written, and finding that out was the row's real work. ADR-005 gates promoting fuse-augmentations to a runtime dependency on four conditions, the first being real SemVer releases to PyPI. Measured against PyPI's own API rather than recalled: the latest fuse-augmentations there is 0.10.0.dev0, there is no 0.11 or 0.12 at all, and all eleven releases are .dev0 pre-releases. The floor this phase stands on is a5a276a. Half of that was already written down in pyproject.toml before the row opened โ€” PyPI forbids re-uploading the 0.10.0.dev0 version string โ€” which is worth noting on its own: the blocker was legible in the tree for five work packages and nothing surfaced it, because scripts/release_guard.py checks the tag, the changelog section and the gate, and has no notion of dependency tiers.

What made refusal the worse option is that ADR-005's own fallback expired. The ADR says a refusal "returns the dependency story to a SHA pin, which keeps the augmentation stack local rather than making it unpublishable". That sentence was true when it was written and is not true now: data/affine.py, data/augment.py, data/letterbox.py, data/mosaic.py and data/rotated_aug.py all import fuse_augmentations at module level, so lucid_yolo.data โ€” and predict, through letterbox โ€” cannot import without it. The stack is not local any more, so refusing promotion returns nothing; it leaves the package in the dev group, where every install omitting that group yields a distribution that fails at import. The choice was never "publishable or not". It was "unpublishable, or broken for anyone who installs it without a development group", and those are not symmetrical.

Condition 1 is therefore recorded as knowingly failed rather than quietly satisfied. The pin moves into [project].dependencies as a direct reference, which is legal to build and install and illegal to upload, so the distribution is unpublishable to PyPI. That costs nothing this project currently does: release.yml states in its own header that publication is an unmade [PRINCIPAL] decision and deliberately omits it, so there is no upload for the rejection to break. Conditions 2, 3 and 4 are met and each was checked rather than asserted โ€” the exact SHA is a stricter ceiling than the range condition 2 asks for; the promotion landed in its own work package whose licence audit was run and now places fuse-augmentations in the republished base tier beside hotcoco, clean at 107 distributions and 344 shipped binaries; and D20 with the docs/ESCALATION.md entry is the record condition 4 asks for. The principal was shown three alternatives โ€” hold the release for PyPI, split the promotion into a later row, or stop โ€” and chose this one. The remedy is small and dated: the day a 0.12 reaches PyPI, a >=0.12,<0.13 range replaces the SHA and nothing else here changes.

What this release does not claim. No tag is cut and no distribution is published, as at 0.4.0, 0.5.0 and 0.6.0 โ€” four release commits now stand ahead of the train's own tags. The contributor-admission rows WP-141 through WP-144 are still unstarted, WP-141 being a principal row whose three preconditions sit outside the roadmap, so Phase 14 ships with the same four-row tail Phase 13 did. A68, A69 and A70 remain open. No trained weights ship (D14). And the phase's subject is a boundary rather than a capability: nothing in 0.7.0 detects anything 0.6.0 could not, and the case for it is that the augmentation engine is now one implementation maintained in one place, with 43 goldens and a four-tier guard standing where the second copy used to be.

WP-160 โ€” the guard checked everything except what a release is

scripts/release_guard.py validated the tag string, the changelog section and the gate. All three are about the repository. None is about the artifact: what a consumer installing the distribution actually receives. WP-159 found that by walking into it โ€” five modules under data/ import fuse_augmentations, its requirement sat in the dev dependency group, and a wheel installed without that group raises ImportError from lucid_yolo.data. The fourth check closes that class.

Why no test could have caught it. The development environment installs every dependency group, so every import resolves and the suite is green by construction. The defect is only visible from outside that environment, which is the definition of a release-time property rather than a test-time one โ€” and it is why the check belongs in the guard even though it is cheap enough to run anywhere. It also sat legible in pyproject.toml for five work packages: the comment beside the pin already said PyPI forbade re-uploading 0.10.0.dev0, one clause short of the conclusion.

What it does. Walk the shipped package for absolute imports, drop the standard library and the package itself, resolve each remaining top-level module to its distribution through importlib.metadata.packages_distributions, and refuse the tag when a distribution is missing from [project].dependencies. The refusal names the group that does declare it, so it diagnoses rather than complains. Two smaller decisions are worth stating: the walk is over every Import node rather than the module preamble, because an import inside a function is one the installed package can still execute and merely fails later; and a module resolving to no installed distribution is reported rather than skipped, because a guard that reads "unknown" as "fine" is the failure mode this check exists to close.

What it deliberately does not check. Whether the runtime requirements are uploadable to PyPI. [project].dependencies carries a direct reference by decision โ€” D20 accepted the unpublishable distribution two commits ago with its consequence recorded โ€” so a check refusing it would re-litigate a decision rather than protect one. The gap that leaves is real and stated here rather than hidden: a second direct reference could be added later without any gate objecting. The tier check protects something unconditional instead, since a distribution that cannot import is broken however it was obtained.

Evidence it is load-bearing. The group-only case is asserted against a reconstruction of the pre-WP-159 tier layout, where it fails and names dependency-group 'dev'; against the tree as it stands it passes, reporting all twelve third-party top-level imports declared at runtime. Six cases were added โ€” group-only, runtime-declared, unresolvable, stdlib-and-relative, conditional-import, and one driving the CLI so the check is wired rather than merely importable.

One thing worth recording for whoever edits this file next: ruff's autofix removed the four new imports on the edit that added them, because at that instant nothing referenced them yet. The same trap caught WP-157.

WP-141 โ€” the guide is written; the condition it waits on is not this row's

docs/CONTRIBUTING.md, three issue templates, and the sentence in AGENTS.md that D18 was always going to flip. The file sits under docs/ rather than at the repository root deliberately: docs/ is one of the three locations GitHub resolves CONTRIBUTING.md from, and it is the only one that is also inside the mkdocs tree, so a single file satisfies the platform and the docs-site audit at once instead of a root copy drifting from a nav copy.

What the row could not do, and did not pretend to. D18's relaxation is conditional on two things: the reproduction report carrying all four accepted tiers, and the repository being public. The first is satisfied โ€” the fourth tier was accepted at WP-125. The second is not: O3 is still blocked-on-principal in the open-items register, and its own deadline is "before the repository or any tag is public", so it is precisely the thing standing in the way. AGENTS.md used to say "Until WP-141 lands, the contract above applies in full", which names the wrong gate; it now names the repository's visibility, because that is what actually decides. The relaxation is written down and gated rather than in force, and no row here changes that.

Three layers, and the table saying what each one is not. The guide's central table is the part worth defending: DCO establishes ownership and says nothing about derivation; the licence attestation covers derivation and produces nothing reviewable; the provenance trailers produce the reviewable artifact and say nothing about the right to submit. Each column of "what it does not" is what justifies the next layer existing, and a contributor reading only the middle row would reasonably conclude that a checkbox is the whole system.

The clean-room rule is stated at its real width. WP-143 widens the pull-request template; the guide states the same thing in prose, because the template is a checklist and a checklist is a poor place to learn a rule. Ultralytics is enumerated as the instance a YOLO contributor reaches for by reflex, not as the definition, and the guide says explicitly that a contributor who has already read one should say so rather than conceal it โ€” an undisclosed derivation is the only version of this that cannot be repaired.

No unverifiable link ships. config.yml would naturally carry contact_links to the guide and the security advisory form, both absolute URLs into a repository that is not yet public, so neither could be fetched and checked before shipping. Rather than ship two links on the assumption they will resolve later, the file carries none and the pointers moved into the templates as prose. The one external link in the guide, the Developer Certificate of Origin, was fetched and its clause (a) read before being cited.

WP-142 โ€” a sign-off is a representation, not an identity proof

scripts/lint/check_dco.py and a dco job in lint.yml. Every non-merge commit in a pull request must carry a well-formed Signed-off-by: trailer naming a person and an address that reaches them. Merges are excluded because their message is generated by whoever pressed the button rather than written by the contributor, so there is no representation in it to check.

The design decision worth defending is what the check refuses to do. It does not require the sign-off address to match the commit author's, which is the first thing a stricter implementation reaches for. A DCO sign-off is a representation by the signer: co-authored work legitimately carries several, an author committing under an employer address may sign under a personal one, and a maintainer applying a contributor's patch signs their own line beside the original. Author-matching rejects all three and catches nobody willing to type a false name, so the check enforces the shape of the statement and leaves its truth where the DCO itself puts it. A test asserts the non-matching case passes, so the decision is pinned rather than merely intended.

Malformed is reported as malformed, never as missing. A second pattern matches lines that mean to be a sign-off โ€” a missing address, a bare address with no name, a domain without a dot, Signed off by, a trailer without angle brackets โ€” so the report distinguishes a one-character fix from a contributor searching for a trailer they believe they already wrote. Five near-miss shapes are parametrized, and the refusal text names the flag that fixes it.

Pull requests only, and this repository is the reason. Its own history predates the requirement โ€” running the check against main would refuse every existing commit rather than any submitted one, which is a gate that fails at everything and therefore protects nothing. Verified rather than assumed: run over HEAD~1..HEAD it refuses WP-141's own commit, which is the correct answer and exactly why the job is conditioned on pull_request.

Two smaller things. The job installs nothing, the checker being standard-library only, so layer one costs a container start rather than a dependency resolve. And the two SHAs reach the command through the environment rather than the command string: they are interpolated from event data, and a value reaching a shell directly is how that becomes an injection rather than a range.

WP-143 โ€” one instance of a rule, stated as though it were the rule

The pull-request template's clean-room line read "no denylisted source (Ultralytics repo, mirror, package copy, or docs.ultralytics.com) was consulted". Every word true, and the rule it enforces is much wider: D13's admissible set is the permissive allowlist โ€” MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC โ€” so what a contributor attests to is not having copied from anything outside it. A contributor reading the old line would reasonably conclude a GPL detector was fair game, which is not a misreading; it is what the line said.

The checklist now enumerates copyleft (AGPL, GPL, LGPL, SSPL), source-available (BSL, Elastic, PolyForm), paid or proprietary source of any kind, and any source whose licence cannot be read โ€” D17's case, where unreadable is not permissive. Ultralytics stays, moved to the end and labelled as the instance a YOLO contributor reaches for by reflex rather than as the definition. A second item asks for the sign-off WP-142 checks, so the template and the CI job agree about what a pull request owes.

The row's real deliverable is that it cannot narrow again silently. A checklist is prose a maintainer edits, and the failure mode is not a wholesale revert โ€” which is obvious โ€” but one family falling out of a rewrite. check_pull_request_attestation_covers_the_allowlist in audit_docs_present.py pins each family and both clauses, so a dropped SSPL fails the gate naming SSPL rather than passing as an attestation that still looks thorough. That the previous line survived eight months while meaning all of this is the argument for gating it rather than trusting the next edit.

Two notes on the check itself. It takes docs_dir and discards it, so it matches the signature every other check in that module carries and _CHECKS stays a plain tuple rather than growing a dispatch. And the two non-licence conditions โ€” "proprietary", "cannot be read" โ€” are matched as substrings against the lowercased text, because neither is a licence name and both are exactly the kind of clause that falls out of a rewrite unnoticed.

WP-144 โ€” the layer that produces an artifact, and the contradiction it exposed

A trailers job in lint.yml runs check_commit_trailers.py --range over a pull request's own commits. This is the only admission layer producing something reviewable rather than a promise: a contributor must name what an algorithmic change derives from, so an unnamed derivation on a loss or an assigner is visible at review instead of after release.

ci-tests.yml's exemption was right and too wide. Its comment said the trailer contract is a local pre-commit check "because a squash merge replaces the messages this workflow would have validated", which is true of the merge result and false of the submission. Checking the squashed commit validates a message nobody wrote; checking the contributor's own commits validates the ones review actually reads. The comment now says which half it covers rather than reading as a blanket exemption.

Wiring it up exposed a contradiction between the checker and the guide. WP_RE required WP: <digits>, so every commit needed a roadmap row โ€” while D18, and the guide WP-141 had just written, say a change altering no shipped behaviour, no public symbol, no golden and no documented assumption lands without one. Enforced as written, CI would have demanded a row id from exactly the contributors D18 exempts, and the first person to hit it would have been told to invent a number. WP: now also takes the literal none.

none rather than an omitted trailer, and the distinction is the point: it matches how Assumptions: states its own absence, and it makes the message say no row applies instead of leaving a reader to decide whether one was forgotten. Omission is still refused, and so is free text โ€” a test pins each. The other three trailers stay mandatory, because the derivation question does not go away because the tracking did.

The range path had no test at all before this row, in either checker: validate_message was covered and the git walk under it was not. Both now assert that an empty range is clean โ€” a pull request selecting no commit is not one that failed to cite โ€” and the trailer checker additionally validates this repository's own last ten commits, so a regression that let a real message fail surfaces here rather than on a contributor's first pull request.

With this row Phase 13's four-row tail closes. Every numbered row in the roadmap is now โœ…; what is not done is not a row โ€” the repository is still private, and O3 still stands between here and the relaxation these four layers were built for.

WP-161 โ€” the discipline was written down and the banner did not follow it

docs/PROVENANCE.md sec. 3.5 states the rule in one sentence: "YOLO26", "YOLO11", "Ultralytics" and their logos never appear in package names, module paths, class names or model identifiers, and nominative references to the paper ("the YOLO26 paper, arXiv:2606.03748") are the only usage. The README's first line said "the Ultralytics YOLO26 paper". That is not a nominative reference to a paper; it is a vendor name bound to the method, which is the one binding this project's whole thesis argues against โ€” the method is what the paper describes, and a codebase's choices are a separate thing that the shared name makes impossible to see. The register had the rule and the most-read file in the repository did not follow it.

Three mentions, two categories, and only one of them moved. The attributive one names the work: it is now "the YOLO26 paper", matching the register's own quoted form. The other two are the non-affiliation disclaimer โ€” "not affiliated with, endorsed by, or derived from Ultralytics or its codebase" and "No Ultralytics source code, configurations, or model weights were consulted or used" โ€” and they are a different instrument. A disclaimer that does not name the party it disclaims is not a disclaimer, it is a sentence about nobody. Both are also pinned as literal fragments in scripts/lint/audit_license_headers.py and required in NOTICE, so removing them from the README is a three-file change to a legal statement rather than a wording fix, and it is not what "stop naming the vendor as the method's owner" asks for.

The id became the link it already was. arXiv:2606.03748 sat as bare text in a file where every other reference resolves. It is now [arXiv:2606.03748](https://arxiv.org/abs/2606.03748), fetched and read before being written: the page returns the paper R1 cites, by the authors R1 names. The RLE id on the keypoints line is deliberately left alone โ€” linking it is a separate verification pass, and this row's subject is the paper this project reproduces.

One thing this row does not fix, recorded so the next person does not have to rediscover it: NOTICE carries the identical drift, "the Ultralytics YOLO26 paper", and unlike the README's disclaimer sentences that phrase is not an audited fragment there. It is the same one-word correction. It is left for whoever touches NOTICE next rather than widened into this row.

WP-162 โ€” what "cheap enough" was worth, measured

WP-130b moved golden-check and release-guard from stages: [manual] to [pre-commit, manual] so that pre-commit run --all-files would exercise every hook rather than most of them, and justified both with one sentence: "cheap enough off a release tag". For release-guard that is exactly right โ€” off a tag it resolves git describe, finds no tag and exits. For golden-check it was an estimate nobody checked. Measured: 34.6 s wall, 65 s CPU, on every commit, and twice per make gate โ€” once inside precommit, once through the golden target. WP-130b's own comment recorded that double invocation as an accepted trade; what it did not have was the size of it.

The duplication is the argument, not the runtime. A 34-second hook that found something the gate does not would be worth its cost. This one recomputes the same 43 goldens that make golden recomputes, and make gate โ€” precommit test golden โ€” is the merge bar every commit in this repository has to pass anyway. So the commit-stage copy could never fail where the gate passed. .pre-commit-config.yaml is the linting-and-quick-checks surface; a full metric recomputation is a gate step wearing a hook's clothes, and stages: [manual] puts it back where WP-130 first had it while keeping the standalone pre-commit run --hook-stage manual golden-check path intact.

What the stage change would have broken, and why the row is two files rather than one. lint.yml runs pre-commit run --all-files, and that run was CI's only full 43/43 sweep โ€” ci-tests.yml runs pytest, whose golden-touching suites (tests/models/test_param_flops.py, tests/assign/test_assignment_goldens.py, tests/data/test_data_goldens.py, tests/optim/test_toy_convergence.py) import the harness for specific goldens rather than sweeping all of them. Dropping the stage alone would have taken the sweep out of CI entirely and left a green pipeline saying nothing about the goldens. The harness therefore lands as its own ci-tests.yml step, in the job that runs the suite rather than back in the linting job, because recomputing model metrics is test-shaped work.

The trade WP-130b accepted is withdrawn rather than overturned: --all-files no longer exercises literally every hook, which is a real and small loss, and the thing bought back is that the gate stops paying for the same 43 goldens twice and a commit stops paying for them at all. release-guard keeps [pre-commit, manual] โ€” its half of that sentence was never in question.

WP-163 โ€” one transfer per batch, and the three candidates that did not survive measurement

This row is the surviving quarter of a survey of RF-DETR's 1.9.0..develop range (137 commits, ~40 perf-tagged, Apache-2.0, read under D13 and never copied). Most of what that survey found was either already implemented here or inapplicable; what follows is the part that measured well, and the part that measured badly, because the second is the more useful record.

What the change is. Every validation accumulator moved its ground truth to the host one image at a time: two tensors per image for detection, three for oriented detection and keypoints, and a mask stack for segmentation. Each of those transfers carries the accelerator's fixed per-transfer latency and a payload of a few kilobytes, so a batch of 16 paid that latency 32 to 48 times to move something that fits in a single transfer. _host_split concatenates along the batch axis, moves once, and slices the result back into the same per-image pieces. Values, dtypes and ordering are unchanged, so nothing the metrics read changes and no golden moves.

Measured, not assumed. On MPS, 16 images x 7 boxes: 2.823 ms to 0.210 ms, 92.6% less, median of 40 timed runs after 15 warm-ups with the device synchronised inside the timed region. On CPU the same batch goes from 0.004 ms to 0.013 ms โ€” a 9-microsecond regression, because on CPU there is no transfer to amortise and the concatenation is pure overhead. That trade is taken deliberately: the accelerator path is where validation actually runs, and 9 microseconds against 2.6 milliseconds is not a close call.

Honest scope. Per validation batch this is milliseconds. Across a 5000-image validation split at batch 16 it is roughly 0.8 s an epoch โ€” under 1% of a training epoch. The row is worth landing because it is bit-identical, strictly less work, and removes a per-image pattern that would otherwise be copied into the next accumulator; it is not worth landing on the strength of its epoch-level number, and this entry does not claim one.

Three candidates the measurement rejected.

Skipping the padding rows before the segmentation mask decode. The validation decoder hands decode_instance_masks a fixed 100 rows per image and decoded[keep] discards the padding immediately after, so filtering first is bit-identical and strictly less work โ€” and it is slower on the accelerator: CPU improves 32โ€“67% depending on survivor count, MPS regresses 40โ€“86% at 40 repeats. Boolean indexing plus the data-dependent sync costs more than decoding 100 rows of a 160x160 grid in parallel. Rejected rather than adopted device-guarded: the guard would exist to serve the offline path at the accelerator's expense, for a proxy metric measured in milliseconds. Upstream made the same call in the other direction for one of its own changes, excluding MPS explicitly.

Hoisting torchmetrics' per-annotation score reads. CocoBackend._get_coco_format hoists boxes and labels once per image but reads area[image_id][k] one annotation at a time, and it accepts scores=None, so the scores can be assigned per image afterwards. Measured 26.3% and 26.6% faster at 32 and 128 images of 100 detections. Not adopted: reaching it requires calling a private method on a private attribute (metric._coco_backend._get_coco_format), which binds this project's validation path to torchmetrics internals that carry no compatibility promise, and the epoch-level saving is around 0.16%. The measurement is recorded so the trade can be re-taken if torchmetrics ever exposes it.

The uint8 / 255 divisor form. Upstream found that CUDA evaluates a division by the Python int 255 as a multiplication by its reciprocal, landing one ULP from the host result for 126 of 256 byte values, and switched to a 0-dim tensor divisor to restore parity. This repository's consumer conversion uses the same Python-int form, so the question was live โ€” and it does not reproduce here: across all 256 byte values, both divisor forms match the host bit-for-bit on CPU and on MPS, 0 of 256 differing. A26's cross-platform divergence is not this mechanism. Recorded as a closed question rather than left as a plausible-sounding hypothesis.

WP-164 โ€” the copy that dropped nothing, and the three faults that hid it

This row lands the candidate WP-163 rejected one entry above, and the more useful half of it is why that rejection was wrong. The rejection is left standing there per this log's convention of not editing a historical paragraph to match a later correction; what follows is the correction.

What the change is. _update_val_segm decoded every one of its 100 detection rows and then wrote masks[keep]. In the deployed configuration that filter drops nothing: the validation decoder is TopKDecoder(), whose conf_threshold defaults to 0.0, and a 640-px canvas carries more anchors than the cap of 300, so no row is padding and no row is zeroed โ€” every score is a sigmoid output, and a sigmoid is never zero. The boolean index was therefore an unconditional second copy of a 100x160x160 boolean stack, 2.56 MB per image, to select all of it. Counting the live rows on cpu_detections โ€” a host copy the loop already has โ€” and slicing the decode to that count removes the copy. Where a caller does set a confidence threshold the same slice shrinks the decode itself, but that is the secondary effect, not the reason this lands.

Why the slice is sound. It rests on the survivors being a leading run, which is a property of the decoder rather than of this call site, and three separate mechanisms could have broken it: o2o_topk_with_indices ranks score-descending, pad_detections appends its zero rows after the ranked ones, and _zero_below_threshold cuts a descending column from some point on. All three are suffix operations, so score > 0 can only ever be a prefix. Nothing stated that anywhere, which would have made a future reordering of the decode break this caller silently, so test_the_scored_rows_are_a_leading_run now states it against TopKDecoder itself โ€” with k above the anchor count and a threshold set, so padding and the cut are both live in the same case.

Measured on the shape that actually runs. 16 images, 100 rows each, all live โ€” the deployed case, not a favourable one: CPU 27.68 ms to 24.44 ms (+11.7%) and MPS 11.54 ms to 9.76 ms (+15.4%), medians of five interleaved trials of 20 timed runs each, every individual trial positive on both devices. With a threshold set so only a handful of rows survive, the same change is +62% to +93%. No golden moves and no metric value changes: the rows fed to MeanAveragePrecision are the same rows in the same order.

The three faults that produced WP-163's opposite conclusion. They compounded, and each is worth naming because each is a benchmark-design error rather than a measurement accident. First, the harness's patched arm reconstructed the full 100-row tensor with a scatter (out[:, keep] = decoded) that the real call site never performs โ€” it measured a formulation nobody proposed, and that scatter is where MPS lost. Second, the proxy decoded on-device and stopped there, omitting the .cpu() transfer the real loop performs, so the thing most likely to dominate was outside the timed region. Third, MPS timings drift downward across a process's life well past five warm-ups; timing base first and patched second, once, hands the first arm the cold clock, and trial 0 of every re-run reproduces exactly that โ€” +41% falling to โˆ’5% by trial 4 on unchanged code. Interleaving arm order across trials and taking medians of trials makes the drift visible instead of letting it sign the result. The general lesson, which is the one worth keeping: benchmark the call site's own work profile, not a sketch of it, and on a lazily-executing accelerator treat a single ordered A-then-B run as unmeasured.

Unverified on CUDA. The mechanism โ€” one fewer copy of a boolean tensor, no data-dependent indexing โ€” has no reason to invert there, and this project has no CUDA device to confirm it on. Stated rather than assumed.

WP-165 โ€” a private method overridden on purpose, and the guard that makes it survivable

This row lands the candidate WP-163 measured, declined, and recorded so the trade could be re-taken. The principal re-took it. What follows is the mechanism, the honest number, and the reason the coupling is bounded rather than merely acknowledged.

What the change is. torchmetrics converts an accumulated epoch into the two COCO documents its evaluator scores. Most of that conversion is already hoisted to one call per image โ€” boxes[image_id].cpu().tolist(), the same for labels โ€” but each detection's score is read individually as scores[image_id][k].cpu().tolist(): a tensor index, a device transfer and a Python conversion per annotation, against two per image for everything else. At this project's validation shape that is 100 such chains per image where two would do. The parameter is already optional upstream โ€” scores=None builds the annotations without a score key โ€” so _HoistedScoreBackend passes None, lets super() build the document, and attaches the scores from one tolist() per image afterwards.

The number, at the call site rather than the function. MeanAveragePrecision.compute() over 320 images of 100 detections: 187.7 ms to 169.5 ms, 9.7% less, medians of five interleaved trials, every individual trial faster. The conversion in isolation is ~26% faster, which is the figure WP-163 recorded; the 9.7% is the one that belongs in a decision, because compute also runs the evaluation the conversion feeds and that part is unchanged. Per epoch this is a fraction of a percent, as WP-163 said when declining it. That has not changed and this entry does not claim otherwise โ€” what changed is who is deciding whether the coupling is worth it.

Why the coupling is bounded. _get_coco_format is private and carries no compatibility promise, and the failure mode of overriding one is worse than a break: upstream changes what it produces, the override keeps being called, and a validation metric moves with nothing raising. Two things bound that. The override delegates rather than reimplements โ€” super() builds the document and this writes exactly one field, so any field upstream adds, removes or reshapes arrives unchanged, and a new keyword argument reaches upstream through **kwargs instead of hitting a signature that refuses it. And tests/ptl/test_coco_backend.py asserts document equality against the stock backend on whatever version is installed, so drift that the delegation cannot absorb turns the gate red rather than moving a number. That test is the load-bearing artifact of this row; the speed is the easy part.

The one place the two documents could have diverged. Upstream skips an image entirely when its mask list is empty and the document carries no boxes โ€” which is exactly the segmentation path here, where an image whose detections were all filtered contributes nothing. An override that flattened every image's scores in order would then hand the third image's scores to the second image's annotations: plausible masks carrying each other's confidences, which no metric value reveals. The override reproduces that condition, strict=True on the zip makes any residual disagreement raise rather than misalign, and the mask test puts the empty image in the middle of the batch, where an off-by-one cannot accidentally line up.

Scope of the private surface. One attribute assignment, in one factory: build_mean_average_precision constructs the stock metric and replaces its _coco_backend. Both metrics the module builds go through it, so the reach into torchmetrics internals is one line in one place rather than a pattern.

WP-165b โ€” a phase opened from an adversarial read, and what the grouping is for

Phase 14 ended at its own release and six rows kept landing after it, belonging to no phase. They open Phase 15 rather than trailing a closed one, and the header that read WP-154โ€ฆ165 now reads WP-154โ€ฆ159.

What the audit was. Seven readers over separate dimensions โ€” numerics, data, models, eval, gate, docs, provenance โ€” plus one external reviewer over all of it, each working from the tree at 3ba7880 and writing independently. After cross-source de-duplication (roughly 30 findings arrived from two or three readers at once, which is the signal that a finding is about the code rather than about a reader's angle) 125 distinct gaps remain, alongside 26 boundaries that are correct but under-stated. The audit file itself is not committed: it lives under .plans/, which is gitignored, and the roadmap cells carry its per-finding ids so a row can be traced back to it.

Why the rows group by file surface rather than by finding. The obvious grouping is one row per reader, or one row per severity. Both produce rows that touch the whole tree, and a row that touches the whole tree cannot be reverted without reverting everything else that happened to ride along. Grouping by the surface each finding touches means a row is one revertable change to one part of the tree, and the price โ€” a critical and a low-severity item landing in the same commit because they live in the same module โ€” is worth paying. WP-166 is the clearest case: two critical findings and five consistency items, all in eval/coco_eval.py, one commit.

What the clean-room read found. Nothing. Three readers hunted contamination independently: no denylisted source is referenced anywhere, the licence audit is clean over 107 distributions and 344 shipped binaries, and the naming discipline holds. Every one of the 125 findings is about machinery certifying less than the prose beside it claims โ€” which is a different problem, and the one this phase is about.

Two rows do not open with a commit. WP-169 and WP-172 each move a value pinned in goldens/frozen/0.2 through 0.7. Under sec. 7 that is a standing prohibition and under sec. 4 an escalation trigger, and the two overrides already on record (WP-154b, WP-155) were both written down as dated exceptions rather than precedent. Each of those rows opens with its own docs/ESCALATION.md entry and a ruling. Three further rows need an accelerator and cannot go green offline.

WP-166 โ€” the scorer that shipped by default was reporting a different protocol

lucid-eval picks its engine with eval_backend="auto", and auto resolves to hotcoco wherever it is usable, which is everywhere this project runs. That engine's ground-truth records were built by _accumulate_boxes, which hardcoded "iscrowd": 0 and recomputed "area" as box width times height. annotations.py has parsed both fields off the COCO file since it was written and hands them over in the target dict, and the identical dicts reach both scorers โ€” so the default engine implemented neither COCO's crowd-ignore rule nor its small/medium/large partition, while the faster_coco_eval path fed the same targets implemented both.

The size of it. On a crowd case โ€” one normal ground truth, one iscrowd=1 ground truth, one detection sitting inside the crowd region โ€” hotcoco reported map 0.5050 against faster_coco_eval's 1.0000. On an area case whose annotation area is 200 under a box measuring 40000, the instance landed in the large bucket instead of the small one. On a real split this is systematic rather than marginal: instances_val2017.json carries roughly 1.6% crowd annotations, whose detections became false positives and whose instances became false negatives, and its area field is the segmentation area, always at or below the box area, so objects shifted smallโ†’mediumโ†’large and the three size statistics described a different partition of the split than COCO's.

The defect was this project's, not the library's. Driving _hotcoco_stats with hand-built records that do carry the fields returns COCO's answer exactly. hotcoco honours both; _accumulate_boxes never gave them to it.

Why the gate could not see it. Every fixture TestHotcocoParity fed to both scorers omitted iscrowd and area entirely, so torchmetrics defaulted area to the box area and crowd to zero โ€” exactly what the hotcoco path hardcoded โ€” and the two engines agreed by construction on the only two fields where they could differ. The CHANGELOG's "verified bit-for-bit against the existing engine" was true of a fixture family that could not express the divergence. That is the shape worth remembering: a parity test proves two implementations agree, not that either is right, and it proves nothing at all about a field no fixture carries.

The mask half was measured, not assumed. The class docstring claims the two engines differ in how they swap box area for mask area, which would have made the same fix wrong on the segm side. A probe whose annotation area (200), mask area (2025) and box area (40000) name three different buckets scored small on both halves โ€” so torchmetrics reads the supplied area there too, and the annotation's value is correct on both paths.

What travelled with it. Four consistency items in the same module, each the same shape as the main defect โ€” a number that depends on which library computed it. _MAX_DETS now reaches both engines instead of hotcoco alone; the keypoint detection cap and area partition are pinned beside the sigmas that were pinned for exactly that reason; the sigma comment's claim to equal the library default "exactly" was false at 1.39e-17 and now states the measured figure; and the OKS category table is built from ground truth alone, as COCO fixes it, rather than from the union with prediction labels.

One narrowing worth its own line. _redirect_native_output blinded file descriptors 1 and 2 for the whole evaluator call, discarding native warnings about exactly the malformed records this scorer builds. Running each phase with both descriptors captured separately showed every phase but summarize writing zero bytes โ€” including a run whose ground truth carried a negative-width box, a negative area and an out-of-range iscrowd. Blinding the rest bought no silence and cost the one channel a malformed record would be reported on.

WP-168 โ€” five gates that asserted less than their own prose

Each of these was written down as a check and was not one. The row's claim is narrow and demonstrable: after it, each gate asserts the thing its name says.

CI ran a different suite than the gate. The workflow step called pytest inline without --doctest-modules and without the src scripts paths, so testpaths = ["tests"] applied and CI collected 1548 tests where the Makefile collects 2668. Every test whose subject is a scripts/ module โ€” the ten lint checkers, the golden harness, the release guard, the freeze โ€” plus some 879 doctests ran on the developer's machine and never remotely. A pull request breaking a lint checker's own logic was green in CI. The step now runs make test.

That target then swallowed pytest's exit 5 and reported it as a pass, a scaffold from before WP-002 when there was nothing to collect. Harmless while only a human ran it; not harmless once CI depends on it, because a job collecting nothing would go green. Removed in the same row that made it reachable โ€” which is the general rule worth stating: a change that puts weight on an existing shortcut owns that shortcut.

Nothing verified a frozen golden was unmodified. check_goldens recomputes each file's producer and compares it against that same file's own stored values and tolerances โ€” the module docstring states this as the design. So an edit that moves the values and the tolerances together is green by construction. Demonstrated on a copy of goldens/ outside the tree: frozen/0.5/optim_toy.json with steps_to_threshold_musgd moved 47.0 โ†’ 52.0 and its tolerance widened 15.0 โ†’ 100.0 reports 43/43 goldens passed. This is the rule two principal overrides were granted against, so the project had already exercised the decision path that the mechanism did not back.

goldens/frozen/MANIFEST.sha256 pins all 34 frozen files by digest, and audit_frozen_manifest.py asserts it. It is .sha256 rather than .json because discover_goldens globs the frozen tree for every .json file and would hand a .json manifest to the golden parser as a malformed golden. freeze_goldens.py is the only writer, with two modes because a release and a sanctioned move are different acts: freeze() seals only the rows for the files it just copied and preserves the rest verbatim, so a snapshot tampered with under an earlier minor cannot be re-blessed by the next release, while --reseal recomputes every row and is what records a move that escalation trigger 4 has already approved.

What the manifest narrows rather than closes. It is committed beside the files it covers, so a commit that edits a golden and reseals in the same breath still passes. What it buys is that declaring a move becomes a separate named act that shows up in a diff as a wholesale manifest rewrite, instead of something a values-and-tolerances edit does silently. Signing would close it; there is no key here. The hook runs on every commit rather than behind a files: filter, for the same reason license-audit does โ€” it reads tree state, not the diff โ€” and specifically because pre-commit's staged-path filter omits deletions, so a commit that only deletes a frozen golden would match no filter and never reach the one check that notices it is gone.

The release guard did not check the version it would ship. It checked the tag, the changelog section, the dependency tiers and the gate. Tagging v0.8.0 with a matching changelog section, __version__ still 0.7.0 and no goldens/frozen/0.8 passed every one โ€” a wheel carrying 0.7.0 under a 0.8.0 tag, and a release freezing nothing while the CHANGELOG says it did. check_version parses __version__ with ast and never imports the package, because a guard deciding whether a distribution is shippable must not require that distribution to import first.

The licence audit recognised one family. COPYLEFT_PATTERN matched the GPL family against a policy (D13, D18d) that bans source-available, research-only, non-commercial and commercially licensed dependencies by name, and a contributor checkbox that asks a human to catch exactly those. A second tier-blind pattern now covers SSPL, BUSL, Elastic, PolyForm, Prosperity, Commons Clause and the non-commercial family โ€” second rather than folded in, because BUSL is not copyleft at all and filing it under that name would make the finding misdescribe what it found. The docstring now names which families the patterns recognise and says plainly which ones still pass.

A ratchet with 47 rows of slack is not a ratchet. _ASSUMPTION_FLOOR sat at 26 against 73 rows and tested max(ids), so 47 assumptions could be deleted from the tail before it fired, and a middle row could be deleted without it ever firing. _WP_FLOOR tests len(ids) and sits at its exact live count; the assumption floor was the one left behind, and _DECISION_FLOOR had the same predicate with one row of slack. Both are now len at their live counts, and the rule that adding a row raises the floor is written in the ASSUMPTIONS.md header, which is the text somebody adding a row actually reads.

WP-174 โ€” three shipped surfaces that a callable(...) Example stood in for

predict_keypoints had zero executed lines in the gate. pose_eval.py, the keypoint tier's acceptance scorer, sat at 37%. _overlay_ema was unexecuted entirely. All three carried a docstring Example of the form >>> callable(run), which satisfies --doctest-modules and the repository's own doctest-coverage audit while asserting nothing a broken implementation would fail. That is the pattern worth naming: a required Example is a coverage obligation, and an Example that only asserts the symbol exists discharges the obligation without discharging the purpose. detect_eval.py and rotated_eval.py carry the identical Example and sit at 97% and 96%, so the pattern only bites where nothing else happens to reach the code.

After: predict.py 76% โ†’ 99%, pose_eval.py 37% โ†’ 100%, checkpoint.py 47% โ†’ 94%. 31 new test items.

The keypoint expectations are hand-computed, not recorded. On the 64ร—128 planted canvas the letterbox scale is min(64/64, 64/128) = 0.5, content is 32ร—64, pad_top is 16 and pad_left is 0, so the inverse is x_orig = 2ยทx_canvas and y_orig = 2ยท(y_canvas โˆ’ 16). Those literals are in the test with the arithmetic beside them. A test that asserts whatever the code returned would have passed against a broken inverse just as well.

The fixture is built to fail in the interesting ways. The one-to-one and dense branches carry disjoint point sets, so a path reading the other branch's stem returns a plausible pose of the wrong object beside a correct box โ€” the failure the source docstring warns about, and one no shape assertion can see. A decoy set sits on anchor 0, which is never the selected anchor, while every other anchor carries zero offsets decoding to its own centre; a fixed-row gather therefore returns the decoy or a centre rather than the plant. That case is self-guarding: if the decoy anchor ever coincided with the selected one, the decoy would overwrite the plant and the primary assertion fails rather than passing vacuously. K is 3 throughout rather than 17, so a function that assumed the human schema fails.

One brief corrected against the data. The row asked for the K != 17 refusal "at minimum"; that branch was already covered at K=7, which the audit's own coverage line implies โ€” missing 130-175 means 117-129 runs. The real gap was the scoring body. Both landed: the scoring body is now executed, and the refusal gains K=16, the value adjacent to the boundary where an off-by-one or a table padded by one joint lets a foreign schema through. The pose split is hand-built in the layout run resolves, which is what pins the annotation file name โ€” person_keypoints_ rather than instances_, the substitution that divides a person detector's AP by eighty.

_overlay_ema was the one place the tests found real defects. Three, all in how the EMA claim was established rather than in arithmetic. The callback's key was resolved by substring in one place and by literal name in another, so a checkpoint written by a parametrized callback โ€” whose state_key carries its arguments โ€” passed the substring match, was overlaid, and then died on a bare KeyError after the module had been mutated. Two matching entries were resolved by iteration order, the last silently winning. And the copy walked the shadow's keys rather than the module's, so any module tensor absent from the shadow kept its raw value with nothing said โ€” a run reporting a mixture of EMA and raw weights under the EMA name, which is neither of the two claims a report can make.

The key is now resolved once and handed back so the shadow and the update counter come from the same entry; more than one match raises; and coverage is checked before any copy rather than after. Validating after would have reproduced the defect's own shape โ€” naming the problem while leaving the module in the state the message declares invalid. Validating first means no half-overlaid module ever exists, and the test asserts that stronger contract by snapshotting every float tensor and proving none moved after the refusal.

What the row did not find is worth recording too. predict_keypoints and pose_eval.run are correct as written โ€” every hand-computed coordinate matched on the first functional run. Their gap was evidentiary, not behavioural. That is the honest result for two of the three surfaces, and it is why the row is test(eval) rather than fix.

WP-171 โ€” nine entry points that checked a type and not a domain

One shape, nine instances: a value outside its domain reached arithmetic or dispatch instead of validation, and produced a plausible answer rather than an error. --limit -5 scored every image except the last five while the banner printed the truncated count as though it were the request. --batch_size 0 raised from a progress-bar total. A misspelled decoder took the one-to-many path and answered with another branch's boxes โ€” on the oriented path, another branch's headings. img_size=641 crashed inside the neck, because the upsampled P5 width is 42 while P4's is 41. An unknown task was scored as detection. --device cuda on a machine without CUDA was accepted, loaded the checkpoint, parsed the annotations, and failed minutes later inside the first .to(device) with a message about a tensor rather than about the flag.

Three homes, so no rule is written twice. A new public lucid_yolo/validate.py carries require_at_least, require_in_range and require_one_of in one voice โ€” name must <constraint>; got <value> โ€” and both numeric checks refuse NaN explicitly, since value < minimum is false for NaN. assign/grid.py carries require_grid_side and require_grid_canvas, because stride divisibility is architecture and belongs beside HEAD_STRIDES; positivity is a separate condition there, as 0 % 32 and -640 % 32 are both zero. Where a file already had a validation voice โ€” MuSGD.__init__, orthogonalize โ€” the check matches its siblings rather than importing the helper.

Refusals arrive before the work. The evaluate doctest passes a nonexistent checkpoint path and still gets the flag's message rather than FileNotFoundError, which is the property that makes the validation worth having: the point is not that the run fails, it is that it fails at the flag, immediately, naming what to write instead.

One placement was corrected mid-row. The draft validated conf_threshold and decoder in cli/predict.py, where the audit pointed. Re-reading cli/predict.py's own note โ€” a library-owned refusal must not be restated in the command โ€” moved both into the four library entry points, which also covers scripts/draw_predictions.py and direct callers. The command inherits the identical message.

The vocabulary is read, not restated. task must be one of ('obb', 'detect', 'segment', 'keypoints') takes its tuple from DEFAULT_IMG_SIZE's keys rather than a hand-written list, and the now-unreachable .get(task, 640) fallbacks became [task] lookups, with a test pinning both per-task tables to the same key set and to values that are themselves usable. A vocabulary written twice drifts; the second copy is the one that goes stale.

pick_device's tests patch both probes in every case. Asserting that "cuda" is refused would otherwise pass on this laptop and fail on a machine that has one โ€” the same "green because of where it ran" failure the device flag itself produces. A separate case asserts the alternatives the refusal offers are exactly the spellings pick_device accepts, because a message is only useful if its list is a list of working commands.

No existing test changed. Verified before the guards went in: no test constructs MuSGD with a negative or NaN gain, no caller passes steps < 1, every canvas constant is stride-divisible, every conf_threshold in the tree is in [0, 1], and no test calls pick_device with an accelerator name. Five test files gained cases; none had an assertion altered, loosened or skipped.

WP-170 โ€” six ways a geometrically impossible input answered plausibly

Not one bug six times, but one shape six times: an input that cannot describe a real object produced a number instead of a refusal or a correct zero, and every one of them was invisible to a finiteness assertion.

The aspect term was a 1/eps gradient spike. atan(w / (h + eps)) with eps = 1e-7 keeps the value finite at h = 0 and makes the derivative enormous: against target [0,0,10,20], a point box [5,5,5,5] drew grad(x1) = 301186.5, enough to throw a box across the image in one step. The existing test covered exactly this input class and asserted torch.isfinite(pred.grad).all(), which passes on 301186.5 โ€” the assertion that cannot fail on the failure it was written for. atan2(w, h) is exact on the whole axis and carries no such derivative; the point box now draws 0.0. The agent verified atan2's origin gradient empirically on CPU and MPS before adopting it rather than trusting the identity โ€” PyTorch returns 0.0, not NaN โ€” and dropped a guard it had planned once the measurement said the guard was unnecessary.

An inverted box draws exactly zero from the aspect term, so CIoU can never un-invert one. That is now documented as intended rather than left as an accident: repair belongs at the decode boundary, which is where M-20 put it.

A rotated box with negative extents scored IoU 1 โ€” and the finding understated it. Two negative extents are a half-turn, so canonicalize rebuilds a correctly-wound rectangle and the shoelace formula has nothing to object to. The audit reported identical negative boxes matching each other. Measurement found worse: the negative row also scored 1.0 against the honest [0,0,4,2,0] describing that region, so an impossible row was an exact match for a real detection. _valid_rboxes now evaluates isfinite & (w > 0) & (h > 0) on the inputs as given, before canonicalization can erase the evidence, and folds the answer into the final where.

A NaN rotated box was emitted as a detection with a real score. torch.where(union > tiny, ...) selects the zero branch on a NaN comparison, so invalid overlap became zero overlap, so _suppress suppressed nothing and the box survived every round. Now the zero is stated by the validity mask rather than reached accidentally โ€” which also covers inf, whose comparisons behave fine โ€” and the decoder drops non-finite rows at the threshold so the row is never emitted at all.

ProbIoU overflowed in half precision. Finite degenerate fp16 boxes produced B_D = inf; coincident ones produced all-NaN gradients. _working_dtype promotes fp16/bf16 to fp32 and casts back. Both public losses needed it independently: the Hellinger sqrt has a derivative of ~5e9 at _RADICAND_FLOOR, itself Inf in fp16, so inheriting promotion from the inner call would have left the coincident-NaN case unfixed. fp32 is bit-preserved โ€” the promotion is an identity cast there, pinned by a test.

Masking was applied to the reduction, not the inputs. Every v == 0 keypoint was still pushed through the RealNVP stack and the base density; only the average excluded it. The value was correctly independent of the masked point, which is what the existing test asserted โ€” but with a non-finite unlabeled coordinate every gradient including the flow's own weights was NaN while the loss read healthy, and under default validate_args the same input raised ValueError from Normal.log_prob, so a masked-out annotation crashed the loss. Selection now happens before the arithmetic: flatten to (N*K, 2), index by the mask, run the Laplace term, the flow and the log sigma on the selected rows only. The value is bit-identical (3.038270 before and after), the flow cost drops by the unlabeled fraction, and a masked point is now structurally unable to reach any gradient.

The lesson the row keeps repeating. Three of the six had a test over exactly the right input that asserted the wrong property โ€” finiteness where magnitude was the question, value-equality where gradient-independence was the question. A degenerate-input test that asserts only "did not produce NaN" certifies that the code survived, not that it answered.

One gap the agent's own test found in the agent's own fix. Its first M-20 mask relied on ordering comparisons; l = r = inf decodes to [-inf, -inf, inf, inf], which is correctly ordered and passed as a box covering the plane. An explicit finiteness term closed it, making the axis-aligned mask symmetric with the rotated decoder's.

decode_ltrb was deliberately not touched. It is a frozen-golden path, and the drop went to the confidence-threshold boundary in NMSDecoder._decode_image instead, where boxes and anchor indices still pass through exactly one selection. Nothing frozen moved: 43/43 before and after. Three pre-existing tests used torch.zeros raw distances as incidental scaffolding โ€” zero-area boxes โ€” while asserting the threshold and padding contracts; their inputs became 4-pixel boxes on 8-pixel centres, still disjoint, with every assertion left byte-identical and now running against real geometry.

WP-173 โ€” a tuple that defended its width but not its order, and a floor never reached

The branch output argued against itself. _DetectionBranch.forward returned six tensors as a bare positional tuple, destructured in five places, four of them with underscore placeholders โ€” while its own docstring argues that a fixed shape must be defended, and the outer DualHeadOutput is a dataclass precisely because a tuple was judged unsafe one layer up. The width was defended and the order was not: inserting or reordering a future optional output would silently rebind every consumer, with no error anywhere. A frozen BranchOutput with the same six fields now carries them, read by attribute at all five sites. Stem execution order inside forward was preserved exactly, so no numeric drift was possible โ€” and the params_flops_* goldens passing unchanged is the proof rather than the hope.

count_flops flipped the caller's model into train mode. deploy() returns a freshly constructed view whose training is always True, so the saved flag was always True and the closing module.train() propagated through shared submodule references back into the parent. Measured on build_detector("n", 4).eval() then count_flops(m.deploy(), 64): the model reported training == False while backbone, neck and head.o2o were all True โ€” the parent reporting eval while three quarters of it would use batch statistics and mutate BN running stats on the next forward, with nothing raising. A per-submodule snapshot is restored in a finally, by direct assignment rather than train(mode) so restoration does not depend on named_modules() being parent-first to undo the recursion. GFLOPs identical at 0.052695.

The decay never reached its floor. LambdaLR's last invocation for a run of total_steps is step = total_steps - 1, so the factor landed at 0.021 against an intended lrf of 0.01 โ€” twice the floor โ€” while the doctest that displayed lrf evaluated step = 100, an index the run never reaches. A doctest can only certify what it evaluates, and this one evaluated a point outside the run. decay_span = max(1, total - warmup_steps - 1) fixes it and the doctest moved to 99. One existing parametrized expectation changed 0.75 โ†’ 1.0 - 0.5 * 50 / 99, written as an expression with a comment recording that this is a semantic correction and not a loosened tolerance: 0.75 was the off-by-one's answer.

warmup_steps >= total_steps was unguarded, and that shape is not hypothetical โ€” it is a 1โ€“3 epoch smoke run with a 3-epoch warmup default, and it fires inside test_fast_dev_run_smoke today. Every step took the ramp, the LR climbed for the whole run, peaked below lr0, and nothing warned. Clamped at the call site with a warning, rather than raised, because a raise would fail a launchable run over a schedule detail on the tier where the schedule matters least.

The clamp's honest limit, recorded because a test caught the overclaim. Clamping to total_steps - 1 recovers the peak but never the decay: any over-request lands exactly on warmup_steps == total_steps - 1, whose single post-warmup step sits at progress 0, so the factor is 1.0 and the floor is unreachable by construction. The agent's first warning text promised decay; its own two-step test failed, and the message was corrected to say what the clamp actually buys and what the operator should change instead. That is the right direction of repair โ€” the assertion stood and the claim moved.

One latency corrected downward. _TupleOutputAdapter's SegmentOutput branch enumerated ten fields where its sibling enumerated twelve, dropping all four keypoint tensors. Both enumerations are now derived from dataclasses.fields(), so a new optional output cannot be forgotten in one branch. But SegmentOutput is produced only by Segmenter.forward, and Segmenter.__init__ never passes num_keypoints, so no public builder reaches the drop today: this removes a possibility rather than repairing an active miscount, which is also why no FLOP golden moved.

What this row hands to WP-167. M-32 changes the schedule every trained run follows, so goldens/gpu/shapes_regression_det.json's three metrics are expected to move. The cause is quantified rather than guessed: det_nano_smoke.yaml over 6 epochs is 678 optimizer steps with 339 warmup, the final factor moves 0.01292 โ†’ 0.01000, and the largest per-step gap anywhere in the run is 0.00292, 0.29% of lr0, against a 0.05 tolerance. The four overfit_micro_* goldens are structurally immune and this was verified by reading the producer rather than assumed: run_overfit hardcodes lrf=1.0, warmup_epochs=0.0, which trips schedule_off so no scheduler is constructed at all. optim_toy runs at constant LR and never imports warmup_decay_factor.

WP-169 โ€” alignment weights survive zero-metric ties and small overlap

topk ranked the ties globally, not within each ground truth. _select_topk ran topk over an alignment metric that is zero at non-candidates and zero at a candidate whose prediction misses โ€” the state a freshly initialized head is in, since decode_ltrb applies no non-negativity and inverted distances decode to zero-IoU boxes. With a ground truth's whole row tied at zero, topk filled its k slots from the global tie order, which need not contain a single one of that ground truth's candidates, and the intersection with candidate_mask then returned zero positives for a ground truth that had four. Measured on a 4ร—4 stride-4 grid with one real ground truth whose candidates are {10, 11, 14, 15} โ€” deliberately not the low indices the tie order favours โ€” every one of topk โˆˆ {1, 3, 4, 6} returned 0 positives where min(k, c) was expected. Ranking over align_metric.masked_fill(~candidate_mask, -1.0) fixes it; -1 is the sentinel _resolve_conflicts already uses and is safe because t is non-negative by construction. The intersection is kept rather than dropped: when k > c the surplus slots land on the -1 fill and are removed there.

An absolute floor under a sixth power. The target normalization divided by t_max + eps with eps = 1e-9, but t_max = s ยท u_maxโถ collapses as the sixth power of the overlap, so the floor is eventually larger than the quantity it guards. Parametrized over u_max โˆˆ {1.0, 0.5, 0.1, 0.01}, the best anchor's weight โ€” which is u_max by definition, since t_max is its own row's maximum โ€” came out 0.999999998, 0.49999994, 0.0998004 and 4.9975e-06: correct at full overlap, 2001ร— too small at IoU 0.01. A positive trains as background while fg_mask still calls it foreground. The denominator is now floored at torch.finfo(dtype).tiny, which only ever guards a true 0/0 โ€” and there align_pos is zero too, so the product is zero rather than NaN.

The repo's own frozen data was the strongest evidence. tests/ptl/prechange_detect_step.json pinned, at initialization, train/o2m_box at 4.12e-09 and train/o2m_l1 at 3.17e-08 โ€” the one-to-many branch's entire localization objective, weighted into nothing, roughly 1.9e6ร— suppressed. That snapshot was written to detect objective drift across WP-088 and did that job; what it also did, unremarked for eight releases, was pin the defect. tests/assign/test_rotated_assign.py carried the same fingerprint: image 1 anchor 5 is its ground truth's best anchor, so its weight is exactly u_max = 0.25, and the snapshot stored 0.249998539686203.

Both frozen sets were re-captured, and the ruling is recorded here rather than in ESCALATION.md. Nothing under goldens/ moves โ€” all 43 pass, and a zero-tolerance sweep of all 447 metrics across 48 files confirms every assignment_cases.json metric is bit-identical in the live copy and in all six frozen sets. Trigger 4 is "a change would require altering a frozen golden", and no change does, so no escalation entry was opened. The two tests/ snapshots are a different question, decided deliberately: the pre-change loss values were re-captured by replaying the test's exact sequence under the same seeds and set_num_threads(1), with seeds, config, gains and the 714 state_dict_keys unchanged and a recapture_reason field recorded in the file. The rotated weights were derived from the frozen ones by the closed-form factor rather than read off the code under test, and agree with the current output to โ‰ค2 ULP.

One test passed only because of the defect. test_rotated_candidacy_reaches_the_assigner asserted the rotated call produced strictly fewer positives than the axis-aligned one. It passed โ€” because the smaller rotated candidate set simply lost more slots to the global tie fill. With H-16 fixed both saturate at min(k, c) = k, so the count stopped discriminating while set membership still does; the assertion moved to not torch.equal(rotated.fg_mask, axis_aligned.fg_mask), which still fails if gt_rboxes is dropped, since the two calls then become identical. Probed over 40 independent seeds: the new assertion holds 40/40 and the old one 0/40, both counts pinned at exactly 10 every time. The old assertion is not merely weakened โ€” it is now never true.

Attribution was measured, not argued. Applying one fix at a time separates them cleanly: with H-16 reverted, test_detect_step_reproduces_the_pre_change_snapshot yields 24.32134246826172, identical to all digits with both fixes, so H-16 contributes nothing to that scene; the three bit-identity failures in test_rotated_assign.py also survive H-16's reversion and move by exactly (t_max + 1e-9) / t_max, and only test_rotated_candidacy_reaches_the_assigner passes with H-16 reverted.

What this row hands to WP-167. H-17 changes the objective's weighting on the first step of every trained run, so goldens/gpu/overfit_micro_*.json and goldens/gpu/shapes_regression_det.json are expected to move. Those five files have no copies under goldens/frozen/, so recomputing them is an ordinary FREEZE=1 re-freeze on an accelerator rather than a ยง7 question โ€” but it must happen with 169 and 172 both landed, which is why it belongs to 167 and not here.

WP-172 โ€” every merge path keeps every modality

CopyPaste._merge rebuilt Targets without two of its columns. It passed boxes, labels, polygons, keypoints and keypoint_vis and no more, so difficult and rboxes were dropped. The difficult loss is the quieter of the two: Targets._resolve_difficult expands an omitted column to one False per instance, so nothing raises โ€” a destination carrying difficult=[True] merged with a source [False, True] returns [False, False] where [True, False] is correct, and under A48 that turns every ignorable ground truth into an ordinary false positive. difficult is now carried by the same index the boxes take, which is total by construction since _resolve_difficult guarantees both sides are length-N. rboxes is deliberately not carried, and the docstring says so, so the omission is not re-read as this same bug later: a paste transfers a rasterized polygon mask and the oriented path carries no polygons (WP-056), so refusal is the fix rather than a transfer that would claim to paste something the transform cannot paste.

The rotated refusal did not hold through the seam WP-147 made public. _reject_rboxes was called only from __call__, never from apply, so a caller building CopyPasteParams directly with a rotated destination reached _merge and lost the modality silently โ€” the exact failure the guard exists to prevent. apply now calls it as its first statement. __call__ keeps its own copy on purpose: the guard has to fire before sample consumes a generator draw, so moving it rather than duplicating it would have changed RNG ordering on the sampling path. The Raises: section records both the duplication and its reason.

The flip never re-clipped. _mirror_targets reflected about width - 1 and returned. The COCO reader clamps boxes to [0, width], so a box legitimately touching the right edge mirrors to x1 = (W-1) - W = -1: at W=10, [4, 1, 10, 3] came back as [-1.0, 1.0, 5.0, 3.0]. HorizontalFlip is the last stage of the train pipeline, so that -1.0 is what the assigner receives. The clamp is on x only. A full-canvas clip was implemented first, using fuse-augmentations' own clip_bbox_xyxy, and it failed the class doctest by trimming y2 from 2 to 1 โ€” which is the argument for the narrower fix rather than a reason to repair the fixture: a mirror moves only x, so clamping y edits geometry this transform never touched, makes the flip's output depend on a height it does not otherwise read, and silently trims a box that arrived off-canvas from some earlier stage. Keypoints, rotated boxes and polygon rings are left unclipped, each for a stated reason โ€” a mirror is an isometry and cannot push a wholly on-canvas box off it, clamping an rbox centre while keeping its extents describes a different rectangle rather than a clipped one, and nothing consumes rings raw before rasterization, which clips at the grid.

The convention was stated but unasserted, which is the half that matters. Correcting the row's own framing on evidence: the W-1 convention is implemented and is documented, in augment.py's class docstring and in rotated_aug.mirror_rboxes, both citing WP-154b. What was missing is a test that could fail. The existing ones restate the formula (width - 1) - src, which cannot distinguish a correct implementation from an expectation that drifted with it. The new test asserts by consequence instead: a box drawn tightly around a bright two-column block must still bound that block after both are mirrored. Under the W convention the block lands at columns {6, 7} while the box is carried to [7, 8], and the test fails. It passed on first run before any fix โ€” which is the evidence that the choice was already right and only unguarded.

Train and val COCO label spaces were derived independently and never compared. _build_coco_splits constructs two CocoDetectionDataset, each running _build_category_maps over its own file's categories, sorting ids and numbering from zero. Given val [1, 7] against train [1], val maps 7 -> 1 while train has no label 1: the model trains against one numbering and is scored against another, reporting a plausible number the whole way. _check_shared_label_space now raises before either split is served, naming the two one-sided sets separately so the message identifies which file is the odd one. Comparing id sets is sufficient and not merely necessary, since the mapping is a function of the sorted id set. The YOLO path needs no equivalent โ€” YoloDetectionDataset.from_root reads one data.yaml for both splits, so the label space has a single source and cannot disagree with itself.

No golden moves, and the escalation clause is vacuous. All 43 pass. A zero-tolerance sweep of 447 metrics across 48 files finds exactly one moved entry, rotated_rbox_area at 96.1934 -> 96.1935, in the live aug_invariants.json and in frozen/0.6 and frozen/0.7 โ€” and it is not this row's: running the pristine main-tree lucid_yolo against the same unmodified producer reproduces it. The unrounded value is 96.1934585571289, sitting 8.6e-06 above the 4-decimal rounding boundary that round(x, 4) resolves to 96.1935; it was frozen when it sat just below. That is environment drift predating WP-172, five thousand times inside the 0.5 tolerance, and it is recorded here because the next re-freeze would otherwise read it as this row's consequence. The strongest evidence that the flip fix itself moves nothing is data_checksums.json, bit-identical across all ten metrics: it runs the whole WP-015 pipeline under a fixed seed and is the only golden that exercises HorizontalFlip in-pipeline, aug_invariants' producer containing no flip call at all.

One fixture was pinning the defect. test_double_flip_is_identity ran on a torch.rand(3, 4, 10) canvas while its _targets() fixture carries boxes out to x=55 โ€” geometry five times off-canvas. The involution is a property of the mirror, and a re-clip necessarily breaks it for geometry that started outside the canvas, so the test as written could only pass while nothing clipped. The canvas moved to 64x64, which keeps the test's intent and makes its geometry coherent.

What is deferred, and why. Two files can share an id set and disagree on what each id names โ€” a real label-space disagreement with an empty symmetric difference. That check is not implemented: CocoDetectionDataset does not retain category names, so it would mean widening the reader's public surface, which is outside this row. Recorded rather than dropped.

WP-175 โ€” the record describes the repository that exists

Two checks first, then the wording. The order is the point. A sweep that only edits prose leaves the next drift undetected, and every claim this row corrects had been wrong for at least one release precisely because nothing failed on it. audit_version_single_source.py gained check_readme_names_the_current_version, which compares the README's Current release: **X** line against lucid_yolo.__version__ and also fails when that line is absent, so deleting the sentence cannot silence the guard; it caught the live 0.4.0 against 0.7.0 drift on its first run. audit_license_headers.py gained the YOLO26 paper as a required fragment of both NOTICE and the README. A positive fragment suffices rather than a ban on the vendor-bound form, because the leading article discriminates โ€” the YOLO26 paper is not a substring of the Ultralytics YOLO26 paper โ€” and a repo-wide ban would have to carve out R1's own literal title in PROVENANCE.md.

Three of the row's own premises were wrong, and are corrected here rather than transcribed. The row says the blueprint is the source of 49 in-code citations; measured on this tree it is 133 blueprint lines across 36 files under src/, 107 of them in blueprint sec. N form, and R37 records the measured figure. The row says the README "offers an install line that resolves to nothing" โ€” true, but understated: PyPI's JSON API shows lucid-yolo with 26 releases, every one a .devN pre-release, latest 0.5.0.dev2, so a bare pip install resolves to nothing because pip skips pre-releases, --pre resolves to a snapshot two minors behind this tree, and the README's own explanation ("every distribution published before 0.4.0 is a .devN") was wrong twice over โ€” there is no non-dev release at all and no 0.4.x of any kind. The install line is gone, replaced by the evidenced explanation and D20's consequence: the runtime git pin makes 0.7.0 unpublishable until a fuse-augmentations 0.12 exists on PyPI.

A seventh site, and a claim the row did not know was stale. The vendor-bound phrase was enumerated in NOTICE, docs/index.md and four model cards; it also sat in docs/REPRODUCTION_REPORT.md's preamble. That document is append-only under D10 โ€” but the rule is scoped to its dated sections ("a later release corrects an earlier claim by adding to it"), and the preamble is the document's standing self-description rather than a record of anything, so correcting it edits no claim away. The non-affiliation sentence keeps the vendor's name, on WP-161's reasoning that a disclaimer not naming the party it disclaims is a sentence about nobody. Separately, README:62 asserted the paper "addresses no keypoint task at all"; the R1 abstract, fetched during link verification, lists pose estimation among the family's tasks. The absolute claim is replaced by the checkable one โ€” no allowlisted paper publishes a keypoint architecture or parameter table, which is what A65/A66 actually support.

The task count, in six documents and one coupled pair. The README banner, docs/index.md, docs/TRAINING.md, docs/DATASETS.md, NOTICE and lucid_yolo.__init__'s package docstring all counted three tasks against four shipped tiers, the keypoint tier having released at 0.5.0. pyproject.toml's description and mkdocs.yml's site_description counted three as well, and those two are not independent โ€” audit_docs_site.py requires them byte-identical, so editing one alone fails the gate. It did, which is how the coupling was confirmed rather than assumed.

Two register rows. docs/PROVENANCE.md gains a Project-internal governing documents section with R37 for the blueprint โ€” authorship as NOTICE's copyright holder, unpublished and non-public, no licence grant and not redistributable, every method claim downstream of R1โ€“R36 โ€” and a second audit row covering 975ff50..f6ec3e1. That audit walked 193 commits and found 27 whose trailers are refused by check_commit_trailers.py; all 27 predate b4f3e03, and the enforced window b4f3e03..f6ec3e1 is clean at 15 of 15. The 27 are recorded as unexamined rather than characterised, because sampling found at least one that is a citation failure ('Provenance:' trailer lists no source id) rather than a shape failure, and a register row asserting a category it did not check is the defect this row exists to remove.

One stale claim left standing, deliberately. docs/DECISIONS.md's D1 still reads "Pose and classification remain future 0.x work" against a keypoint tier that shipped at 0.5.0. D1 is a recorded decision rather than a description, and rewriting one to match what later happened is how a decision log stops being evidence of anything. It wants a superseding entry, which is a [PRINCIPAL] act and not this row's.

WP-167 โ€” the accelerator half gets a schedule and the floors a gate

The Makefile said it was the schedule. gate-gpu's comment claimed the target "is that schedule", and nothing invoked it: four workflows, none with schedule, none with workflow_dispatch, none mentioning the accelerator. ci-tests.yml runs -m "not gpu and not data" and calls check_goldens.py without --include-gpu, so the whole goldens/gpu/ subtree and every marked test were unrun remotely โ€” which is how the frozen detection overfit golden drifted for six days under WP-078, and why RESEARCH_LOG.md still records shapes_regression_det.json as outstanding. gate-gpu.yml is nightly at 17 4 * * * plus workflow_dispatch; the off-the-hour minute keeps it out of the top-of-hour queue spike.

Arming it is one repository action, deliberately outside the file. The job carries if: vars.GPU_RUNNER_LABEL != '' and runs-on: ${{ vars.GPU_RUNNER_LABEL }}. Unset, both triggers resolve to a visible skipped job rather than a run queued forever against a self-hosted label no machine answers to โ€” the failure mode where a green Actions tab and an unrun gate look identical. GitHub-hosted runners have no GPU, so there is no default worth hard-coding. cancel-in-progress: false, because a half-killed training run leaves no signal about the goldens at all.

The run record is the gate's own stdout, not a second opinion. Clause 1 admits "a run recorded under .reports/", and the workflow tees make gate-gpu into .reports/gate-gpu/gate.log under set -o pipefail. It deliberately does not re-run check_goldens.py --include-gpu to produce the record: every producer there retrains a model, so recording the result that way would cost a second full gate and could disagree with the run that decided the verdict.

Three of four floors were enforced by a human at a prompt. The comparison lived once, in main(). run_overfit returned its score unchecked, so the seg, obb and kp golden producers and every marked test accepted a below-floor run; only detection had a test asserting a floor. The comparison moved into run_overfit, raising FloorNotMet on the raw unrounded score. It subclasses ValueError and pointedly not RuntimeError, because run_overfit catches RuntimeError to retry training non-deterministically and a floor miss must never be swallowed as a missing deterministic kernel. The ordering matters as much as the placement: the raise precedes the freeze branch, so --freeze is now unreachable on a failing run and a bad re-freeze cannot happen quietly. Under make golden-gpu a below-floor task surfaces through check_golden's broad handler as a clean producer raised FloorNotMet: ... line rather than a traceback; check_goldens.py itself was not touched.

Two bands admitted values the published DoD rejects. write_golden stored spec.tolerance verbatim, so overfit_micro_kp.json at 0.335667 ยฑ 0.05 had a lower edge of 0.285667 against a 0.30 floor, and overfit_micro_obb.json at 0.938966 ยฑ 0.05 an edge of 0.888966 against 0.90. A golden could pass while the acceptance criterion it exists to protect had failed. clamped_tolerance(score, spec) returns max(0.0, min(spec.tolerance, round(score - spec.floor, 6))) and write_golden calls it, so the clamp lives in the writer and a re-freeze produces a correct band โ€” no stored JSON was edited. The six-decimal rounding is not cosmetic: the natural assertion score - tolerance >= floor failed on obb at one ulp (0.8999999999999999), and pinning the arithmetic to the precision the goldens actually store fixed the representation rather than widening the assertion. shapes_regression.py was checked for the same defect class and has tolerances but no acceptance floors at all, so there is no band to clamp; reported rather than changed.

What this row cannot close, and why it stays open. Clauses 3 and 4 โ€” goldens/gpu/ re-frozen with every band above its floor, and make gate-gpu green โ€” need CUDA. This tree has MPS. WP-169 moved the first-step objective weighting, so the five accelerator goldens that passed on an L4 against f8225af will not pass now, and the re-freeze must run after 169 and 172 rather than before. The row therefore lands its code with its status still open; the regenerated goldens and the flip are a second commit. The freeze order is overfit for each of det, seg, obb, kp and then shapes for det โ€” five files, not four, shapes_regression_det.json being the one RESEARCH_LOG.md records as outstanding.

Measured, and left alone. The L4 reference run sat near 10% GPU utilization and 5% of 24 GB, and Lightning named both causes itself: num_workers=0 against 12 vCPU, and default matmul precision on a Tensor Core card. Neither is taken here. Both change the numerics and therefore move every accelerator golden, which is a decision this row has no standing to make while it is busy fixing the gate that would catch it. The cheapest change that is not a numerics change is running the four overfit_micro tasks concurrently โ€” separate processes, ~5% of the card each โ€” and that is recorded as a recommendation rather than applied.

The re-freeze, measured before it was performed. Clauses 3 and 4 closed on an L4. make golden-gpu ran first, against the goldens frozen before WP-169, and returned 46/48: overfit_micro_kp expected 0.335667 and got 0.574728, overfit_micro_seg expected 0.814623 and got 0.86491. That failure is the evidence the re-freeze was necessary, rather than an assumption that it must have been. The other three passed while still moving โ€” detection 0.994932 to 0.996622, oriented 0.938966 to 0.964928, shapes_regression_det's NMS mAP50-95 0.882337 to 0.870817 โ€” so the stored bands absorbed a real 0.026 shift on obb without reporting it. A band wide enough to hide the movement it was sized for is the same defect class as a band admitting a sub-floor value; the first is recorded here, the second is what clamped_tolerance fixes.

The clamp does not bite today, and saying otherwise would be false. Every re-frozen score clears its floor by more than its own tolerance: keypoints by 0.2747 against a 0.05 band, oriented by 0.0649, detection by 0.0466 against 0.04, segmentation by 0.1649. min(spec.tolerance, score - spec.floor) therefore returns spec.tolerance unchanged in all four cases, and shapes_regression_det has no floors to clamp against at all. The two edges this row was opened to close โ€” keypoints admitting 0.285667 against a 0.30 floor, oriented admitting 0.888966 against 0.90 โ€” are moot rather than tightened: the scores moved out of reach of their own lower edges. The clamp stands as the guard that will bite on the next regression, not as something that changed a stored band here.

What moved the keypoint number. train_oks_ap rose 0.239061, from 0.335667 to 0.574728 โ€” a 71% relative gain on a tier that shipped at 0.5.0. That is WP-169 landing, and it is the first place in the repository where the size of that defect is visible as a number: the one-to-many branch's entire localization objective had been annihilated, train/o2m_box pinned at 4.12e-09 in tests/ptl/prechange_detect_step.json. The offline gate could show that the objective was suppressed; only the accelerator floors could show what suppressing it cost.

Two runs, one set of values. The freeze ran twice, in two independent Colab sessions on two separate L4 VMs, each reporting NVIDIA L4 and default highest matmul precision. Agreement is claimed at the precision each run actually recorded, which is not the same number in both cases. Within the second session it is exact: golden-gpu's pre-freeze diagnostics report got 0.574728 and got 0.86491, matching the files the freeze then wrote at all six stored decimals, so the measure step and the write step agree digit for digit. Across the two sessions it is four decimals, because the first session's record is overfit_micro.py's own stdout, which rounds โ€” 0.5747, 0.8649, 0.9966, 0.9649 โ€” and its six-decimal values were not preserved. Four independent decimals on four tasks is the checkable claim; "identical to every stored decimal" would not be. The SHA-256 of all five files matches between the session that produced them and this tree. The second session's make gate-gpu did not reach golden-gpu: pytest collection failed at tests/models/test_onnx_export.py:71 on import onnx, because that session installed pip install -e . without the dev dependency group where onnx and onnxruntime are declared, and Colab's own image supplies pytest but not onnx. That is a provisioning gap in the throwaway notebook, not a defect in the tree โ€” .github/workflows/gate-gpu.yml:52 installs --group dev, which is why the workflow this row adds would not hit it. The green gate-gpu of record is the first session's complete run on this same tree: 6 marked tests passed in 4464 s, then 48/48 goldens passed, exit 0.

WP-176 โ€” v0.8.0, the audit remediations

The release whose subject is a class of defect rather than a capability. Nothing in 0.8.0 detects anything 0.7.0 could not. What the sixteen rows have in common is that every defect they fixed was invisible to a green gate: an assigner whose one-to-many localization objective was annihilated at 4.12e-09 while all 43 offline goldens passed, a merge path dropping a modality, entry points validating types but not values, four acceptance floors compared once at a prompt, and a record describing a three-task repository against four shipped tiers. So the gates moved with the fixes โ€” ci-tests.yml runs make test rather than a narrower subset, goldens/frozen/MANIFEST.sha256 makes a frozen value uneditable alongside its own tolerance, the accelerator half has a nightly workflow instead of a Makefile comment claiming to be one, and FloorNotMet raises where the score is produced. The one number the release moves is the keypoint overfit floor, 0.335667 to 0.574728, which is what the assigner defect cost measured after the fact.

The mechanics, which are four lines. __version__ moves 0.7.0 to 0.8.0 and that is the whole version change, pyproject.toml declaring it dynamic and reading the module attribute. [Unreleased] closes as [0.8.0] - 2026-09-05. make freeze-goldens MINOR=0.8 copies seven live metric sets into goldens/frozen/0.8/ โ€” the same seven 0.6 and 0.7 hold, data_checksums.json and fixture_checksums.json still skipped as generator-derived under WP-154c's "freezable": false โ€” and the offline gate goes from 43 goldens to 50. The README's Current release line is rewritten, which for the first time is load-bearing rather than courtesy: WP-175 added check_readme_names_the_current_version, so a release that bumped the attribute and left the prose behind would now fail its own gate instead of drifting three minors the way 0.4.0 did.

The row's own premise was wrong, and the freeze is ordinary because of it. The scope cell says two of this phase's rows moved a value pinned in every frozen set from 0.2 forward, "so this freeze is also where those moves stop being recorded exceptions and become the baseline a later release is held against". Written in advance, that was a prediction about WP-169 and WP-172, each of which carried a DoD clause requiring a principal ruling in docs/ESCALATION.md before any frozen value moved. Both rows measured instead of assuming, and neither moves a frozen value: WP-169's two fixes leave assignment_cases.json bit-identical live and frozen from 0.2 forward, and WP-172's merge-path repair moves no golden at all. docs/ESCALATION.md therefore gains no entry here, and the 0.8 snapshot normalizes nothing โ€” it is an ordinary release freeze. Phase 14's two overrides of AGENTS.md ยง7 remain the only ones, and remain non-precedential.

One value that looks like drift and is not. aug_invariants.json stores rotated_rbox_area as 96.1934; the producer emits 96.1934585571289, which rounds to 96.1935 at four decimals and therefore reads as one ulp of disagreement in any four-decimal report. The unrounded value sits 8.6e-06 above the 96.19345 rounding boundary, and the stored tolerance is 0.5, so nothing fails and nothing has moved. It is copied into 0.8 exactly as 0.6 and 0.7 hold it, and recorded here because a later reader comparing a rounded report against the stored literal would otherwise open an investigation into a representation difference.

D21, and a decision left unedited. docs/DECISIONS.md D1 still reads "Pose and classification remain future 0.x work" against a keypoint tier released at 0.5.0 โ€” the stale claim WP-175 found and deliberately did not touch, on the grounds that rewriting a recorded decision to match what later happened is how a decision log stops being evidence. This row is [PRINCIPAL] and can do what WP-175 could not: D21 supersedes the keypoint half of D1's scope clause and nothing else. Classification and YOLOE-26 stay out of scope, and no row proposes them. The entry carries two qualifications D1's wording could not: the tier is keypoints, not pose, K being a constructor argument that nothing in the head, loss or decode interprets; and it is the one accepted tier not drawn from R1, its loss and evaluation protocol coming from R14 (RLE, arXiv:2107.11291) because no allowlisted paper publishes a keypoint architecture or parameter table to check the head against (A65, A66). D1's word "pose" therefore never named what shipped, which is the second reason to supersede rather than amend.

What this release still does not do. No tag is cut and no distribution is published, as at 0.4.0 through 0.7.0; five release commits now stand ahead of the train's own tags. release.yml fires on a pushed version tag and re-runs the guard, and both are the operator's to trigger. D20 stands unchanged: fuse-augmentations is pinned as a direct reference to a5a276a, which is legal to build and install and illegal to upload, so this distribution remains unpublishable to PyPI until a 0.12 reaches it โ€” at which point a >=0.12,<0.13 range replaces the reference and nothing else changes. No trained weights ship (D14).

Phase 14 โ€” what each row does, and where its boundary is

Phase 14's roadmap cells were the longest in the register by a factor of three, because each row carries a boundary that is easy to cross by accident: the swaps are large, and the difference between "this much of the engine moved" and "and this other thing came with it" is exactly what a scope cell exists to state. That reasoning belongs here, where there is room for it, and the roadmap keeps the summary. Each row below is written before it lands, so it is a specification rather than a record; the ### WP-nnn entry written when the row lands is what says what actually happened.

WP-154 โ€” the pin bump alone. The dependency bump and the re-freeze it forces, with nothing delegated. R21 advances from fedde0c1 to a5a276a โ€” the commit v0.12.0 is tagged from once this roll-out is clean. Two things break loudly and are repaired here. tests/fixtures/synthetic.py imports animal_shapes, which upstream's restructure removed; the replacement is tuple(AnimalShape)[:KEYPOINTS_ANIMAL_COUNT], the same declaration-order selection the helper performed, and the other three imports from that package are unaffected. The synthetic fixtures themselves also moved: PrimitiveShape.TRIANGLE was redesigned from obtuse-scalene to equilateral, polygon_to_obb re-derived from an upright frame rather than minimum-area calipers, and the animal family rebuilt around a packaged SVG zoo, against goldens whose tolerance is zero. Every changed golden value is traced to a named upstream change before it is re-frozen; one that cannot be traced is a regression and stops the row rather than being frozen over.

WP-154c โ€” the freeze mechanism, fixed rather than the files, a third time. WP-154 hit WP-132's wall again: goldens/frozen/0.5 and 0.6 both re-acquired the two generator-derived goldens, because make freeze-goldens is a blind copy with no notion that a golden can be pinned to an external package's output rather than to this project's own code. A "freezable": false field on the two live goldens, a scripts/freeze_goldens.py that skips them at copy time, and a check_goldens.py that rejects one found frozen anyway โ€” so the row that removed the files this time is not the row that has to remove them again next time. No golden value changes and neither frozen directory is touched.

WP-154b โ€” the conventions, before anything delegates. Absorbs FA-1, FA-2 and FA-3, and gives up the two conventions ยง8.12 measured as genuinely different โ€” deliberately before any delegation, so the frozen literals they move travel with a local edit rather than with a swap. The composition centre becomes ((W-1)/2, (H-1)/2) and the mirror axis (width - 1) / 2 (data/augment.py, data/rotated_aug.py), on the principal's reasoning that one convention across image and coordinate transport is worth a re-freeze and two conventions inside one pipeline are worth nothing; rotated angles are unaffected, since pi - theta and -theta differ by a half turn the long-edge convention absorbs.

What is asserted rather than changed: the datamodule's existing torch.Generator reaches fuse's own draws rather than a second stream โ€” the property WP-079 was opened by losing โ€” and end to end under num_workers > 0, which upstream does not exercise; and constant-value padding is a fill= parameter requiring padding_mode="zeros" rather than the PaddingMode.CONSTANT member the plan expected, in the image's own value range so 114/255 transfers unchanged. The half-pixel convention has nothing left to pin: 0.12 declined to add align_corners and sampled with align_corners=True against the normalization sandwich derived for that same flag, so the pixel-space map is the one an align_corners=False implementation produces for the same matrix, measured to atol=1e-5. That measurement is recorded, not frozen โ€” there is no flag whose flip it would catch.

WP-155 โ€” the letterbox and the instance filter. Absorbs FA-4 and FA-5. Letterbox keeps inverse_map and forward_affine unchanged in signature and semantics, so its consumers โ€” six evaluation and prediction call sites, four since WP-152 added predict_keypoints โ€” are untouched, and delegates the resize-and-pad internals. RandomAffine and MosaicAssembly consume one upstream keep mask instead of two local _keep_mask copies that must agree; Targets.filter is unchanged, since which instances survive is a fuse question and what a surviving instance means is not.

The trap is in the defaults. Upstream's min_size and min_visibility are both 0.0, deliberately โ€” the defaults drop nothing rather than encode a plausible-looking minimum โ€” against this project's 2.0 and 0.1. Omit either argument at any call site and every instance survives the warp, including ones pushed entirely off canvas, with no exception and no shape change. Both are passed explicitly at every call site and a test asserts it. Deletes _resize_pad, _LetterboxGeom and both _keep_mask.

WP-155b โ€” the resampling delta, measured on purpose. The row decision 6 asked for, holding no swap. Delegating the letterbox keeps its geometry exactly โ€” unrounded ratio, max(1, round(size * r)) content size, (out - new) // 2 floor-halved pads with the odd pixel right or bottom, verified in both directions on integer arithmetic โ€” while changing its pixels: this project resized with an antialiased bilinear F.interpolate followed by F.pad, where upstream runs one grid_sample from source canvas to letterboxed canvas with antialias=False by default and, when enabled, a Gaussian mipmap prefilter rather than torch's area-weighted kernel. So every coordinate round-trip test passes and only image-derived metrics move, on the evaluation path, across the six evaluation and prediction call sites. A delta that hides behind a green coordinate suite is exactly the one that has to be measured on purpose. This row measures it against the 0.6 frozen goldens, records the number, and re-freezes what moved. Nothing moving is a legitimate outcome and is recorded as one.

WP-156 โ€” the affine reduces to ranges over a segment. Absorbs FA-3 and FA-5 into the warp itself, and takes the local fusion with it. What remains locally is R1 Table S3's ranges and the sampling of them. FusedAffineLetterbox is deleted outright โ€” WP-155 and this row composed inside fuse are that composition, so the local class is a second implementation of the package's whole subject. Polygon rings ride the composed matrix rather than a duplicate warp path: return_matrix=True hands the composed pixel matrix back and apply_affine_to_points applies it to rings here.

What decides whether that matrix is the whole chain is adjacency, not clip_policy โ€” which governs when a fused colour chain clamps and shares nothing with this. Only consecutive geometric operations group into one segment, so a colour operation between two geometric ones splits the run and the returned matrix then describes the last segment alone. Ordering geometry before the letterbox before any colour is therefore a requirement of this row, and the single-geometric-segment condition is asserted as a tier-C invariant because silence is how it would fail. The returned matrix is also None for an exact-only pipeline and for a letterbox-only one, where letterbox_matrix() is the route instead, so a consumer checks not None and single segment before treating it as the whole chain โ€” two conditions, not one. Deletes _warp_image, _theta_from_pixel_matrix, _transform_box_corners and the whole fused class.

WP-157 โ€” the two task conventions. Absorbs FA-7 and FA-8, together because each passes a table or a callback upstream rather than moving a rule there. rotated_aug.py shrinks to what upstream declines to own rather than being deleted outright: clip_rboxes_to_canvas stays, because clipping a rotated box yields a polygon and not a rotated box, so upstream refuses the operation by design and bridges through rbox_envelopes to a plain-box clip and the keep mask for the survival decision instead; check_rotated_pairing stays too, validating a local Targets pairing invariant (WP-056) rather than any geometry. canonicalize is passed as a callback rather than reimplemented upstream, since angle range and periodicity are a task convention (A22) and not a resampling concern, and rotated_geom.py is untouched โ€” the assigner, rotated NMS and the OBB head all read it.

The left/right index-swap table is passed through rather than owned; HorizontalFlip delegates, the table staying dataset metadata read from the caller (A64), which is what keeps K generic. _mirror_targets shrinks rather than being deleted: upstream's permute_keypoint_pairs moves coordinates only and leaves the visibility flags where they were, so the local permutation of keypoint_vis stays. Drop it and every mirrored sample carries a visible point marked occluded and its partner marked visible, with identical shapes throughout and no failing coordinate assertion. The mirror axis is not this row's concern โ€” WP-154b already moved it, which is why nothing frozen moves here.

WP-158 โ€” the closing sweep. The last of the four rows that re-freeze; like the other three, it holds no swap. data/__init__.py re-exports 54 symbols โ€” 55 when this paragraph was written, less the FusedAffineLetterbox WP-156 deleted โ€” a count set when the package owned the whole augmentation stack and raised by WP-147's five parameter dataclasses; it is reduced to what survives, so the public surface states what this project still implements. docs/ROADMAP.md, docs/ASSUMPTIONS.md (A10, A11, A21, A26), docs/PROVENANCE.md, the model cards and README.md are rewritten to describe the boundary rather than a stack this project no longer implements, with A11's half-open rasterisation rule kept โ€” that one stays local and still governs mask supervision.

Any structural fixture metric the swaps moved is re-frozen with the move recorded. Tier D is not re-frozen, because there is nothing there to re-freeze: it holds no literal and no golden, only same-seed-equal and different-seed-differ against whichever implementation is live, so it either still holds after the swaps or names a real loss of reproducibility. Tiers A, B and C are not re-frozen either: if any moved, the swap was wrong and the row that moved it is the defect.