Skip to content

📦 WP-169 — fix(assign): alignment weights survive zero-metric ties and low overlap

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

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

⬅️ WP-168 · Roadmap · WP-170 ➡️

WP-169 — alignment weights survive zero-metric ties and small overlap

topk ranked the ties globally, not within each ground truth. _select_topk ran topk over an alignment metric that is zero at non-candidates and zero at a candidate whose prediction misses — the state a freshly initialized head is in, since decode_ltrb applies no non-negativity and inverted distances decode to zero-IoU boxes. With a ground truth's whole row tied at zero, topk filled its k slots from the global tie order, which need not contain a single one of that ground truth's candidates, and the intersection with candidate_mask then returned zero positives for a ground truth that had four. Measured on a 4×4 stride-4 grid with one real ground truth whose candidates are {10, 11, 14, 15} — deliberately not the low indices the tie order favours — every one of topk ∈ {1, 3, 4, 6} returned 0 positives where min(k, c) was expected. Ranking over align_metric.masked_fill(~candidate_mask, -1.0) fixes it; -1 is the sentinel _resolve_conflicts already uses and is safe because t is non-negative by construction. The intersection is kept rather than dropped: when k > c the surplus slots land on the -1 fill and are removed there.

An absolute floor under a sixth power. The target normalization divided by t_max + eps with eps = 1e-9, but t_max = s · u_max⁶ collapses as the sixth power of the overlap, so the floor is eventually larger than the quantity it guards. Parametrized over u_max ∈ {1.0, 0.5, 0.1, 0.01}, the best anchor's weight — which is u_max by definition, since t_max is its own row's maximum — came out 0.999999998, 0.49999994, 0.0998004 and 4.9975e-06: correct at full overlap, 2001× too small at IoU 0.01. A positive trains as background while fg_mask still calls it foreground. The denominator is now floored at torch.finfo(dtype).tiny, which only ever guards a true 0/0 — and there align_pos is zero too, so the product is zero rather than NaN.

The repo's own frozen data was the strongest evidence. tests/ptl/prechange_detect_step.json pinned, at initialization, train/o2m_box at 4.12e-09 and train/o2m_l1 at 3.17e-08 — the one-to-many branch's entire localization objective, weighted into nothing, roughly 1.9e6× suppressed. That snapshot was written to detect objective drift across WP-088 and did that job; what it also did, unremarked for eight releases, was pin the defect. tests/assign/test_rotated_assign.py carried the same fingerprint: image 1 anchor 5 is its ground truth's best anchor, so its weight is exactly u_max = 0.25, and the snapshot stored 0.249998539686203.

Both frozen sets were re-captured, and the ruling is recorded here rather than in ESCALATION.md. Nothing under goldens/ moves — all 43 pass, and a zero-tolerance sweep of all 447 metrics across 48 files confirms every assignment_cases.json metric is bit-identical in the live copy and in all six frozen sets. Trigger 4 is "a change would require altering a frozen golden", and no change does, so no escalation entry was opened. The two tests/ snapshots are a different question, decided deliberately: the pre-change loss values were re-captured by replaying the test's exact sequence under the same seeds and set_num_threads(1), with seeds, config, gains and the 714 state_dict_keys unchanged and a recapture_reason field recorded in the file. The rotated weights were derived from the frozen ones by the closed-form factor rather than read off the code under test, and agree with the current output to ≤2 ULP.

One test passed only because of the defect. test_rotated_candidacy_reaches_assigner asserted the rotated call produced strictly fewer positives than the axis-aligned one. It passed — because the smaller rotated candidate set simply lost more slots to the global tie fill. With H-16 fixed both saturate at min(k, c) = k, so the count stopped discriminating while set membership still does; the assertion moved to not torch.equal(rotated.fg_mask, axis_aligned.fg_mask), which still fails if gt_rboxes is dropped, since the two calls then become identical. Probed over 40 independent seeds: the new assertion holds 40/40 and the old one 0/40, both counts pinned at exactly 10 every time. The old assertion is not merely weakened — it is now never true.

Attribution was measured, not argued. Applying one fix at a time separates them cleanly: with H-16 reverted, test_detect_step_reproduces_pre_change_snapshot yields 24.32134246826172, identical to all digits with both fixes, so H-16 contributes nothing to that scene; the three bit-identity failures in test_rotated_assign.py also survive H-16's reversion and move by exactly (t_max + 1e-9) / t_max, and only test_rotated_candidacy_reaches_assigner passes with H-16 reverted.

What this row hands to WP-167. H-17 changes the objective's weighting on the first step of every trained run, so goldens/gpu/overfit_micro_*.json and goldens/gpu/shapes_regression_det.json are expected to move. Those five files have no copies under goldens/frozen/, so recomputing them is an ordinary FREEZE=1 re-freeze on an accelerator rather than a §7 question — but it must happen with 169 and 172 both landed, which is why it belongs to 167 and not here.