# 📦 WP-172 — `fix(data): every merge path keeps every modality`

> Engineering log · [Phase 15 — Audit remediation, v0.8.0](https://borda.github.io/lucid-YOLO/engineering/phase-15-audit-remediation/)
>
> 🔗 Depends on [WP-165b](https://borda.github.io/lucid-YOLO/work_packages/wp-165b/) · Needed by [WP-175](https://borda.github.io/lucid-YOLO/work_packages/wp-175/)
>
> ⬅️ [WP-171](https://borda.github.io/lucid-YOLO/work_packages/wp-171/) · [Roadmap](https://borda.github.io/lucid-YOLO/ROADMAP/) · [WP-173](https://borda.github.io/lucid-YOLO/work_packages/wp-173/) ➡️


## WP-172 — every merge path keeps every modality

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

**`CopyPaste._merge` rebuilt `Targets` without two of its columns.** It passed `boxes`, `labels`, `polygons`, `keypoints` and `keypoint_vis` and no more, so `difficult` and `rboxes` were dropped. The `difficult` loss is the quieter of the two: `Targets._resolve_difficult` expands an omitted column to one `False` per instance, so nothing raises — a destination carrying `difficult=[True]` merged with a source `[False, True]` returns `[False, False]` where `[True, False]` is correct, and under A48 that turns every ignorable ground truth into an ordinary false positive. `difficult` is now carried by the same `index` the boxes take, which is total by construction since `_resolve_difficult` guarantees both sides are length-N. `rboxes` is deliberately **not** carried, and the docstring says so, so the omission is not re-read as this same bug later: a paste transfers a rasterized polygon mask and the oriented path carries no polygons ([WP-056](https://borda.github.io/lucid-YOLO/work_packages/wp-056/)), so refusal is the fix rather than a transfer that would claim to paste something the transform cannot paste.

**The rotated refusal did not hold through the seam [WP-147](https://borda.github.io/lucid-YOLO/work_packages/wp-147/) made public.** `_reject_rboxes` was called only from `__call__`, never from `apply`, so a caller building `CopyPasteParams` directly with a rotated destination reached `_merge` and lost the modality silently — the exact failure the guard exists to prevent. `apply` now calls it as its first statement. `__call__` keeps its own copy on purpose: the guard has to fire before `sample` consumes a generator draw, so moving it rather than duplicating it would have changed RNG ordering on the sampling path. The `Raises:` section records both the duplication and its reason.

**The flip never re-clipped.** `_mirror_targets` reflected about `width - 1` and returned. The COCO reader clamps boxes to `[0, width]`, so a box legitimately touching the right edge mirrors to `x1 = (W-1) - W = -1`: at `W=10`, `[4, 1, 10, 3]` came back as `[-1.0, 1.0, 5.0, 3.0]`. `HorizontalFlip` is the last stage of the train pipeline, so that `-1.0` is what the assigner receives. The clamp is on `x` only. A full-canvas clip was implemented first, using `fuse-augmentations`' own `clip_bbox_xyxy`, and it failed the class doctest by trimming `y2` from 2 to 1 — which is the argument for the narrower fix rather than a reason to repair the fixture: a mirror moves only `x`, so clamping `y` edits geometry this transform never touched, makes the flip's output depend on a height it does not otherwise read, and silently trims a box that arrived off-canvas from some earlier stage. Keypoints, rotated boxes and polygon rings are left unclipped, each for a stated reason — a mirror is an isometry and cannot push a wholly on-canvas box off it, clamping an rbox centre while keeping its extents describes a *different* rectangle rather than a clipped one, and nothing consumes rings raw before rasterization, which clips at the grid.

**The convention was stated but unasserted, which is the half that matters.** Correcting the row's own framing on evidence: the `W-1` convention *is* implemented and *is* documented, in `augment.py`'s class docstring and in `rotated_aug.mirror_rboxes`, both citing [WP-154b](https://borda.github.io/lucid-YOLO/work_packages/wp-154b/). What was missing is a test that could fail. The existing ones restate the formula `(width - 1) - src`, which cannot distinguish a correct implementation from an expectation that drifted with it. The new test asserts by consequence instead: a box drawn tightly around a bright two-column block must still bound that block after both are mirrored. Under the `W` convention the block lands at columns `{6, 7}` while the box is carried to `[7, 8]`, and the test fails. It passed on first run before any fix — which is the evidence that the choice was already right and only unguarded.

**Train and val COCO label spaces were derived independently and never compared.** `_build_coco_splits` constructs two `CocoDetectionDataset`, each running `_build_category_maps` over its own file's `categories`, sorting ids and numbering from zero. Given val `[1, 7]` against train `[1]`, val maps `7 -> 1` while train has no label `1`: the model trains against one numbering and is scored against another, reporting a plausible number the whole way. `_check_shared_label_space` now raises before either split is served, naming the two one-sided sets separately so the message identifies which file is the odd one. Comparing id *sets* is sufficient and not merely necessary, since the mapping is a function of the sorted id set. The YOLO path needs no equivalent — `YoloDetectionDataset.from_root` reads one `data.yaml` for both splits, so the label space has a single source and cannot disagree with itself.

**No golden moves, and the escalation clause is vacuous.** All 43 pass. A zero-tolerance sweep of 447 metrics across 48 files finds exactly one moved entry, `rotated_rbox_area` at `96.1934 -> 96.1935`, in the live `aug_invariants.json` and in `frozen/0.6` and `frozen/0.7` — and it is **not** this row's: running the pristine main-tree `lucid_yolo` against the same unmodified producer reproduces it. The unrounded value is `96.1934585571289`, sitting `8.6e-06` above the 4-decimal rounding boundary that `round(x, 4)` resolves to `96.1935`; it was frozen when it sat just below. That is environment drift predating WP-172, five thousand times inside the `0.5` tolerance, and it is recorded here because the next re-freeze would otherwise read it as this row's consequence. The strongest evidence that the flip fix itself moves nothing is `data_checksums.json`, bit-identical across all ten metrics: it runs the whole WP-015 pipeline under a fixed seed and is the only golden that exercises `HorizontalFlip` in-pipeline, `aug_invariants`' producer containing no flip call at all.

**One fixture was pinning the defect.** `test_double_flip_identity` ran on a `torch.rand(3, 4, 10)` canvas while its `_targets()` fixture carries boxes out to `x=55` — geometry five times off-canvas. The involution is a property of the mirror, and a re-clip necessarily breaks it for geometry that started outside the canvas, so the test as written could only pass while nothing clipped. The canvas moved to `64x64`, which keeps the test's intent and makes its geometry coherent.

**What is deferred, and why.** Two files can share an id set and disagree on what each id names — a real label-space disagreement with an empty symmetric difference. That check is not implemented: `CocoDetectionDataset` does not retain category names, so it would mean widening the reader's public surface, which is outside this row. Recorded rather than dropped.

