🔗 Phase 14 — Augmentation upstreaming to fuse, v0.7.0¶
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_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/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_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.
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.