# 🔗 Phase 14 — Augmentation upstreaming to `fuse`, v0.7.0


## WP-154 — every delta traced, and the frozen tree could not absorb it again

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

R21 advances from `fedde0c1` to `a5a276a`, and the two breaks [WP-153c](https://borda.github.io/lucid-YOLO/work_packages/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](https://borda.github.io/lucid-YOLO/work_packages/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](https://borda.github.io/lucid-YOLO/work_packages/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](https://borda.github.io/lucid-YOLO/work_packages/wp-132/) removed. [WP-132](https://borda.github.io/lucid-YOLO/work_packages/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](https://borda.github.io/lucid-YOLO/work_packages/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](https://borda.github.io/lucid-YOLO/work_packages/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](https://borda.github.io/lucid-YOLO/work_packages/wp-140/) froze them into `0.5` and [WP-153](https://borda.github.io/lucid-YOLO/work_packages/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](https://borda.github.io/lucid-YOLO/work_packages/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](https://borda.github.io/lucid-YOLO/work_packages/wp-159/) meets this same wall. The general shape is [WP-153b](https://borda.github.io/lucid-YOLO/work_packages/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

<a id="wp-154c"></a>

[WP-154](https://borda.github.io/lucid-YOLO/work_packages/wp-154/) closed by naming why [WP-132](https://borda.github.io/lucid-YOLO/work_packages/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](https://borda.github.io/lucid-YOLO/work_packages/wp-132/) removed the two files that wall produced; [WP-140](https://borda.github.io/lucid-YOLO/work_packages/wp-140/) and [WP-153](https://borda.github.io/lucid-YOLO/work_packages/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](https://borda.github.io/lucid-YOLO/work_packages/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](https://borda.github.io/lucid-YOLO/work_packages/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

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

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](https://borda.github.io/lucid-YOLO/work_packages/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_polygon_ring_lands_on_frozen_vertices`, `test_boxes_land_on_frozen_output_canvas_coordinates`, `test_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](https://borda.github.io/lucid-YOLO/work_packages/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](https://borda.github.io/lucid-YOLO/work_packages/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](https://borda.github.io/lucid-YOLO/work_packages/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

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

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](https://borda.github.io/lucid-YOLO/work_packages/wp-154b/), on the same file, eight commits later. The alternatives were put again — remove the file on the [WP-132](https://borda.github.io/lucid-YOLO/work_packages/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

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

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](https://borda.github.io/lucid-YOLO/work_packages/wp-153b/) and [WP-154b](https://borda.github.io/lucid-YOLO/work_packages/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](https://borda.github.io/lucid-YOLO/work_packages/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

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

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](https://borda.github.io/lucid-YOLO/work_packages/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](https://borda.github.io/lucid-YOLO/work_packages/wp-153c/)'s §8.12 measurement already named the trap, and the case that pins it — `test_mirrored_visibility_flags_follow_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

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

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](https://borda.github.io/lucid-YOLO/work_packages/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](https://borda.github.io/lucid-YOLO/work_packages/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](https://borda.github.io/lucid-YOLO/work_packages/wp-155/) moved `letterbox_image_mean` and re-froze it inside its own row; [WP-156](https://borda.github.io/lucid-YOLO/work_packages/wp-156/) and [WP-157](https://borda.github.io/lucid-YOLO/work_packages/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

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

`__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](https://borda.github.io/lucid-YOLO/work_packages/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](https://borda.github.io/lucid-YOLO/work_packages/wp-141/) through [WP-144](https://borda.github.io/lucid-YOLO/work_packages/wp-144/) are still unstarted, [WP-141](https://borda.github.io/lucid-YOLO/work_packages/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.


## Phase 14 — what each row does, and where its boundary is

<a id="phase-14-rows"></a>

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.

<a id="wp-154-scope"></a>

**[WP-154](https://borda.github.io/lucid-YOLO/work_packages/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.

<a id="wp-154c-scope"></a>

**[WP-154c](https://borda.github.io/lucid-YOLO/work_packages/wp-154c/) — the freeze mechanism, fixed rather than the files, a third time.** [WP-154](https://borda.github.io/lucid-YOLO/work_packages/wp-154/) hit [WP-132](https://borda.github.io/lucid-YOLO/work_packages/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.

<a id="wp-154b-scope"></a>

**[WP-154b](https://borda.github.io/lucid-YOLO/work_packages/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](https://borda.github.io/lucid-YOLO/work_packages/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.

<a id="wp-155-scope"></a>

**[WP-155](https://borda.github.io/lucid-YOLO/work_packages/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](https://borda.github.io/lucid-YOLO/work_packages/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`.

<a id="wp-155b-scope"></a>

**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.

<a id="wp-156-scope"></a>

**[WP-156](https://borda.github.io/lucid-YOLO/work_packages/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](https://borda.github.io/lucid-YOLO/work_packages/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.

<a id="wp-157-scope"></a>

**[WP-157](https://borda.github.io/lucid-YOLO/work_packages/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](https://borda.github.io/lucid-YOLO/work_packages/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](https://borda.github.io/lucid-YOLO/work_packages/wp-154b/) already moved it, which is why nothing frozen moves here.

<a id="wp-158-scope"></a>

**[WP-158](https://borda.github.io/lucid-YOLO/work_packages/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](https://borda.github.io/lucid-YOLO/work_packages/wp-156/) deleted — a count set when the package owned the whole augmentation stack and raised by [WP-147](https://borda.github.io/lucid-YOLO/work_packages/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.
