Skip to content

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

Engineering log · Phase 14 — Augmentation upstreaming to fuse, v0.7.0

🔗 Depends on WP-155b · Needed by WP-158

⬅️ WP-155 · Roadmap · WP-157 ➡️

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.