Skip to content

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

Engineering log · Phase 15 — Audit remediation, v0.8.0

🔗 Depends on WP-165b · Needed by WP-175

⬅️ WP-171 · Roadmap · WP-173 ➡️

WP-172 — every merge path keeps every modality

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), 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 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. 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.