# 🕺 Phase 12 — Keypoint detection, v0.5.0


## WP-120 — a mirror does not know anatomy

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

Phase 12 opens on a container change rather than a model change: `Targets` gains `keypoints` (`(N, K, 2)` float32) and `keypoint_vis` (`(N, K)` int64), and `HorizontalFlip` -- the one existing transform this WP's scope covers -- gains the matching mirror. Every other geometric transform (`RandomAffine`, `MosaicAssembly`, `Mixup`/`CopyPaste`, `Letterbox`) is untouched; keypoint support there is a later WP's concern, not this one's.

K is a constructor argument the way the class count already is: `keypoints`/`keypoint_vis` follow `polygons`' existing "count is 0 or N" convention (the shared instance axis every box-aligned modality already uses) rather than `rboxes`' independent-axis-plus-separate-mask one, since a keypoint set belongs to one box the way a polygon ring does, not to a second geometry entirely. `concat` additionally rejects differing `K` across items that do carry keypoints -- a check `_concat_polygons` has no analogue for, since ring length is per-instance there and K is fixed per dataset here, so two items disagreeing on K is corruption, not a valid ragged shape.

The harder decision is what `HorizontalFlip` does with left/right identity. A mirror reflects every point's x-coordinate unconditionally -- that part is geometry, not anatomy, and needed no new assumption. But swapping *which* index is now "left knee" after the flip is anatomical, dataset-specific knowledge the transform has no business hardcoding into a K-generic container's own mirror: nothing in this repository's K-point design says K=17 or names a COCO ordering, and baking the pairing into `HorizontalFlip` would silently narrow "generic K-point task" back down to "COCO human pose" the first time this class ran. `keypoint_flip_pairs: list[tuple[int, int]] | None = None` is the resolution -- a caller-supplied swap map, `None` meaning "mirror coordinates, keep column identity", the correct default for a K-point task with no left/right symmetry. A64 records COCO's own 17-point pairing as the value the demo dataset will actually supply, but the row is marked `open`: [WP-121](https://borda.github.io/lucid-YOLO/work_packages/wp-121/)'s COCO datamodule is what passes the concrete list in, not this one.

Implementation delegated to `codex:codex-rescue --write` against a design spec fixing the exact field names, shapes, validation rules, and test scenarios up front, rather than leaving the K-point/anatomy split to be discovered mid-implementation -- reviewed diff-by-diff against that spec before landing; one docstring grammar slip (`"A supplied, a dataset keypoint pair map..."`) was the only correction needed.

______________________________________________________________________



## WP-121 — a reader is not a wiring

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

`CocoDetectionDataset` gains `keypoints: bool = False`, matching `oriented`'s existing shape exactly: opt-in, defaulting to "leaves the reader exactly as it was". When set, each retained annotation's flat COCO `keypoints` field (`[x, y, v, ...]`, [WP-120](https://borda.github.io/lucid-YOLO/work_packages/wp-120/)'s `Targets.keypoints`/`keypoint_vis` on the receiving end) is split by a new `_parse_keypoints` helper into `(K, 2)` coordinates and `(K,)` visibility, stacked across an image's retained instances, and threaded into whichever of the axis-aligned or `_oriented_targets` construction paths `_build_targets` takes -- both grew optional `keypoints`/`keypoint_vis` parameters rather than staying axis-aligned-only, so a caller who sets both `oriented=True` and `keypoints=True` does not crash even though this repository's real pose data is never oriented. Crowd and RLE-skipped annotations are excluded from keypoint parsing exactly as they already are from boxes/labels/polygons -- the same retained-annotation loop populates all four, so the axes cannot drift apart. A mismatched `K` across two instances of one image raises naming the file and both observed counts, the same "cannot describe a rectangular tensor" failure `_oriented_targets` already raises for a non-quadrilateral ring.

[WP-120](https://borda.github.io/lucid-YOLO/work_packages/wp-120/)'s own log entry above states "WP-121's COCO datamodule is what passes the concrete [flip-pair] list in" -- that was wrong when written, not merely superseded: this row's DoD names only `tests/data/test_coco.py`, and the roadmap row's Scope never promised datamodule or `HorizontalFlip` wiring. Left standing in [WP-120](https://borda.github.io/lucid-YOLO/work_packages/wp-120/)'s own entry per this log's convention of not editing a historical paragraph to match a later correction, but A64 in `docs/ASSUMPTIONS.md` is corrected in this WP's own commit, since the assumption row itself (not a narrative entry) still needs to be accurate going forward: it now states that no roadmap row currently owns constructing `HorizontalFlip(keypoint_flip_pairs=...)` for a pose task, rather than pointing at a WP whose actual scope never included it.

Delegated to `codex:codex-rescue --write` against a fixed design spec, as [WP-120](https://borda.github.io/lucid-YOLO/work_packages/wp-120/) was. One environment-only test failure surfaced during codex's own verification pass (`test_worker_loader_augments_differently_each_epoch`, a pre-existing, unrelated DataLoader-worker test failing because codex's sandbox blocks `torch_shm_manager`'s multiprocessing calls) -- root-caused correctly by codex itself (failure occurs during worker process startup, before any WP-121 code runs; the same 62-test file otherwise passed) and left for the lead's full-access environment rather than patched around. Confirmed independently: the same test file passes 63/63 outside the sandbox, and the full `make gate` (2282 passed, 27/27 goldens) is green.

______________________________________________________________________



## WP-122 — a stem is architecture, its sigma is not

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

`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](https://borda.github.io/lucid-YOLO/work_packages/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](https://borda.github.io/lucid-YOLO/work_packages/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.

______________________________________________________________________



## WP-123 — a loss written by hand, from equations fetched three times

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

Not delegated. AGENTS.md sec. 6 reserves loss/assignment/optimizer/rotated-geometry modules for hand-written implementation from the equations -- the highest AGPL-regurgitation-risk surface -- and R14's RLE loss is squarely a loss module, the first one this project's Phase 12 work has touched. Every other WP in this phase (120/121/122) went to `codex:codex-rescue`; this one did not, and no part of it was delegated except (implicitly) nothing -- the module, its RealNVP flow, and its test suite are all written directly against R14's own equations, fetched from arXiv's HTML rendering (the allowlisted primary-source endpoint) across three separate targeted `WebFetch` passes rather than one broad read, specifically to cross-corroborate the one detail secondary summarization is most likely to get subtly wrong: which direction the flow runs and what sits at its latent end. All three fetches agreed on Eq. 8 verbatim, R14 sec. 4's `K=6`/`Lfc=3`/`Nn=64`/Leaky-ReLU conditioner config, and Eq. 12's affine coupling formula; the third fetch additionally recovered R14 sec. 3.2's `z_bar ~ N(0, I)` latent statement and sec. 3.3's `sigma_hat` sigmoid sentence -- resolving A65, which [WP-122](https://borda.github.io/lucid-YOLO/work_packages/wp-122/) had deliberately left open rather than guessed at. No detection-repository code, including R14's own official implementation, was opened at any point (AGENTS.md sec. 7).

**The loss (Eq. 8, `log s` dropped per R14's own stated implementation choice):** `L_rle = -log Q(x_bar) - log G_phi(x_bar) + log sigma_hat`, where `x_bar = (mu_g - mu_hat) / sigma_hat` is the standardized residual, `Q` a fixed unit Laplace (no parameters, R14's "preset density"), and `G_phi` the density the RealNVP flow induces over `x_bar` via the standard change-of-variables identity. `Q`'s gradient reaching `mu_hat`/`sigma_hat` independently of the flow is R14's own stated "gradient shortcut" motivation for the additive split (sec. 3.2) rather than an incidental property -- `test_gradient_shortcut_reaches_mu_and_sigma_with_flow_frozen` freezes every flow parameter and confirms the regression head still receives nonzero gradient, a direct test of that specific paper claim rather than a generic backprop smoke test.

**The flow (Eq. 12, Appendix A):** six stacked affine coupling layers over the 2-D residual, each holding one coordinate fixed and transforming the other by a scale-and-shift pair from a 3-layer/64-unit/Leaky-ReLU conditioner. The paper states the coupling formula but not that the fixed/transformed partition alternates between layers -- that is RealNVP's own standard construction (Dinh et al., which R14 cites rather than restates), and without it one of the two coordinates could never move at all under the whole stack, making it degenerate rather than a valid bijection over R^2. Implemented as `synthesize` (the generative `z -> x` direction) and `invert` (`x -> z`, RealNVP's defining closed-form property, needed for density evaluation) as two directions of the same module rather than one direction plus a solved inverse.

**What a unit gate can and cannot prove here:** the mechanism-claim validation this row's roadmap text (and R14 itself) cares about -- RLE beating an OKS-only ablation -- needs the [GPU][PRINCIPAL]-gated [WP-125](https://borda.github.io/lucid-YOLO/work_packages/wp-125/) tier run this session cannot execute. Every property a unit test *can* pin was therefore treated as load-bearing rather than incidental: `test_flow_density_integrates_to_one` numerically integrates the flow's induced density over a 161x161 grid and checks it lands within 0.05 of 1.0 -- the single most discriminating check of change-of-variables bookkeeping, since a base-density term without its accompanying log-determinant (or vice versa) passes every other test in the file but does not integrate to a proper probability density. `test_log_determinant_matches_autograd_jacobian` compares the analytic per-layer log-determinant against `torch.autograd.functional.jacobian`'s own determinant, independently of `invert` entirely -- self-consistency between `synthesize` and `invert` alone could not catch a sign error that happened to cancel between the two, and this check cannot make that mistake since it never calls `invert`.

**Design decisions this row records rather than silently makes (A65, A66):** sigma's positivity mapping (deferred by [WP-122](https://borda.github.io/lucid-YOLO/work_packages/wp-122/)/A65) resolves here as `torch.sigmoid`, applied in the loss rather than the head, per R14 sec. 3.3's own stated sentence -- not chosen freehand. Visibility masking (A66, new): COCO `v=0` is excluded unconditionally (no ground truth exists to train against), `v=1`/`v=2` both contribute equally, since COCO's own OKS protocol already scores against `v>=1` regardless of occlusion and training against a narrower set than the metric evaluates against would be the inconsistent choice, not the safe one.

**A structural fix along the way:** every other loss in this repo (`instance_mask_loss`, `ciou_loss`, `probabilistic_iou`, ...) is a parameter-free pure function over already-produced network outputs. RLE's flow is the first loss component in this project with trainable weights of its own, and a first draft reached for a lazily-built module-level global singleton to hold it -- convenient for a bare function's signature, but a violation of this project's own "no global mutable state" rule, undiscoverable by an optimizer's `.parameters()`, and unable to move devices or checkpoint the way a registered submodule does. Corrected before landing: `RLELoss` is an `nn.Module` owning the flow as `self.flow`, the shape every other stateful component in this codebase (`DualDetectionHead`, `ProtoNet`) already takes.



## WP-124 — a premise checked against the installed package, not the roadmap text

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

Delegated to `codex:codex-rescue`, `--write`, scoped to `src/lucid_yolo/eval/coco_eval.py` and a new `tests/eval/test_keypoint_eval.py` only -- no decoder, model, CLI, roadmap, or assumption edits, no commit, no full `make gate` inside the sandbox. Before dispatch this row's own Scope text was verified against the installed environment rather than trusted: `torchmetrics==1.9.0`'s `MeanAveragePrecision.__init__` accepts `iou_type` values of only `"bbox"`/`"segm"` (checked via direct signature/source inspection, not memory), so the row's premise -- "keypoint mAP wired the way WP-069 wired box mAP" -- was factually wrong for what is actually installed. This did not rise to an AGENTS.md sec. 4 escalation: the row's actual intent, a COCO-OKS-faithful engine wired against [WP-121](https://borda.github.io/lucid-YOLO/work_packages/wp-121/)'s ground truth and [WP-122](https://borda.github.io/lucid-YOLO/work_packages/wp-122/)'s decode, is still satisfiable, just through a different already-a-dependency library (`faster_coco_eval`, R23) called directly instead of through torchmetrics's wrapper. The spec handed to codex stated the corrected mechanism up front and explicitly instructed it to re-verify the premise itself against the installed package before writing any code, rather than trust the prompt.

**What codex built:** `keypoints_to_predictions` adapts a fixed-size `(B, N, K, 2)` decode-shaped batch into COCO prediction dicts, filtering the score-zero padding rows a fixed decoder shape produces and remapping contiguous class labels back to original COCO category ids -- the same shape of adapter `detections_to_predictions` already provides for boxes, not a new pattern. `evaluate_keypoints` builds a minimal in-memory COCO ground-truth/result document pair from [WP-121](https://borda.github.io/lucid-YOLO/work_packages/wp-121/)'s `keypoints`/`keypoint_vis` tensor convention directly (`v == 0` excluded from both OKS matching and the per-instance bounding-box area, `v >= 1` included -- A66 applied here exactly as [WP-123](https://borda.github.io/lucid-YOLO/work_packages/wp-123/) defined it, not re-decided) and drives `faster_coco_eval.COCOeval_faster(iouType="keypoints", kpt_oks_sigmas=...)` through its native `evaluate()` / `accumulate()` / `summarize()` sequence, one-shot rather than `DualPathEvaluator`-streaming since no keypoint decode pipeline exists yet for a streaming caller to feed. `COCO_KEYPOINT_OKS_SIGMAS` names R12's 17-point sigma table as a module constant rather than leaving it to the library's own `Params.setKpParams` default -- the values are identical today, but a project-owned constant keeps the number this project reports against from silently moving if the dependency's own default ever changes.

**Independent re-verification (lead, outside the sandbox):** `tests/eval/test_keypoint_eval.py` -- 5 passed (padding-drop/category-remap adapter contract; a perfect-match OKS oracle scoring near-1.0 AP; a far-miss oracle scoring exactly 0.0 AP, ruling out a category-only match; the empty-input all-zero ten-key contract, avoiding a degenerate call into `COCO.loadRes`'s empty-list case; corrupting only a `v=0` target coordinate and asserting the ten stats are bit-for-bit unchanged, reusing [WP-123](https://borda.github.io/lucid-YOLO/work_packages/wp-123/)'s own `TestVisibilityMasking` pattern). `coco_eval.py`'s module doctests -- 6 passed, 1 pre-existing skip. `mypy` on the file -- clean. Full `tests/eval/` regression -- 216 passed (211 pre-existing + 5 new, matching codex's own reported delta exactly). Full `make gate` -- 2319 passed, 37 skipped, 3 deselected, 27/27 goldens, run twice to confirm no stray failure line in the fuller output.

**No new assumption recorded.** The spec instructed codex not to add an `ASSUMPTIONS.md` row itself and to flag anything it found instead; nothing surfaced that qualifies -- the sigma-table caution above is a documented fragility comment, not an open gap, since the value is pinned as a citable constant rather than inherited implicitly either way.



## WP-121b — a segmentation ring only an oriented reading actually needs

<a id="wp-121b"></a>

Surfaced during Phase 12's own dogfooding rather than invented: validating a fuse-augmentations `Task.KEYPOINTS` COCO export against [WP-121](https://borda.github.io/lucid-YOLO/work_packages/wp-121/)'s reader (an offline check of the user's own upstream package, ahead of wiring a fixture from it) found that every annotation in the export parsed to zero instances. Root cause traced two layers deep. The proximate one was upstream -- fuse-augmentations' `CocoWriter` never emitted a `segmentation` field for `Task.KEYPOINTS`, even though the outline polygon it would need was already computed for every shape regardless of task -- and was fixed there, by the user, not here (AGENTS.md sec. 6/7: no fix to a dependency's own source from this repo). The deeper one was this repo's own: `_parse_annotation` ([WP-014](https://borda.github.io/lucid-YOLO/work_packages/wp-014/)'s original reader) required a *parseable* `segmentation` ring to retain any annotation at all, in **every** mode -- plain, oriented, and [WP-121](https://borda.github.io/lucid-YOLO/work_packages/wp-121/)'s own keypoints reading alike -- so the upstream bug's fix alone was not sufficient; a keypoints-only or plain-detect-only export genuinely has no ring to give, by design, and the reader treated that as identical to a crowd or RLE annotation it should legitimately drop.

**The fix, scoped precisely.** Two kinds of "no ring" are not the same claim. A `segmentation` value that is *present but unusable* -- an RLE crowd dict, an empty list, a ring under three points -- is never a real countable instance, in any mode; that half of [WP-014](https://borda.github.io/lucid-YOLO/work_packages/wp-014/)'s original policy is untouched, and it is exactly what `TestKeypointParsing`'s own existing fixture already exercises (one RLE annotation, no `iscrowd` flag set, expected to still be dropped) -- a first draft that relaxed on "ring is `None`" alone broke that test immediately (`KeyError` reading `ann["keypoints"]` off an annotation the keypoints branch was never meant to see), which is what forced the narrower `raw_segmentation is not None or self._oriented` condition actually landed. A `segmentation` key that is **absent entirely** is the other case, and it is fatal only for `oriented=True`, which has no other source for the rotated box (`_oriented_targets` derives both `boxes` and `rboxes` from the same quadrilateral ring); a plain or keypoints reading now keeps the box instead. `_build_targets` collapses the whole image's `polygons` to `[]` the moment any one retained instance lacks a ring, rather than leaving a ragged per-instance mix -- `Targets.polygons`'s own documented contract is "0 (no masks) or N," never in between.

**Why this does not reopen the silent-empty-mask hole.** `instance_mask_targets` already raises `ValueError` when `len(target.polygons) != len(target.boxes)` -- a guard written for exactly this shape of mismatch, already in place before this fix, for a different reason ([WP-099b](https://borda.github.io/lucid-YOLO/work_packages/wp-099b/)'s YOLO-layout path, which never carries rings at all). Relaxing the parse-time gate does not need a new guard; it needs this existing one to still be reachable, and it is: requesting `mask_targets=True` against a ring-less image raises there precisely as it does for a genuinely maskless dataset.

**Verification.** `tests/data/test_coco.py::TestRinglessAnnotations` -- a plain reading keeps both ringless boxes with `polygons == []`; an oriented reading against the same file still returns zero instances, locking in that oriented mode's requirement did not loosen; `instance_mask_targets` against the same ringless targets still raises, naming "detection-only annotation cannot supervise masks." Every existing `CocoDetectionDataset` caller re-run as a regression sweep -- `test_coco.py`, `test_coco_eval.py`, `test_annotations.py`, `test_build_dota_tiles.py`, `test_overfit_batch.py`, `test_datamodule.py` -- 175 passed. `coco.py`'s own module doctests and `mypy --strict` both clean. Full `make gate` green (2322 passed, 37 skipped, 27/27 goldens), run against the project's actually-pinned `fuse-augmentations` wheel -- deliberately not the newer local checkout the upstream fix and the keypoints-fixture wiring both need, since that checkout's widened `Shape` vocabulary moves `class_names(ClassMode.SHAPE)`'s span for every task (det/obb/seg included) and would silently drag the frozen `*_num_categories` goldens along with it. That vocabulary-scoping gap is a separate, still-open finding, reported back rather than worked around: `class_names()` has no parameter to restrict its span to a shape family, so det/obb/seg cannot currently pin "geometric shapes only" against a `fuse-augmentations` version new enough to carry keypoints at all. The keypoints-fixture wiring this fix unblocks (`tests/fixtures/synthetic.py::generate_keypoints_fixtures`, a small fixed animal subset for an overfit-friendly category count) is validated end-to-end against a local dev-only override but held uncommitted pending that upstream resolution and the resulting pin bump.



## WP-131 — a dependency fixed upstream, checked rather than trusted

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

The upstream half of [WP-121b](https://borda.github.io/lucid-YOLO/work_packages/wp-121b/)'s "still-open finding" landed same-day: `class_names(class_mode, shapes=...)` (opt-in, defaulting to the old full-vocabulary span when omitted, so every existing caller is unaffected), a mirrored `SyntheticConfig.colors` field, and the `Task.KEYPOINTS` segmentation-emission fix [WP-121b](https://borda.github.io/lucid-YOLO/work_packages/wp-121b/) already depended on. Verified rather than assumed fixed: the first check found the new parameter existed and its own tests passed, but `generate_dataset` -- the convenience function this project's fixtures actually call, not the primitive directly -- still called the bare unscoped `class_names(config.class_mode)` at its one writer-construction call site, so `generate_dataset(..., shapes=DEFAULT_SHAPES, ...)` kept emitting all 16 categories regardless. Reported back with the exact line and the one-line fix it needed; landed within the hour as an amended commit (`74af985`), re-verified empirically both ways -- `shapes=DEFAULT_SHAPES` now yields exactly `['circle', 'rectangle', 'square', 'triangle']`, `shapes=animal_shapes(2)` yields exactly `['duck', 'elephant']` -- before trusting it for anything downstream.

**Pinned by commit, not by version (R21).** `fuse-augmentations`' version string had not moved (`0.10.0.dev0`) across every commit in this saga, and PyPI forbids re-uploading that string, so a bare `uv lock` re-resolve would have kept silently re-pinning the stale 2026-08-02 wheel. `pyproject.toml`'s dependency became a git+commit-SHA pin instead (`git+https://...@74af985...`) -- matching R21's own provenance row, which already cited a fixed commit rather than a floating version, closer to A26's original intent than the PyPI-registry pin the project had drifted to. `uv lock`'s re-resolution incidentally also picked up `onnx`/`onnxruntime` into the lock for the first time; both had been declared in the `dev` dependency group but were absent from `uv.lock` entirely, a pre-existing drift unrelated to this row that a plain `uv sync --frozen` had exposed by removing the two packages someone had evidently `pip install`ed by hand outside the lock.

**Goldens verified unchanged, not re-frozen.** `generate_detseg_fixtures`/`generate_obb_fixtures` gained an explicit `shapes=DEFAULT_SHAPES` argument -- defensive, not corrective, since that was already the implicit default -- so the fix restores exactly the 4-category vocabulary the frozen goldens were built against. Full `make gate` after the pin bump: 2325 passed (2322 plus the two new keypoints-fixture tests plus one doctest), 27/27 goldens passed with no `--freeze` needed, confirming the restored parity rather than assuming it.

**`generate_keypoints_fixtures`.** 12 images, `animal_shapes(2)` -- duck and elephant, not fuse-augmentations' full 12-animal vocabulary -- specifically to keep an eventual overfit-style milestone run's category count low; more animal classes means a wider vocabulary a memorization-style run has to fit before its own metric means anything. Mirrors `generate_detseg_fixtures`/`generate_obb_fixtures`'s existing shape exactly: idempotent, seeded (`KEYPOINTS_SEED`), a matching `keypoints_fixture_dir` session fixture in `conftest.py`. `test_keypoints_set_loads` pins every annotation's landmark table at fuse-augmentations' fixed 16-point animal schema and the category count at or under `KEYPOINTS_ANIMAL_COUNT`; `test_keypoints_generation_deterministic` mirrors the existing det/obb determinism tests byte-for-byte.



## WP-132 — every part built, nothing connected

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

Phase 12 shipped five consecutive rows of keypoint work and never once ran a keypoint. [WP-120](https://borda.github.io/lucid-YOLO/work_packages/wp-120/) added the target channels, [WP-121](https://borda.github.io/lucid-YOLO/work_packages/wp-121/) the COCO reader, [WP-122](https://borda.github.io/lucid-YOLO/work_packages/wp-122/) the head stem, [WP-123](https://borda.github.io/lucid-YOLO/work_packages/wp-123/) the RLE loss, [WP-124](https://borda.github.io/lucid-YOLO/work_packages/wp-124/) the OKS scorer -- each landed green, each with its own passing unit suite, and the composition none of them owned did not exist. `DetectionLitModule`'s task enumeration still read `detect`/`segment`/`obb`, so the keypoint stem was never constructed by anything; `RLELoss` was exported from `losses/__init__.py` and had **zero call sites** outside the module that defined it; `keypoint` appeared nowhere in `ptl/module.py`, `ptl/datamodule.py`, `data/collate.py` or `models/build.py`. The suite was green because every test was a component test, and a component test cannot fail for a wire that was never run.

The trigger for finding it was a question about running the overfit gate, not a failing test -- which is the part worth recording. Nothing would have surfaced this until someone tried to train, because "wire absent" and "wire correct" are indistinguishable to a test suite that only ever exercises the parts. [WP-087](https://borda.github.io/lucid-YOLO/work_packages/wp-087/) and [WP-088](https://borda.github.io/lucid-YOLO/work_packages/wp-088/) exist for exactly this reason and are the precedent this row follows: each is a *training path* row that carries its own overfit-100 golden as DoD, rather than a component row trusting that a later integration will notice. The gate is the thing that makes a wire testable at all.

**Additive, not substitutive.** The RLE term enters the detection total at `keypoint_gain` (A68) alongside the box and class terms, which makes this [WP-087](https://borda.github.io/lucid-YOLO/work_packages/wp-087/)'s structural shape rather than [WP-088](https://borda.github.io/lucid-YOLO/work_packages/wp-088/)'s -- the oriented path is the one task that *replaces* terms, zeroing `box_gain`/`l1_gain` inside the dual loss and respending them on rotated equivalents. Keypoints add a channel of supervision without contesting any existing one, so the arrangement is the simpler one and the `keypoint_gain=0` bit-exactness test is what proves it: with the gain at zero the detection total is bit-identical to a detect run, so the term is genuinely additive and the gain genuinely consulted rather than folded in somewhere upstream.

**The whole transform layer, after a scoping that was wrong twice.** Every geometric transform rebuilds `Targets(...)` from scratch, and all of them dropped the keypoint fields on the floor -- `letterbox.py`, `mosaic.py`, `affine.py`, `mixup.py`. The first scoping fixed `Letterbox` alone and argued the boundary was principled: `_warp_targets` is a pure forward affine that resizes and pads, never crops, so no point can leave the canvas and there is no policy to decide, where mosaic/affine/mixup crop and would force a clamp-vs-demote decision an augmentation-off gate could not settle honestly. Every sentence of that is true and the conclusion was still wrong, because the premise it rested on was never checked: the claim "augmentation is off" was verified against the **validation** loader, which is letterbox-only, and generalized to the training loader, which is not. `_TrainPipeline` runs mosaic, `FusedAffineLetterbox`, mixup, copy-paste, HSV and flip unconditionally. The gate died on `pad_keypoints`' instance-count mismatch on its first batch, and the fix was the whole layer -- which is what made A70 this row's decision instead of [WP-125](https://borda.github.io/lucid-YOLO/work_packages/wp-125/)'s.

**The loud bug was hiding a silent one.** Re-reading the transform path after that failure -- rather than only the line that raised -- turned up `HorizontalFlip` constructed at `datamodule.py:767` with no `keypoint_flip_pairs` argument at all. The flip is K-generic by design and cannot know which index mirrors which, so the omission did not mean "no swap needed", it meant every mirrored sample supervised `flank_left` toward the location of `flank_right`. That trains, converges to a left/right-confused head, and reports nothing: no shape check fails, no loss value looks wrong, and the overfit gate would very likely still have passed. Worse, the first draft of A64 asserted the pairing *was* being threaded through -- a documentation claim written from what the code should have done rather than from what it did, and corrected only because the affine's hard error forced a second reading of the same path. A64 now resolves by reading the pairing off the dataset's own COCO category names (`left_eye`, `flank_left`), which is where the anatomy is actually stated.

**Symbols over animals, for reasons that outrank "simpler".** R21's `SymbolShape` family carries K=7 against the animals' K=16, and its landmarks are placed analytically -- three-quarters of the way from each outline's area centroid toward a named vertex -- rather than traced anatomically. Two consequences beyond the cheaper stem. Only 5 of 7 slots are populated on most symbols, so a symbol run carries `v=0` points natively and exercises A66's visibility exclusion inside a real training loop instead of only in `test_rle_loss.py`. And because the points are analytic, R12's OKS sigma table -- 17 values derived from measured *annotator* standard deviations on human anatomy -- describes a quantity that does not exist here, which is what A67 records: a uniform sigma is not a simplification of the real table but the only honest reading, since any per-point vector this project invented would fabricate a structure the data does not have.

**The pin bump was not fixture-neutral, and the frozen tree could not absorb it.** Reaching `SymbolShape` meant advancing R21 from `74af985` to `0a0cc64`, a commit that also redefines `GeomShape.TRIANGLE` from equilateral to obtuse-scalene and re-centres every outline on its area centroid. Both moved the existing fixtures substantially -- `obb_num_annotations` 44 to 61 (+38.6%), `data_checksums.bbox_area_sum` -45.2%, `image_std_sum` -35.5% -- and eight of 27 goldens went red. Six of those eight were in `goldens/frozen/0.2|0.3|0.4`, the per-release snapshots whose whole purpose (`check_goldens.py`: "a release's frozen goldens staying green *is* the frozen-golden regression") is that current code still satisfies every value a past release pinned. Re-freezing those would have rewritten what v0.2.0 actually produced, so the question went to the principal rather than being resolved by the row that raised it. What decided it was that the failures partitioned exactly: every pure-code frozen golden (`assignment_cases`, `optim_toy`, `params_flops_*`) still passed, and the only two that failed were `data_checksums` and `fixture_checksums` -- the two whose producers render images through an external package. A generator-derived metric can never satisfy "current code must still satisfy every past value" once the generator changes, so those two were never really freezable; they were a category error in what got snapshotted, not a regression. They were removed from the frozen release tree (recoverable in history) and the two current copies re-frozen, leaving 21/21 green and the frozen-golden regression still meaning something for everything it can actually test.

**One flow or two, and a gate that cannot tell.** Wiring the RLE term forced a choice neither R14 nor R1 speaks to: the dual head has two branches, and a normalizing flow is the only trainable-weight loss in this project, so the term either shares one density across both branches or fits one per branch. One shared flow was taken (A69) on two arguments that are each weak alone -- it is what R14 actually describes, one density over one regressor's residuals, where two would assert the branches' errors differ in kind rather than merely in schedule; and it is the smaller parameter count. What matters more than the choice is that this row's own gate cannot check it: an overfit-100 run drives both branches to the same near-zero residual, which is precisely the regime where one flow and two are least distinguishable. So A69 ships `open` with [WP-125](https://borda.github.io/lucid-YOLO/work_packages/wp-125/) named as the first run whose branches disagree enough to tell, rather than being quietly ratified by a green gate that was never sensitive to it. The same caution applies to A68's `keypoint_gain = 1.0`: a memorization run will pass at almost any gain, so passing is not evidence the weight is right.

**Two numerical faults that no component test could have caught.** With the wiring done and the transforms carrying points, the gate still would not run -- it went non-finite inside the RLE flow, and the two causes are the clearest evidence this row could offer for why a *training path* is its own work package.

The first is a frame mismatch (A71). `decode_keypoints` returns absolute input pixels, deliberately and correctly -- it is the deployed decode. R14 forms the residual as `(mu_g - mu_hat) / sigma_hat` and bounds `sigma_hat` into `(0, 1)` with a sigmoid, which it can do because it regresses inside a top-down person crop: the crop is the normalization, so the paper never names a frame and this project had no reason to notice one was missing. Composed, the two make a residual whose *smallest* expressible value for a 40 px error is 40. Measured on the first batch, the keypoint term entered at **1300 of a 1342 total loss** -- three orders above the box and class terms it is supposed to sit beside -- and the flow overflowed on the second step. Normalizing both point tensors into the assigned box's frame first brings the median residual to **1.28**. Nothing about this is visible to `test_rle_loss.py`, which evaluates the loss at `O(1)` residuals because that is the regime the equations describe; the loss was never wrong, its input frame was unstated.

The second is a missing stabilization (A72). [WP-123](https://borda.github.io/lucid-YOLO/work_packages/wp-123/) built the coupling conditioner from R14's stated recipe -- 3 fully-connected layers, 64 units, Leaky-ReLU after each -- and emitted its raw linear output as the log-scale. R14 cites RealNVP for the coupling construction rather than restating it, and the parameterization of `s` is stated only in Dinh et al.: "To compute the scaling functions s, we use a hyperbolic tangent function multiplied by a learned scale", given there explicitly as a stability measure. A raw linear log-scale is not a simplification of that, it is a different layer, and the stack multiplies `exp(log_scale)` six times so the difference compounds. Measured after the frame was fixed: a residual of **27** arrived at the latent as **4e11** and at the loss as **7e22**. The general lesson is narrower than "read the citations": [WP-123](https://borda.github.io/lucid-YOLO/work_packages/wp-123/) read R14 closely and implemented what R14 states, and what bit it was a detail R14 *delegates* -- the one place a paper's own citation is load-bearing rather than contextual.

**The gate's number is small and that is the ruler, not the model.** With both faults fixed the gate runs its 100 epochs clean and scores **OKS AP 0.3357** over 548 instances -- next to the detection gate's 0.995, the oriented gate's 0.939 and the mask gate's 0.815, a number that reads like a failure. It was checked before it was believed, and the check says otherwise. Feeding the ground truth back through the scoring path as the prediction returns exactly **1.0**, so the harness, the sigma vector and the label space are all wired correctly and the ceiling is real. Displacing every point of every instance by a uniform offset then measures the slope: **+1 px scores 0.934, +3 px scores 0.269, +8 px scores 0.000**. On symbols a few dozen pixels across, scored at A67's uniform sigma, OKS AP is a cliff rather than a slope, and 0.3357 is where a landmark error of roughly 3 px lands. The floor was set from that measurement -- 0.30, with the same 0.05 tolerance the oriented gate uses -- rather than from the 0.75 that had been guessed before any number existed. One honest limit on the reading: the probe displaces every point *uniformly*, so it bounds the interpretation without proving it; the same AP is also consistent with a mixture of exact and badly-missed instances, which only a per-instance breakdown would separate.

Both faults were found by running the gate and neither by any test in the suite, which is the same shape as the wiring gap this row opened with. What is deliberately **not** claimed here is that the numerics are now healthy: the residual tail still spikes into the thousands on this data, the A70 off-canvas points meeting a flow whose base density is quadratic in the residual. The tanh removes the compounding feedback path; it does not make the tail bounded, and A72 says so rather than letting a green gate imply otherwise.



## WP-133 — an Unreleased section, and the rule it follows

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

`CHANGELOG.md` had no `## [Unreleased]` section at all. 27 commits had landed since the 0.4.0 release commit (`0a3ef8b`) -- the dev-version bump to `0.5.0.dev0` and [WP-132](https://borda.github.io/lucid-YOLO/work_packages/wp-132/)'s own keypoint training path among them -- and every one of them carried zero changelog entries, so the file read as though nothing had shipped since 0.4.0. Surfaced by a direct question, not a check: nothing in `make gate` or the pre-commit hooks reads `CHANGELOG.md`'s content (`release_guard.py` checks a tag has *a* matching entry, not that intervening commits do), so the gap had no way to fail loud.

**Backfilled from `git log 0a3ef8b..HEAD`**, one bullet per user-visible change, grouped Added/Changed/Fixed to match the existing release sections' own convention. Internal-only commits -- test reorganization, roadmap bookkeeping, CI hook wiring, docstring formatting -- are omitted by the same judgment the existing sections already apply; none of the four shipped 0.x sections carries an entry for its own test suite. The keypoint task's five component WPs (120-124) plus its training-path composition (132) and the ringless-annotation fix (121b) collapse into one Added bullet rather than six, since a changelog reader wants "the keypoint task landed and what it cost to get right," not a WP-by-WP replay -- that granularity already exists in this table and in RESEARCH_LOG.md.

**The section states its own closing rule inline**, per the standing instruction this row also establishes: a commit that sets `__version__` to a plain, non-dev, non-rc value closes the `## [Unreleased]` section and opens the next one. Going forward, a WP's own commit should add its own `## [Unreleased]` bullet rather than the whole set being reconstructed after the fact the way this row had to -- the reconstruction is legible from git history exactly once; a habit of writing it live is not.

**Two pre-existing date mismatches surfaced and are recorded rather than silently corrected.** `[0.2.0] - 2026-08-10` against release commit `eb81caa`'s `2026-08-11`; `[0.4.0] - 2026-08-15` against `0a3ef8b`'s `2026-08-16`. Both one day earlier than the commit, both the same direction -- consistent with the date recorded being the day a run was accepted rather than the day the release commit landed, not a typo in either single case. `[0.3.0] - 2026-08-14` matches its commit exactly, so the convention is not applied consistently even under that reading. Left as a known discrepancy: correcting two dates nobody had complained about is a scope this row did not need, and the pattern is now written down for whoever next touches a release date to decide.

**`configs/pose_smoke.yaml` folded into this same commit, at the user's direction, rather than opening a separate WP.** [WP-125](https://borda.github.io/lucid-YOLO/work_packages/wp-125/) needed a launch config before a Colab session already underway could proceed; writing one is mechanical against the `det_smoke.yaml`/`seg_smoke.yaml` precedent (`task: keypoints`, `num_keypoints: 17` for COCO `person_keypoints`, `keypoint_gain: 1.0` per A68's un-tuned default, explicit `train_ann_file`/`val_ann_file` since `lucid_yolo.data.layout` only ever resolves the `instances_` spelling and `person_keypoints_{split}2017.json` sits beside it in the same annotations archive under a different name). Verified by dry-parse only -- `tests/ptl/test_cli.py::test_config_dry_parses[pose_smoke.yaml]`, the same DoD every other shipped config carries -- not by a training run. This is explicitly not [WP-125](https://borda.github.io/lucid-YOLO/work_packages/wp-125/)'s acceptance: that row is `[GPU][PRINCIPAL]` and its criterion is RLE's mechanism claim against an OKS-only ablation, which a config file cannot settle and this entry does not claim to.



## WP-134 — a checkpoint reported at one eightieth of its own score

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

[WP-124](https://borda.github.io/lucid-YOLO/work_packages/wp-124/) built the OKS scorer. [WP-132](https://borda.github.io/lucid-YOLO/work_packages/wp-132/) built the training path. Nothing ever joined them: `evaluate_keypoints` and `keypoints_to_predictions` had no caller anywhere outside their own module and its tests, and `cli/eval.py` branched on three tasks. [WP-125](https://borda.github.io/lucid-YOLO/work_packages/wp-125/)'s acceptance metric -- OKS keypoint mAP on COCO human pose -- was therefore not producible by any shipped command at all, which is the same shape of gap [WP-132](https://borda.github.io/lucid-YOLO/work_packages/wp-132/) opened with and the reason that row's own log ends where it does.

**The 0.006288 was not a decode fault, and finding that out first is what kept this row small.** A keypoints checkpoint run through `lucid-eval` fell through to `detect_eval` and reported `map=0.006288` against a training loop that had logged `val/mAP=0.48` at the same epoch. That reads as a broken decode, a broken un-letterbox, or a broken assignment, and any of the three would have made this a much larger row. It is none of them. `detect_eval.run` hardcodes `annotations/instances_val2017.json` regardless of what the checkpoint's task or class count is, and COCO's mAP averages average precision over **every category the ground truth contains**. A person-only checkpoint predicts contiguous class 0 and nothing else, so the other 79 categories each hold ground truth, receive no prediction, and score AP=0. The arithmetic closes: `0.006288 * 80 = 0.503`, against the 0.48 the epoch metric logged. Confirmed independently on a synthetic three-category case through `torchmetrics` directly -- perfect predictions in one category of three give `map=0.333` and `map_per_class=[1.0, 0.0, 0.0]` -- before any code here changed.

So the fix is the ground-truth file, not the mathematics. `pose_eval.run` reads `person_keypoints_val2017.json`, which names one category, and the dilution disappears as a side effect of scoring the protocol the checkpoint was trained for. `detect_eval`'s behaviour is untouched: for a `detect` or `segment` checkpoint the GT category count matches the model's own, and its average is over exactly the right set.

**Which row of the report compares.** Training's `val/mAP` is logged from the o2o branch alone -- `_val_decoder.decode_with_indices(head_out.o2o_cls, head_out.o2o_box, ...)` against the datamodule's already single-class targets. The comparable figure in a new report is the **e2e** entry, and the `nms` entry is the dense branch's own answer, expected to sit above it by R1 sec. 4.4's deficit. The runner prints that in words, because a report that does not say which figure it quotes is how the oriented tier confused two of them for a whole release.

**The keypoint mode enters `DualPathEvaluator` exactly where masks did, and differs where the pieces force it to.** Structural detection off `o2o_keypoints`/`o2m_keypoints` rather than a flag, so a detect, segment or obb checkpoint provably takes the path it already took -- those fields are `None` on its head output. Per-branch and per-path, so the E2E path reads the one-to-one branch's points at the E2E decoder's own anchor indices and the dense path reads its own. Two differences from the mask case, both dictated rather than chosen: `decode_keypoints` composes the raw offsets with the whole `(A, 2)` anchor grid and has no per-detection indexing, so this path decodes the dense batch and *then* gathers, where the mask path gathers coefficients and then decodes; and COCO's keypoint protocol is one matching over one document with no incremental update, so the points are accumulated for the split and scored once where the boxes stream. The second is affordable only because the sizes are opposite -- a split of masks is hundreds of gigabytes and a split of points is a few megabytes.

The `PAD_ANCHOR_INDEX` sentinel is `-1`, which `Tensor.gather` accepts and answers with the last anchor's row. It is clamped to a valid position and the result zeroed, the same two steps `_gather_coefficients` takes, and those rows carry score 0 and are dropped regardless -- belt and braces on a failure whose symptom would be a plausible pose at a correct box.

Points invert the letterbox through `Letterbox.inverse_map`, which is already point-shaped: `to_letterboxed_original` is that same call plus the score and class columns of a detection tuple. Reaching for the box function would carry two columns onto coordinates, and writing a second affine would be a second definition of what a letterbox is, which is the rule WP-053a set.

**The ground truth was the part that was quietly wrong, and it was wrong before this row.** `evaluate_keypoints` reconstructed each instance's `bbox` and `area` from the extent of its visible keypoints and forced `iscrowd=0` and `num_keypoints` to a recount. Every one of those four is a **supplied** field in COCO's protocol and each does real work: `area` is what OKS divides by *and* what draws the medium/large bucket boundary, and for a person it is the segmented area rather than the hull of the labeled joints; `iscrowd` and a declared `num_keypoints == 0` are the two flags the backend marks an instance ignored on, verified by reading `COCOeval_faster._prepare` in the installed `faster_coco_eval` 1.7.2 rather than recalling pycocotools' behaviour. Reconstructing them scores a different protocol under the same name. They are now read from the annotation when the target carries them, `annotations_to_target(with_keypoints=True)` carries them, and the reconstruction stays as the fallback -- so [WP-124](https://borda.github.io/lucid-YOLO/work_packages/wp-124/)'s one-shot callers, its doctests and every synthetic fixture keep the exact numbers they had.

The regression test makes the two readings disagree on purpose: an instance whose visible points span 2500 px² and whose annotation declares 20000 belongs to the medium bucket under one reading and the large bucket under the other, and exactly one of the two can be populated. Reconstruction reports `AP_medium=1.0, AP_large=-1.0`; the annotation reports the reverse.

**A K that is not 17 is refused rather than scored.** This protocol's ground truth, its point ordering and its sigma table are all COCO's person schema. A checkpoint predicting some other K has no correspondence to it -- point *i* of the prediction is not point *i* of the annotation -- and R12's sigmas measure annotator variance on joints that are not the ones being predicted. That is A67's argument read from the other side: A67 already refuses to fabricate a per-point table for analytically-placed synthetic landmarks and scores them on its own uniform sigma in its own gate, so this row needs no new assumption and invents no table.

**Validation is synthetic, and says so.** There is no COCO checkout and no GPU on this machine, so nothing here ran against real data. Each claim is separated by construction instead: the two branches carry offsets of opposite sign and each anchor a different magnitude, so of the four combinations of branch and index the evaluator could take, only the right one scores OKS 1.0. Both mutations were checked to fail -- crossing the branches and dropping the padding clamp each turn the suite red. What that does **not** establish is the figure a real pose checkpoint will report; the first honest reading of this instrument is [WP-125](https://borda.github.io/lucid-YOLO/work_packages/wp-125/)'s, on real data, and the arithmetic above is a prediction of what it should show rather than a measurement of it.

One promotion rode along: `_parse_keypoints` in `data/coco.py` becomes `parse_coco_keypoints`, because the evaluation reader now parses the same field off the same file format and a second copy would be free to disagree about the triplet stride or the visibility dtype -- after which a training run and its own acceptance score would read different ground truth from one file.



## WP-135 — a control arm, because one number states no direction

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

[WP-125](https://borda.github.io/lucid-YOLO/work_packages/wp-125/)'s acceptance is not a pose figure. It is RLE's **mechanism** claim: that the *learned* residual density is what buys the improvement, rather than the reparameterization, the sigmoid-bounded per-point scale, or the `log sigma_hat` Jacobian that come with it. A claim of that shape cannot be settled by a run, however good the number, because one figure is equally consistent with the flow doing all the work, none of it, and two terms cancelling. It needs a paired run whose only difference is the mechanism under test -- and the tier had no second arm to pair against. This row is that arm, and nothing else: it builds the instrument and does not read it.

**The comparison is R14's own, not this project's invention.** R14 Table 7 ablates exactly this: "Laplace, learnable variance" -- the NLL over a learned per-point scale with no normalizing flow -- scores **67.4 AP** against full RLE's **70.5 AP** on COCO. Verified this session against the paper itself (arXiv:2107.11291, read through the ar5iv HTML rendering), not recalled. So the direction of effect is already published, and what a paired run here measures is whether it reproduces at this scale on this recipe -- a much smaller question than the one it would be if the baseline were invented. That is also why no assumption id accompanies this row: an assumption records an open project decision made without a source, and this one has a source with numbers in it.

**The loss is `RLELoss`'s formula with one term deleted.** R14 Eq. 8 (with the `log s` normalization R14's own implementation drops) is `-log Q(x_bar) - log G_phi(x_bar) + log sigma_hat` over the standardized residual `x_bar = (mu_g - mu_hat) / sigma_hat`. `LaplaceNLLLoss` is that expression without `G_phi`. Everything else is held: the residual is formed the same way, `sigma_hat` comes through the same R14 sec. 3.3 sigmoid (A65), the `log sigma_hat` term is the same reparameterization log-Jacobian, the sum is over the same two axes, A66's visibility mask is the same mask, and the zero-positives reduction is the same one. Holding all of it fixed is the entire point -- a control that differed in a second place would make the result unattributable, and every one of those clauses is a place it could have differed silently.

The identity is asserted rather than argued: `test_rle_loss_with_exactly_flow_term_removed` scores both losses on shared inputs and checks the difference equals the flow's own log-density term to `1e-5`. A drifted residual, a different sigma activation or a different reduction in either module turns that test red, and nothing else in either suite would have reported it.

**"Learnable variance" names what is kept.** `sigma_hat` is still a head output and still trained -- the `log sigma_hat` term is what gives the likelihood a reason to prefer a small scale where the model is accurate, and `test_gradient_reaches_both_location_and_learned_scale` pins that both `mu_hat` and `sigma_raw` receive gradient. What the ablation removes is the learned *shape* of the error distribution. The further-degenerate baseline with a fixed scale too is not this row, is not built here, and is just L1; R14 lists it separately.

**Zero parameters is the structural difference, and it is load-bearing twice.** `RLELoss` is the one loss in this repository carrying weights -- 42 flow tensors on the tiny test module -- which is why it is held as a submodule at all. `LaplaceNLLLoss` has none: `.parameters()` is empty, `state_dict()` is empty, and construction draws no RNG. The second consequence is the useful one. The module docstring's construction-order rule exists because the flow's `Linear` layers consume RNG and would perturb every parameter drawn after them; an ablation module draws nothing, so its weights are bit-for-bit a same-seed detection module's, and `test_ablation_adds_no_parameters_to_model` pins its state dict as a detection module's plus the point stems and no third thing.

**The attribute keeps the name `rle_loss`, deliberately.** It is an `nn.Module` attribute, so it prefixes the loss's state-dict keys, and renaming it to something task-neutral would move `rle_loss.flow.*` to a new prefix and stop every keypoints checkpoint produced before this row from loading -- including the one behind the frozen `goldens/gpu/overfit_micro_kp.json`. That is the same key-stability rule [WP-087](https://borda.github.io/lucid-YOLO/work_packages/wp-087/) established and this module keeps its stages flat for, arriving from a direction it had not been tested from. The name is now slightly wrong under one of two arms; a broken checkpoint is worse than a slightly wrong name, and the attribute says so in a comment rather than being quietly left to look like an oversight.

**Selection is additive, and the default is what makes it so.** `DetectionLitModule(keypoint_loss=...)` defaults to `"rle"`, so every shipped config, every checkpoint on disk and every accepted figure describes exactly what it described before. The two names are validated against one mapping that also builds them, mirroring how `task` and `rotated_iou_form` are validated in the same constructor -- one dict rather than a name tuple beside a constructor branch, so the accepted set and the thing each name builds cannot drift apart. An unrecognized value raises at construction, which matters more here than the pattern usually does: a silent fallback would produce an "ablation" run that trained the very objective it was meant to control for, and a comparison whose two arms are the same arm looks exactly like a null result.

`pose_smoke_ablation.yaml` is `pose_smoke.yaml` with one line added. Seed, variant, epochs, callbacks, every gain and the data placeholders are identical, `keypoint_gain` included -- it stays at A68's un-tuned `1.0` not because that is right but because moving it would make the arms differ twice. Verified by dry-parse only (`tests/ptl/test_cli.py::test_config_dry_parses[pose_smoke_ablation.yaml]`), the same DoD every shipped config carries.

**One claim this row makes about numerics, and its limit.** The ablation degrades more gently than RLE outside the `O(1)` residual range A71 normalizes into: the unit Laplace's log-density is linear in `|x_bar|`, where the flow's latent is exponential in a compounding log-scale and its base density quadratic in that latent -- A72's non-finite run. `test_far_out_residual_stays_finite_rather_than_overflowing` measures it at a residual two orders out rather than leaving it as an argument. What that does **not** say is that the ablation is safe unnormalized: a loss dominated by a handful of far-out points is still training the wrong thing, and both arms take the same A71 box frame for the same reason.

**Nothing here was run against real data.** There is no COCO checkout and no GPU on this machine, so this row ships a tool and a set of unit gates, not a result. The 67.4-vs-70.5 figures are R14's measurement quoted, not this project's; the arithmetic of the comparison is a prediction of what a paired run should show, and the first honest reading of it is [WP-125](https://borda.github.io/lucid-YOLO/work_packages/wp-125/)'s, which is `[GPU][PRINCIPAL]`-tagged and belongs to the user's machine.

______________________________________________________________________



## WP-136 — a filename is not a scratch space for whatever axis is on your mind that day

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

`src/lucid_yolo/configs/` had grown eight files under what read as one two-token scheme (`<task>_<tier>.yaml`), but a closer look found three separate inconsistencies rather than one: two files used a third token to mean something no sibling file's name carried at all, one word (`ablation`) meant two structurally different things across two files, and the scheme itself never named the one axis every config actually sets differently -- `variant`. User direction (mid-session, superseding this row's own first draft) was to stop patching the two collisions and instead put every config on one explicit scheme: `<task>_<variant>_<tier>[_<detail>].yaml`, variant spelled out in full rather than left as the bare letter the CLI and each file's own `variant:` field use.

**The full rename, driven by what each file's own `variant:` line already said (checked, not assumed):**

| old | new | why |
| -- | -- | -- |
| `det_smoke.yaml` | `det_nano_smoke.yaml` | `variant: n`, task truncated as it already was |
| `seg_smoke.yaml` | `seg_nano_smoke.yaml` | `variant: n` |
| `obb_smoke.yaml` | `obb_nano_smoke.yaml` | `variant: n` |
| `pose_smoke.yaml` | `pose_nano_smoke.yaml` | `variant: n`; `pose` kept as `task: keypoints`'s established domain term (roadmap's own "Pose-smoke tier" language, not a choice this row introduced) |
| `det_ablations.yaml` | `det_small_ablations.yaml` | `variant: s` -- the one config in the directory that is not `n`, previously invisible in its own filename |
| `overfit_100.yaml` | `det_nano_overfit_100.yaml` | had no task prefix at all, relying on `task`'s implicit `"detect"` default; `variant: n`; `100` kept as [WP-040](https://borda.github.io/lucid-YOLO/work_packages/wp-040/)'s own detail (image count, not a tier word) |
| `det_yolo_smoke.yaml` | *dropped* | see below |
| `pose_smoke_ablation.yaml` | `pose_nano_smoke_laplace_nll.yaml` | `ablation` collided with `det_ablations.yaml`'s different sense (a whole blueprint tier, sec. 10 tier B, ~120-epoch paired runs, its own sign-matching acceptance against R1 Tables 2/3/5/6) where this file is a same-budget arm of the *smoke* tier; `laplace_nll` names the mechanism swapped in, matching how the loss class and the `DetectionLitModule` docstring already refer to the arm |

**`det_yolo_smoke.yaml` was dropped, not renamed.** It was first renamed `det_smoke_layout.yaml` (naming the `--data.layout` axis, not the one value it took), then reconsidered a second time against `det_nano_smoke.yaml` sitting beside it in a listing -- "layout" alone read as no more specific than the word it replaced, so the candidate became `det_nano_smoke_yolo.yaml`, spelling the actual value. Diffing it against `det_nano_smoke.yaml` at that point (checked, not assumed) found the two differ in exactly three things: the `data:` block (a YOLO root and its `layout: yolo`), `model.num_classes` (`3` for the demo root vs COCO's `80` -- a property of the demoed root, not an independent axis), and header prose. Everything else -- schedule, gains, optimizer, `variant: n` -- is identical. A whole config file whose only content beyond a data override and its dependent class count is header prose is not a ninth point in the scheme; it is three CLI flags on `det_nano_smoke.yaml` that never needed a file of their own. The prose itself was not lost: `docs/DATASETS.md`'s "Dataset formats" section already carries the deeper mechanics (`layout` vs `format`, `YOLO_CANDIDATES`, `data.yaml` priority, why `segment` is unavailable), so the file's comments were largely restating what that section already says more completely. `docs/TRAINING.md` gains a "A YOLO-format root instead of COCO's" section instead: the three-flag override, why `--data.layout yolo` is stated rather than probed (A63's undecidable-tie case), the `num_classes` coupling to the root's own `data.yaml`, and the `segment` refusal, each in one paragraph rather than a whole file's worth of header comments.

**Variant spelled out, deliberately creating a second vocabulary.** Every config's own `variant:` field and the `--variant` CLI flag stay bare letters -- that is the real API and does not change here. The filename now spells `nano`/`small` for readability at the cost of a reader having to know `nano == n` when connecting a filename to the config's own content or an override. Confirmed with the user rather than assumed, since nothing else in this codebase (code, docs, or CLI) has ever spelled these out.

**What that scheme cannot promise, stated rather than left implicit.** A filename's variant token is the config's *default*; `--variant s` on `det_nano_smoke.yaml` still runs an s-scale job under an n-named file, exactly as `keypoint_gain 1.0` in a filename would not survive a `--model.keypoint_gain` override. The name documents the shipped default, not a runtime guarantee -- the same caveat every config's own header comment already states about `variant` ("this file's default scale, not a claim about the run").

**What did not move: the historical record.** `docs/REPRODUCTION_REPORT.md`'s rows are explicit about being "the exact commands as they were run at the time" (`docs/TRAINING.md`), and the `ROADMAP.md`/`ENGINEERING_LOG.md` rows for [WP-040](https://borda.github.io/lucid-YOLO/work_packages/wp-040/), [WP-099c](https://borda.github.io/lucid-YOLO/work_packages/wp-099c/), and [WP-135](https://borda.github.io/lucid-YOLO/work_packages/wp-135/) describe what those WPs built and named *then*. [WP-129](https://borda.github.io/lucid-YOLO/work_packages/wp-129/) already set this project's precedent for exactly this situation: moving `audit_licenses.py`/`check_commit_trailers.py` into `scripts/lint/` did not rewrite [WP-084](https://borda.github.io/lucid-YOLO/work_packages/wp-084/)'s, [WP-109](https://borda.github.io/lucid-YOLO/work_packages/wp-109/)'s or [WP-115](https://borda.github.io/lucid-YOLO/work_packages/wp-115/)'s rows, which still cite the pre-move path. None of those earlier rows are touched here; this section is where all eight old names are cited for the last time.

**What did move: everything a reader or the tool itself would act on.** `README.md`, `docs/TRAINING.md`, `AGENTS.md`, `docs/DATASETS.md` (live usage docs, not a historical ledger -- a stale name there is just a broken command); `scripts/overfit_micro.py`'s and `scripts/shapes_regression.py`'s `_RECIPE_PATH` (functional, not prose -- the old name pointed at a file that no longer exists under it); `src/lucid_yolo/cli/train.py`'s `_DEFAULT_CONFIG` and its own doctests; `tests/ptl/test_cli.py`'s hardcoded `test_configs_dir_non_empty` set and every parametrized config-name literal; and every config's own internal header comment that named a sibling file. `CHANGELOG.md`'s still-open `## [Unreleased]` section also updated, on the same live-vs-historical reasoning -- it describes what the next release will ship, not what a past WP called something at the time, so a stale name there would mislead exactly the reader it exists for.

No test needed a structural change: `tests/ptl/test_cli.py`'s `_CONFIG_PATHS` is `_CONFIGS_DIR.glob("*.yaml")`, so all eight renamed files are discovered and dry-parsed under their new names automatically. The one hardcoded list (`test_configs_dir_non_empty`'s membership assertion) and the several tests parametrized on a literal config name still needed the literals updated by hand, since a glob does not know what a test author meant to assert about.



## WP-137 — a signal that only ever existed downstream of the run it should have warned about

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

Started as a request to prototype `hotcoco` (github.com/derekallman/hotcoco), a Rust-backed reimplementation of `pycocotools`'s `COCOeval`, as a faster replacement for `faster_coco_eval`. It ended somewhere else, and the two findings that moved it there are both worth keeping.

**`hotcoco` cannot be a torchmetrics backend, and the reason is structural rather than a version gap.** `torchmetrics.detection.MeanAveragePrecision`'s `CocoBackend` builds an empty `COCO()`, assigns a plain dict to its `.dataset` attribute, and calls `.createIndex()` -- a construction contract shared by `pycocotools` and `faster_coco_eval` alike. Installed and inspected directly (Tier-1 evidence, not the README's own drop-in claim): hotcoco's `COCO.dataset` is a read-only Rust-bound attribute (`AttributeError: attribute 'dataset' of 'builtins.COCO' objects is not writable`), and no `createIndex`/`create_index` method exists at all -- confirmed even after calling hotcoco's own `init_as_pycocotools()` monkeypatch, which does make `from pycocotools.coco import COCO` resolve to hotcoco's class (verified via `sys.modules` inspection) but returns an object that still fails both calls. A working hotcoco integration exists -- `COCO(full_dict)` -> `.loadRes(results)` -> `COCOeval(gt, dt, iou_type=...)` -> `.evaluate()/.accumulate()/.summarize()`, verified end to end with correct numbers -- but it is the same hand-driven shape `evaluate_keypoints` already uses for `faster_coco_eval`, not a `backend=` value, and it costs two things a subclass does not: hotcoco's dict schema requires `height`/`width` per image unconditionally (torchmetrics' own bbox-only dataset builder never produces them), and hotcoco offers no incremental `.update()` -- the whole dataset must be built in memory before scoring. For segm that reintroduces exactly what `_StreamingScorer`'s own docstring exists to prevent: a full split's masks held at once, "hundreds of gigabytes" in this module's own words.

**Benchmarked anyway, since a real cost needs a real number rather than a plausible one.** A synthetic bbox-only benchmark at COCO val2017 scale (5000 images, 34,227 instances, matched to `REPRODUCTION_REPORT.md`'s own cited count) measured `faster_coco_eval` (via `evaluate_bbox`) at 1.160 s and a hand-driven hotcoco path at 0.089 s -- roughly 13x, and the two engines agreed to three decimals (AP 0.888 both). But `MeanAveragePrecision.update()`/`.compute()` runs once per **validation epoch** (`module.py`'s `self._val_map`), inside a training run whose own per-epoch wall-clock is measured in minutes; ~1.07 s saved per epoch over a 50-epoch run is roughly a minute total against hours, not a bottleneck a training loop notices. Separately, `roboflow/rf-detr`'s own PR #1375 ("consolidate COCO mAP evaluation", 3.28x CPU speedup measured in its own PR body) turned out not to apply here either, for a reason worth stating precisely rather than waved past: that PR fixes a cost specific to `class_metrics=True` on torchmetrics `<1.9`, and this project is on `torchmetrics>=1.9` with `class_metrics` never set -- already the fast path that PR exists to reach. **Conclusion: `hotcoco` is rejected** (A73) -- not for lacking a benefit, but because the benefit it has does not clear the cost of reaching it.

**What the research surfaced instead was a real gap, one already named earlier this session and left open.** A `task="keypoints"` module's `validation_step` fell into the `if self.task != "obb":` branch and called `self._val_map.update(...)` with plain box/label dicts -- the same call a plain detection module makes. Nothing scored keypoints during training validation at all; the only place OKS was ever computed was the standalone `lucid-eval` report, run by hand, after the fact. A user direction mid-turn reframed the whole question around this: not "should det/seg's eval get faster," but "should keypoints and det/seg have one eval *experience*" -- and `roboflow/rf-detr`'s own `PR #1183` (`keypoint_oks.py`'s `MetricKeypointOKS`, read in full via `gh pr diff`) turned out to be a real precedent, but one that validates the shape this project already had for keypoints rather than motivating a change to it: a hand-rolled `reset/update/compute` facade over a `faster_coco_eval`-backed evaluator, built for the identical reason -- their own docstring names it, citing upstream torchmetrics `PR #3348` as the eventual real fix rather than attempting one themselves.

**So the unification landed as: give keypoints the same per-epoch validation monitoring det/seg/obb already had, not a new abstraction to hold it in.** `DetectionLitModule` already carries this exact pattern for `"obb"`'s [WP-063](https://borda.github.io/lucid-YOLO/work_packages/wp-063/) rotated mAP -- `self._val_rotated_preds`/`self._val_rotated_targets` as plain lists, accumulated per batch and scored once at epoch end, because "mAP is not a per-batch quantity that can be averaged" (the module's own words) and there is no `torchmetrics.Metric` shape to hold an epoch-whole matching. Keypoints' OKS is the identical case, so `self._val_keypoint_preds`/`self._val_keypoint_targets` follow it exactly rather than reaching for a wrapper class: `_update_val_keypoints` decodes the one-to-one branch's raw point offsets (`decode_keypoints`) and gathers each kept detection's own set by the anchor index the *same* top-k selection already returned (`gather_keypoints` -- `coco_eval.py`'s `_gather_keypoints`, made public, since `DualPathEvaluator` and this module now both need the identical padding-row-zeroing contract and a second hand-copied version of it is exactly the drift [WP-109](https://borda.github.io/lucid-YOLO/work_packages/wp-109/)'s "three strides, two of them private" lesson warns against); `_log_oks_map` scores the epoch once via the existing, unmodified `evaluate_keypoints` and logs `val/oks_mAP`.

Unlike `"obb"`, this sits **beside** `val/mAP`, not instead of it ([WP-102](https://borda.github.io/lucid-YOLO/work_packages/wp-102/)'s own distinction, restated for the new case): a keypoints module's box branch is a real person detector, orthogonal to its pose quality, the same relationship `val/segm_mAP` already has to `val/mAP`. The sigma vector is picked by point count rather than stated once: `COCO_KEYPOINT_OKS_SIGMAS` only when `num_keypoints == 17` matches COCO's own schema exactly, A67's uniform `SYMBOL_KEYPOINT_OKS_SIGMA` for every other count -- the same choice `scripts/overfit_micro.py`'s synthetic gate already made for its own fixture, now read from one place (`coco_eval.py`) instead of two independently-hardcoded `0.072` literals that could have drifted apart.

**Verification.** `tests/ptl/test_keypoint_training.py::TestKeypointValidation` -- one validation step populates both buffers and `on_validation_epoch_end` logs a finite `val/oks_mAP` and clears them; `val/mAP` and `val/oks_mAP` are both present, confirming the "beside" relationship; a monkeypatched `evaluate_keypoints` confirms the sigma ternary picks `COCO_KEYPOINT_OKS_SIGMAS` only at `num_keypoints == 17` and `SYMBOL_KEYPOINT_OKS_SIGMA` otherwise. Full regression sweep (`test_keypoint_training.py`, `test_coco_eval.py`, `test_module.py`, `scripts/_tests/test_overfit_micro.py` -- the last one exercising `SYMBOL_KEYPOINT_OKS_SIGMA` from its new location) -- 75 passed. `make gate` green.



## WP-138 -- a claim was wrong, and the fix it justified had already shipped

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

**[WP-137](https://borda.github.io/lucid-YOLO/work_packages/wp-137/)'s own text claimed something false, and this row exists to say so plainly rather than quietly patch it.** That entry rejected a hand-driven `hotcoco` path for segmentation on the grounds that it "would cost back the memory-bounded streaming `_StreamingScorer` exists for" -- an assumption never checked against the code it was reasoning about. Checked here, against torchmetrics' actual `_get_safe_item_values` source (the function `_StreamingScorer`'s own docstring already cites): it RLE-encodes every mask **immediately inside `update()`** and discards the dense tensor the moment the batch is scored. Nothing about that is exclusive to torchmetrics' own machinery -- a hand-driven adapter can do the identical thing, encode-then-discard per batch, and only assemble the small already-encoded document at `compute()` time. The memory bound is about dense masks, not about "whole document at once," and conflating the two was the error. `docs/ASSUMPTIONS.md` A73 is revised in place rather than left standing, per this project's own rule that a wrong claim gets corrected where it was made, not silently worked around.

**The correction changed the answer, not just the reasoning.** With the memory objection gone, the remaining question was purely a feasibility one -- can hotcoco's actual API be driven correctly -- and the user asked for it reassessed with a harder constraint: even accepting a `MeanAveragePrecision` subclass if that is what it takes. Two things closed that question before any subclass was needed. First, `roboflow/rf-detr` merged PR #1402 in the interim (after [WP-137](https://borda.github.io/lucid-YOLO/work_packages/wp-137/)'s own research agent scanned `develop`'s merged commits, which is why it was missed then) -- a real, working `_HotCocoBackend(CocoBackend)` subclass: constructed under the accepted `"faster_coco_eval"` string (passes `CocoBackend`'s own validation), its `coco`/`cocoeval`/`mask_utils` properties overridden to return hotcoco's objects, and `_get_coco_datasets` (the assign-`.dataset`-then-`createIndex()` method hotcoco cannot support) skipped entirely on that path -- confirming a subclass *is* buildable, contradicting [WP-137](https://borda.github.io/lucid-YOLO/work_packages/wp-137/)'s blanket "cannot be a torchmetrics backend" framing. Their own measured verdict, independent of this project's: "not a training-throughput change... under 2% of an epoch," even with an array-fast-path optimization this project did not attempt. Second, this project has no DDP anywhere (`grep` across `cli/train.py`, `module.py`, `docs/TRAINING.md` -- nothing), which is `Metric`'s other real selling point beyond what a hand-driven scorer already gets for free by copying the same encode-then-discard pattern. Between a training-loop win both this project's own benchmark and rf-detr's independently measure as negligible, and a subclass whose main other benefit does not apply here, the subclass (WP B) stays deferred rather than built.

**What landed instead: hotcoco behind `lucid-eval`'s own `--eval_backend` knob, where rf-detr's own PR says it actually matters** -- "standalone evaluation of a checkpoint, large validation sets, sweeps that evaluate far more often than they train" is `lucid-eval`'s exact shape, not `module.py`'s per-epoch training loop, which stays on `faster_coco_eval` untouched. Default `"auto"` prefers hotcoco -- its wheels cover this project's real target platforms (`cp39-abi3`, macOS/Linux/Windows) -- and falls back to `faster_coco_eval` only if a runtime probe (`hotcoco_available()`: import, then construct an empty document, since a partially-broken compiled extension imports fine but cannot build anything) finds it unusable, recording the reason in the report's own `info` dict rather than only printing it. An explicit `--eval_backend hotcoco` is a stated requirement and raises rather than silently substituting -- the same "explicit override wins" shape `--data.layout` already has (A63). `--eval_backend faster_coco_eval` stays a fully-tested rollback, no reinstall, matching rf-detr's own choice to ship theirs the same way.

**Five real hotcoco compatibility traps, each verified directly against this project's own installed package rather than taken from rf-detr's PR on faith** (all five match their documented list exactly, which is itself worth recording -- independent verification, not a citation):

1. `params` is copy-on-read. `ev.params.recThrs = [...]` is a silent no-op; the whole object has to be reassigned (`p = ev.params; p.recThrs = ...; ev.params = p`). Confirmed directly: a mutate-in-place `maxDets` change did not survive, the reassignment did.
2. `dataset` is copy-on-read too, which is why `_HotcocoStreamingScorer` builds **two independent** ground-truth/result documents for a combined bbox+segm model (one box-area, one mask-area) rather than mutating one loaded document's `area` field between passes the way torchmetrics' own internal combined path does.
3. RLE `counts` comes back `bytes` from `faster_coco_eval`'s own `mask.encode`, and hotcoco's constructor decodes only the `str` form -- silently reading a bytes payload as an **empty mask**, no exception. Verified directly: a 9-pixel mask round-tripped through bytes scored 0 pixels; through `str` (`.decode("utf-8")`), 9. This is now centralized in one function (`_encode_mask_rle`) specifically so the fix cannot be half-applied at some call site that builds a record by hand.
4. `mask.encode` accepts `uint8` only, not the boolean arrays this project's mask tensors already are -- cast before encoding.
5. hotcoco writes its own stdout (a summary table) and stderr (one warning per evaluator parameter differing from COCO's defaults -- this project overrides `recThrs` and `maxDets`, so every call would otherwise warn) directly from Rust. Verified directly, the same way rf-detr's PR states it: wrapping a hotcoco `summarize()` call in `contextlib.redirect_stdout` left the table on the real terminal and an empty capture buffer. Fixed with descriptor-level `os.dup2` redirection (`_redirect_native_output`), scoped tightly around the three evaluator calls so a genuine failure -- hotcoco raises Python exceptions on error, never merely writes to descriptor 2 -- still surfaces normally.

**The acceptance instrument is `TestHotcocoParity`, not a benchmark.** Bit-for-bit (`abs=1e-6`, float32 precision noise) parity between `_StreamingScorer` and the new `_HotcocoStreamingScorer`, fed the *identical* accumulated predictions and targets: all 12 bbox statistics, all 24 bbox+segm statistics, the empty-preds all-zero case, and a full end-to-end `DualPathEvaluator` run through the real decode/letterbox pipeline on the detseg fixture (`test_dual_path_report_matches_across_backends`) -- the last one specifically to catch anything that only surfaces once real decoded detections, not a hand-built dict, reach the RLE/height-width/params machinery. `hotcoco>=0.5,<0.6` joins `pyproject.toml`'s core dependencies (a tight pre-1.0 ceiling, deliberate: three of the five traps above fail silently rather than raising, so each minor needs re-verifying against this same parity suite before it is trusted) -- `faster-coco-eval` stays required alongside it, since keypoints and `module.py` both still call it directly. `make gate` green, full suite plus the new parity class.



## WP-126 — the fourth task existed everywhere except where a reader would look

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

The tier's own artifacts landed cleanly: a model card, a `0.5.0` reproduction-report section, a fourth panel on `plot_training.py` for the keypoint loss term. What did not land, and was not noticed until the naming of one file was questioned, is that every general-audience document still described a three-task project. `README.md` said "Three tasks, one trunk" and listed three; `docs/index.md` had a heading reading "The three tasks"; `docs/TRAINING.md` carried a launch section for detection, segmentation and oriented detection and none for keypoints, and its wiring-gate block listed three of the four `overfit_micro.py` tasks. None of that is a gate failure — every register check, link check and section check passed throughout, because each of them asks whether a named thing exists, and no gate asks whether a *newly* named thing has been added everywhere its three siblings appear.

**The naming question that surfaced it is itself the more useful finding.** The card shipped as `model_cards/pose.md`, describing a "pose detector", which is precisely backwards: this project's keypoint task is `K`-generic by construction — `num_keypoints` is a constructor argument the way the class count is, nothing in the head, the loss or the decode path reads a point's meaning, and the wiring gate deliberately runs a 7-point synthetic symbol schema rather than a human one (A67 exists only because that schema has no annotator variance to derive sigmas from). Human pose is one instantiation, distinguished by having a published sigma table and a ground-truth file, not by being what the architecture is for. The file is `model_cards/keypoints.md`, and the general docs say keypoints and then name pose as the instantiation the shipped checkpoint trained on.

The task-specific prose stays in pose terms wherever it is actually describing the shipped checkpoint — its training data is `person_keypoints`, its ethical-considerations section is about predicting human skeletons, and softening those into schema-neutral language would make the card describe something safer than what was trained. The distinction the rename enforces is between the architecture (keypoints) and this checkpoint (pose), not a blanket find-and-replace.

**Three stale figures were corrected in passing**, each found by reading a line rather than by any check: the README claimed 63 registered assumptions against an actual 73, and 20 frozen golden files against an actual 14 (the number had been read off a total that included the seven live sets); and it described roadmap 111's whole-image oriented figure as not yet measured, which had stopped being true when that row closed and the reproduction report gained the number. A document that quotes a count is a document that goes stale silently, and none of the three would have been caught by anything in `make gate`.

The general lesson for the next task family, whenever there is one: adding a task is not done when its own artifacts exist. The check worth running by hand is to grep for the *previous* task's name across the tree and ask, at each hit, whether the new one belongs beside it.



## WP-139 — the same blindness, one layer down

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

**[WP-126](https://borda.github.io/lucid-YOLO/work_packages/wp-126/) above closes with a lesson, and this row is what running that lesson found.** Greping for the previous tasks' names across `src/` rather than across `docs/` turns up a structural gap the documentation sweep could not see: detection, segmentation and oriented detection each ship a composite `nn.Module`, a `build_*` factory, a `.deploy()` NMS-free inference view and a frozen params/FLOPs golden. Keypoints shipped none of the four. The keypoint model existed only as an inline `build_detection_stages(..., num_keypoints=K)` call inside `DetectionLitModule.__init__` — enough to train, evaluate and report, which is exactly why nothing complained.

**The golden harness cannot notice a golden that was never written.** `check_goldens.py` discovers its work by globbing `goldens/*.json` and `goldens/frozen/**/*.json`, so every golden present is checked and no count is asserted anywhere. Nothing in the repository enumerates "one size golden per task" and compares that list to the tasks that exist. This is the identical shape of blindness [WP-126](https://borda.github.io/lucid-YOLO/work_packages/wp-126/) recorded one layer up — a check that asks whether a named thing is intact, never whether a newly named thing was added beside its siblings — and it would have shipped a 0.5.0 whose headline feature was the one task absent from the release's own regression contract.

**What the measurement says.** The golden is taken at detection's own protocol — a 640-pixel input over 80 classes — rather than at a protocol of its own, specifically so that `params_flops_kp.json` minus `params_flops_det.json` is the point stems and nothing else. At `n` that difference is **+110,652 parameters and +0.1238 GFLOPs**, and the asymmetry between those two numbers is the A29 convention made visible: parameters count the whole checkpoint, so both head branches' stems are in the tally, while FLOPs count only the deployed one-to-one branch, so one branch's stems are. The per-branch stem cost is therefore half the parameter figure, and the two numbers are consistent rather than in tension.

**No published table, and that absence is recorded rather than filled.** \[**Struck 2026-09-17 (D22)**: there is one — R1 Table S10 — and this paragraph's care about not fabricating a table was spent on the wrong side of an unread section. The measurement against it is A77.\] R14 (RLE) specifies a loss and an OKS evaluation protocol and no architecture at all, so unlike detection (R1 Table 7), segmentation (Table S9) and oriented detection (Table S11), there is no size table in the literature to hold this model against. The three siblings each pair their golden with a published-table gate; this one has a golden alone, and `tests/models/test_param_flops.py` says so in a comment where a fourth `_TABLE_*` constant would otherwise be reached for. Inventing one — from a sibling table, from a third-party pose model, or from this project's own measurements — would fabricate a published claim, which is the failure mode the whole register system exists to prevent.

`num_keypoints` is keyword-only and required on both the class and the factory, with no default, matching the refusal `DetectionLitModule` already makes: `K` is a property of the dataset's annotation schema, so a default would be a silent claim about data the model has never seen. The shipped nano checkpoint's own figures in `docs/model_cards/keypoints.md` stay where they are and stay different — they are measured over COCO's single `person` category, which is a different question from the architecture's size.

**Verification.** `make gate` green: 2458 passed, 39 skipped, 3 deselected, **22/22 goldens** where 21 passed before. The new composite gates in `tests/models/test_keypoint_head.py::TestKeypointDetector` assert what the golden cannot — that the stems are run rather than merely built, that the deployed view holds no one-to-many parameter and shares rather than copies the rest, that its returned triple feeds `decode_keypoints` unadapted, and that omitting `num_keypoints` raises.



## WP-140 — a release is a boundary, not an achievement

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

0.5.0 closes the keypoint phase. `__version__` moves from `0.5.0.dev2` to a plain `0.5.0`, which is the whole of the version change — `pyproject.toml` declares the version dynamic and reads the module attribute — and the `## [Unreleased]` section [WP-133](https://borda.github.io/lucid-YOLO/work_packages/wp-133/) opened closes as `## [0.5.0]`, on the schedule its own opening paragraph wrote down.

**The frozen goldens are the release's regression contract, and this one is a set larger than the last.** `make freeze-goldens MINOR=0.5` copies the eight live metric sets into `goldens/frozen/0.5/`, where 0.4.0 froze seven; the extra set is [WP-139](https://borda.github.io/lucid-YOLO/work_packages/wp-139/)'s keypoint size golden, which did not exist a commit earlier. That is the difference between freezing what happens to be lying around and freezing what the release actually claims, and it is only a difference because the missing golden was found before the freeze rather than after it.

**What this release does not claim.** The keypoint tier's acceptance is a *direction of effect* against a control — RLE beat [WP-135](https://borda.github.io/lucid-YOLO/work_packages/wp-135/)'s Laplace-NLL arm at e2e OKS AP 0.2738 against 0.2527 on real COCO human-pose data, the same sign as R14 Table 7's own 70.5-against-67.4 — and it is not a pose figure comparable to anything published. R14's own numbers come from a different backbone, a different schedule and a different scale; this project's smoke tier is fifty epochs at `n`. Three keypoint assumptions stay `open` and each names an experiment nobody has run: A68's tuned gain adequacy (the mechanism-failure bar cleared, no sweep run), A69's one shared flow against one per branch (untested against the alternative, and the overfit gate structurally cannot tell), A70's off-canvas point policy (exercised live in both arms, its frequency and effect unmeasured). The oriented figures are unchanged from 0.4.0's caveat only in that [WP-111](https://borda.github.io/lucid-YOLO/work_packages/wp-111/) has since measured a whole-image number; every other limit that release recorded still stands, and no trained weights ship (D14).

**The fourth task trains, validates and evaluates; it does not yet deploy.** `tests/models/test_onnx_export.py` still runs three tasks, and `predict.py` still ships three functions. That is parity work for a later phase, named here rather than left for a reader to infer from an absence.

**Two CI failures the offline gate structurally cannot see, both found on the release's own run and folded in here.** The first is a four-character bug with a document-wide blast radius: `docs/ENGINEERING_LOG.md` and `docs/ROADMAP.md` both wrote the placeholder `test_` followed by an angle-bracketed `script`, and Python-Markdown's HTML block pass runs *before* inline code spans are recognized, so the backticks protect nothing — an angle-bracketed `script` opens a raw-text element that swallows the rest of the document, and every `<a id="wp-NNN"></a>` anchor after it vanishes from the element tree `mkdocs build --strict` reads. Eighteen roadmap links broke at once, every one of them from the [WP-130](https://borda.github.io/lucid-YOLO/work_packages/wp-130/) paragraph onward, and the anchors were all still present in the rendered HTML — the post-processing step restores stashed raw HTML, so the page looked correct while the validator that never sees that step was right to fail. Renamed to `test_<script_name>.py`, which is not an HTML tag. The second is `scripts/lint/audit_docs_site.py`'s `# type: ignore[no-untyped-call]` on `add_multi_constructor`: whether that call is typed depends on the resolved `types-PyYAML`, which the lockfile does not pin, so the ignore is load-bearing locally and dead on CI's newer stubs, where `warn_unused_ignores` then fails the run. `unused-ignore` now rides along in the same bracket, correct under either version without pinning a stub package for one comment.

The general shape is worth keeping: `make gate` is an offline gate and does not build the docs site, so a docs-site failure is invisible until CI, and a lint that depends on an unpinned transitive stub version is invisible until CI resolves a different one. Neither is a gate to add — the docs build is genuinely CI's job and pinning stubs to satisfy one comment is worse than the comment — but both are reasons the gate being green is not the same claim as the branch being green.

**No tag is cut here and no distribution is published**, as at 0.4.0. `release.yml` fires on a pushed `v0.*` tag and re-runs the guard, which refuses a tag whose changelog section is missing or whose gate is red; both are the operator's to trigger. `v0.4.0` is also still uncut, so the train has now shipped two release commits ahead of its own tags — a gap worth stating plainly, since a reader who looks for `v0.5.0` will find neither it nor its predecessor.



## WP-140b — the contract names a role, and says when it ends

<a id="wp-140b"></a>

Lettered off 140: governance work that follows the release rather than any package. Three things land together because each is the reason for the next.

**`[HUMAN]` becomes `[PRINCIPAL]`.** The tag never meant *a person rather than a machine*; it meant *the party who drives the project rather than the party who executes it*, and every use of it is a call that comes from intent, ownership or risk appetite — a release, a push, a GPU budget, an acceptance. `[OPERATOR]` was tried first and rejected on reading: an operator is one who *operates the machine*, which names the executing half, exactly the half the tag does not mean. `principal` is the term of art for the party whose intent an agent serves, and it survives the human/bot question permanently, because a principal can itself be an agent one level up. The rename is governance-sense only. Anatomical uses stay untouched — "COCO human pose", "human anatomy", "human-readable", "human-drawn polygons" — as do the pre-existing uses of `operator` meaning *whoever runs the command*, which now read as the useful contrast rather than as a competing sense of one word: the principal decides, the operator executes.

**The WP contract is a reproduction instrument, and instruments end (D18).** One WP, one commit, one roadmap row exists so that a from-scratch reproduction of three papers is auditable row by row. Once the reproduction report carries all four accepted tiers that purpose is served, and continuing to demand a tracked row for a typo fix would be ceremony rather than evidence. What relaxes is only the tracking: a change altering no shipped behaviour, no public symbol, no golden and no documented assumption becomes an ordinary gated commit, and the gate itself never relaxes. What restarts it is a new **task** — a fifth head after detection, segmentation, oriented detection and keypoints — because a new task is a new reproduction claim and D2's standard applies to it exactly as it applied to the first four.

**Admission is three layers, because a signature is not a detector.** The exposure is not one vendor. It is any source this project cannot redistribute under Apache-2.0: copyleft, source-available, paid, proprietary, or with a licence that cannot be read. Ultralytics is the loudest instance of that class, not the class itself, and today's pull-request template names it where the rule is D13's whole allowlist. No attestation detects such code arriving, so the layers are picked for how differently they fail — a DCO sign-off records who submitted it and under what claim of right, a generalized attestation fixes what that claim covers, and a provenance trailer on every contributor commit is the only one of the three that yields something reviewable: an id resolvable against `docs/PROVENANCE.md`, whose absence on a change to a loss or an assigner is visible before release rather than after. Enforcement is a check in this repository rather than a third-party app, because a survey on 2026-09-02 found every free hosted CLA service either archived, years stale, or a two-star project, and an admission record should not depend on someone else's uptime.

**Phases 13 and 14 are written down.** The augmentation stack leaves for `fuse-augmentations` — the plan of record is kept outside the tree, and only its roadmap rows land here. The split into two phases is not cosmetic: the guard that authorizes a swap is *published* in 0.6.0 before anything it guards is allowed to move in 0.7.0, so a swap is measured against a released version rather than a working tree. The guard is deliberately not an old-versus-new comparison, which would enshrine legacy behaviour as the definition of correct and keep the code alive as its own reference, blocking the deletion the check exists to authorize. Keypoint ONNX export and `predict_keypoints` join Phase 13 because 0.5.0's own release entry names them as the parity a later phase owes.

