# 📦 WP-157 — `refine(data): rotated boxes and flip pairs delegate upstream`

> 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-154b](https://borda.github.io/lucid-YOLO/work_packages/wp-154b/) · Needed by [WP-158](https://borda.github.io/lucid-YOLO/work_packages/wp-158/)
>
> ⬅️ [WP-156](https://borda.github.io/lucid-YOLO/work_packages/wp-156/) · [Roadmap](https://borda.github.io/lucid-YOLO/ROADMAP/) · [WP-158](https://borda.github.io/lucid-YOLO/work_packages/wp-158/) ➡️


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

