🕺 Phase 12 — Keypoint detection, v0.5.0¶
WP-120 — a mirror does not know anatomy¶
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'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¶
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'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'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'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 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¶
DualDetectionHead gains a third opt-in stem set, num_keypoints, following num_coeffs'/predict_angle's exact shape: absent by default, so the accepted detection/segmentation/oriented module trees are untouched byte-for-byte. _build_keypoint_stem reuses _stem_width (A28's channels // 3) rather than a bespoke width the way _build_coeff_stem already does -- unlike the angle stem, no R1 Table S11 or any allowlisted source measures a keypoint task at all, R14 being registered "future pose milestone only", so there is nothing to fit a dedicated width against [struck 2026-09-17 (D22): R1 Table S10 measures exactly this task, and at its protocol this stem is 13.0% / 26.5% under the paper's n params / FLOPs — A77]. The final 1x1 emits 4 * K raw channels in point-major order (x, y, sigma_x, sigma_y per point) and nothing follows it -- coordinates are unbounded offsets exactly like the angle branch, and per the scope boundary below, sigma is equally raw.
The scope line that mattered most here was what this WP explicitly does not decide: R14's RLE loss needs sigma > 0 for its flow's density, but the specific function that guarantees that (softplus, exp, a scaled sigmoid) is tied to the loss equations themselves, and AGENTS.md sec. 6 reserves loss/assignment/optimizer/rotated-geometry modules for hand-written implementation from the equations, not generated code. A65 records the split explicitly: DualDetectionHead and the new decode_keypoints (heads/keypoint.py, decode_ltrb's anchor-centre/stride convention generalized from a four-value ltrb offset to a direct two-value per-point offset) both leave sigma completely untouched, and WP-123 is where the mapping is decided, from R14 directly. The codex spec for this WP said so in as many words -- "if you find yourself reaching for such a transform, stop" -- and the delegated diff held the line: heads/keypoint.py's decode function does not even accept a sigma argument, and the test suite's test_sigma_output_remains_raw_unbounded_and_point_major drives the stem's output convolution to explicit large-magnitude values and asserts they survive unchanged, which would fail against any accidentally-added activation.
Delegated to codex:codex-rescue --write as WP-120/121 were, but this one surfaced a real regression its own sandboxed test run correctly caught and correctly declined to fix: _DetectionBranch.forward's return tuple grew from four elements to six (cls, box, coeff, angle to ..., keypoints, keypoint_sigma), and three call sites in models/build.py (Detector, OrientedDetector, Segmenter) unpack that tuple positionally rather than through DualHeadOutput's named fields -- cls, box, _, _ = self.o2o(...) and its two siblings. The spec had excluded build.py from scope entirely, which was the actual mistake: those three lines are a direct, unavoidable consequence of widening a tuple those call sites already depend on, not a new feature crossing a WP boundary. Codex's own gate ran tests/models/ as a regression check, found 22 failures and 6 errors all tracing to those three lines, correctly refused to touch the excluded file, and reported the gap rather than guessing. Fixed by hand in three one-line edits (widening each unpack to six positions, matching the new tuple width) -- mechanical enough, and small enough, not to warrant a second delegation round; make gate (2290 passed, 27/27 goldens, including all four params_flops_* goldens the widened tuple had been silently breaking) confirms nothing else depended on the old width.
WP-123 — a loss written by hand, from equations fetched three times¶
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 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 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/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¶
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's ground truth and 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'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 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'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¶
Surfaced during Phase 12's own dogfooding rather than invented: validating a fuse-augmentations Task.KEYPOINTS COCO export against 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's original reader) required a parseable segmentation ring to retain any annotation at all, in every mode -- plain, oriented, and 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'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'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¶
The upstream half of 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 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 installed 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¶
Phase 12 shipped five consecutive rows of keypoint work and never once ran a keypoint. WP-120 added the target channels, WP-121 the COCO reader, WP-122 the head stem, WP-123 the RLE loss, 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 and 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's structural shape rather than 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'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 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 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 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¶
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'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 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'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¶
WP-124 built the OKS scorer. 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'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 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'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'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¶
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 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'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¶
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'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, WP-099c, and WP-135 describe what those WPs built and named then. 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's, WP-109's or 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¶
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 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'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'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¶
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'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'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):
paramsis 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-placemaxDetschange did not survive, the reassignment did.datasetis copy-on-read too, which is why_HotcocoStreamingScorerbuilds two independent ground-truth/result documents for a combined bbox+segm model (one box-area, one mask-area) rather than mutating one loaded document'sareafield between passes the way torchmetrics' own internal combined path does.- RLE
countscomes backbytesfromfaster_coco_eval's ownmask.encode, and hotcoco's constructor decodes only thestrform -- silently reading a bytes payload as an empty mask, no exception. Verified directly: a 9-pixel mask round-tripped through bytes scored 0 pixels; throughstr(.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. mask.encodeacceptsuint8only, not the boolean arrays this project's mask tensors already are -- cast before encoding.- hotcoco writes its own stdout (a summary table) and stderr (one warning per evaluator parameter differing from COCO's defaults -- this project overrides
recThrsandmaxDets, so every call would otherwise warn) directly from Rust. Verified directly, the same way rf-detr's PR states it: wrapping a hotcocosummarize()call incontextlib.redirect_stdoutleft the table on the real terminal and an empty capture buffer. Fixed with descriptor-levelos.dup2redirection (_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¶
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¶
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 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¶
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 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'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'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 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 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¶
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.