📦 WP-122 — feat(models): keypoint head stem and decode composition¶
Engineering log · Phase 12 — Keypoint detection, v0.5.0
🔗 Depends on WP-062, WP-120 · Needed by WP-123, WP-132, WP-137, WP-139
WP-122 — a stem is architecture, its sigma is not¶
DualDetectionHead gains a third opt-in stem set, num_keypoints, following num_coeffs'/predict_angle's exact shape: absent by default, so the accepted detection/segmentation/oriented module trees are untouched byte-for-byte. _build_keypoint_stem reuses _stem_width (A28's channels // 3) rather than a bespoke width the way _build_coeff_stem already does -- unlike the angle stem, no R1 Table S11 or any allowlisted source measures a keypoint task at all, R14 being registered "future pose milestone only", so there is nothing to fit a dedicated width against [struck 2026-09-17 (D22): R1 Table S10 measures exactly this task, and at its protocol this stem is 13.0% / 26.5% under the paper's n params / FLOPs — A77]. The final 1x1 emits 4 * K raw channels in point-major order (x, y, sigma_x, sigma_y per point) and nothing follows it -- coordinates are unbounded offsets exactly like the angle branch, and per the scope boundary below, sigma is equally raw.
The scope line that mattered most here was what this WP explicitly does not decide: R14's RLE loss needs sigma > 0 for its flow's density, but the specific function that guarantees that (softplus, exp, a scaled sigmoid) is tied to the loss equations themselves, and AGENTS.md sec. 6 reserves loss/assignment/optimizer/rotated-geometry modules for hand-written implementation from the equations, not generated code. A65 records the split explicitly: DualDetectionHead and the new decode_keypoints (heads/keypoint.py, decode_ltrb's anchor-centre/stride convention generalized from a four-value ltrb offset to a direct two-value per-point offset) both leave sigma completely untouched, and WP-123 is where the mapping is decided, from R14 directly. The codex spec for this WP said so in as many words -- "if you find yourself reaching for such a transform, stop" -- and the delegated diff held the line: heads/keypoint.py's decode function does not even accept a sigma argument, and the test suite's test_sigma_output_remains_raw_unbounded_and_point_major drives the stem's output convolution to explicit large-magnitude values and asserts they survive unchanged, which would fail against any accidentally-added activation.
Delegated to codex:codex-rescue --write as WP-120/121 were, but this one surfaced a real regression its own sandboxed test run correctly caught and correctly declined to fix: _DetectionBranch.forward's return tuple grew from four elements to six (cls, box, coeff, angle to ..., keypoints, keypoint_sigma), and three call sites in models/build.py (Detector, OrientedDetector, Segmenter) unpack that tuple positionally rather than through DualHeadOutput's named fields -- cls, box, _, _ = self.o2o(...) and its two siblings. The spec had excluded build.py from scope entirely, which was the actual mistake: those three lines are a direct, unavoidable consequence of widening a tuple those call sites already depend on, not a new feature crossing a WP boundary. Codex's own gate ran tests/models/ as a regression check, found 22 failures and 6 errors all tracing to those three lines, correctly refused to touch the excluded file, and reported the gap rather than guessing. Fixed by hand in three one-line edits (widening each unpack to six positions, matching the new tuple width) -- mechanical enough, and small enough, not to warrant a second delegation round; make gate (2290 passed, 27/27 goldens, including all four params_flops_* goldens the widened tuple had been silently breaking) confirms nothing else depended on the old width.