# 📦 WP-156 — `refine(data): the affine reduces to ranges over a fuse segment`

> Engineering log · [Phase 14 — Augmentation upstreaming to `fuse`, v0.7.0](https://borda.github.io/lucid-YOLO/engineering/phase-14-augmentation-upstreaming/)
>
> 🔗 Depends on WP-155b · Needed by [WP-158](https://borda.github.io/lucid-YOLO/work_packages/wp-158/)
>
> ⬅️ [WP-155](https://borda.github.io/lucid-YOLO/work_packages/wp-155/) · [Roadmap](https://borda.github.io/lucid-YOLO/ROADMAP/) · [WP-157](https://borda.github.io/lucid-YOLO/work_packages/wp-157/) ➡️


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

