# 🗺️ Work-Package Roadmap

The agent's work queue: 197 numbered work packages, one commit each, executed in dependency order per the AGENTS.md loop, plus lettered rows recording follow-up work that landed after its package had closed. Transcribed from the governing blueprint (sec. 15) with a live status column.

**Scope says what a package does, not what executing it taught.** Measurements, rejected approaches and negative results live in RESEARCH_LOG.md, linked per row as `· log`; choices the papers left open live in ASSUMPTIONS.md and are cited by id; release results live in REPRODUCTION_REPORT.md. A row that restates any of those is duplicating a record that has an owner.

**Length is guidance, not a limit, and the guide is this table's own median** — Scope about 285 characters, DoD about 80, subject about 50. Nothing enforces it and no test measures it: a row whose claims genuinely need more room takes it, and cutting substance to reach a number is the worse outcome. What the figure is for is the drift it makes visible. A cell running several times its column's median is usually not carrying more scope, it is carrying the reasoning behind the scope — which has an owner elsewhere, per the paragraph above. Compress the prose first, move what survives compression to the linked log, and leave the row stating what the package does.

Legend: **Dep** = prerequisite WPs · **[DATA]** needs a real dataset (synthetic stand-in per A26 for offline development only) · **[GPU]** needs an accelerator (MPS first, D12c) · **[PRINCIPAL]** belongs to whoever drives the project rather than executes it (AGENTS.md sec. 1) — never started autonomously. DoD = the test id(s) that must pass; every WP additionally requires `make gate` green. Status icons: ⬜ todo · 🔄 in-progress · ✅ done · ⛔ blocked (see docs/ESCALATION.md) · ⏸ deferred, scope still wanted but the start is waiting on something outside this repository, named in the row · ⊘ closed without its DoD met, the row itself saying why. Only ✅, 🔄 and ⊘ appear below Phase 16: every WP written before Phase 17 has either landed or been closed, so ⛔ and ⏸ are defined for rows this queue has not yet needed rather than describing anything on it; Phase 17 is the first queue of ⬜ rows, each waiting on an accelerator or a principal. ⏸ is distinct from ⛔ on both sides: nothing here is stuck, and nothing outside is being escalated to. The status column is flipped to ✅ in the same commit that completes its WP.

## 🧱 Phase 0 — Foundation (WP-001…007)

| WP | Commit subject | Scope | DoD | Dep | Status |
| -- | -- | -- | -- | -- | -- |
| 001 | `chore(repo): scaffold src layout, pyproject, Makefile` | Package skeleton, pinned deps, Makefile targets, pre-commit config · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-001/#wp-001) | `make lint` green on empty typed package | — | ✅ |
| 002 | `docs(legal): add LICENSE, NOTICE, README non-affiliation` | Apache-2.0, NOTICE with Redmon attribution (R20), README header verbatim | `scripts/_tests/test_audit_license_headers.py` | 001 | ✅ |
| 003 | `docs(policy): seed PROVENANCE, ASSUMPTIONS, DECISIONS, AGENTS, ROADMAP` | Source allowlist, register A1–A26, D1–D12 + ADR-001/002/003, AGENTS.md, this file | `scripts/_tests/test_audit_docs_present.py` | 001 | ✅ |
| 004 | `ci(pr): lint, types, tests, coverage, license audit` | PR workflow; dependency-license audit; commit-trailer validator · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-004/#wp-004) | Workflow green; negative test: AGPL dev-dep rejected | 002,003 | ✅ |
| 005 | `ci(gates): golden harness and frozen-golden regression` | `goldens/` loader, tolerance comparison, `make gate`, `goldens/frozen/` semantics · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-005/#wp-005) | `scripts/_tests/test_check_goldens.py`; tampered golden fails | 004 | ✅ |
| 006 | `ci(release): tag-gated release workflow and CHANGELOG` | `release.yml`; CHANGELOG scaffold; `make freeze-goldens` | Negative test: tag on red commit refused | 005 | ✅ |
| 007 | `test(fixtures): micro dataset with boxes, polygons, rotated scenes` | Seeded synthetic scenes via fuse-augmentations (A26): det/seg boxes+polygons, rotated scenes; loaders | `tests/fixtures/test_fixtures_load.py` | 003 | ✅ |

## 🚰 Phase 1 — Data pipeline (WP-008…015)

| WP | Commit subject | Scope | DoD | Dep | Status |
| -- | -- | -- | -- | -- | -- |
| 008 | `feat(data): target containers and type-generic transform API` | `Targets` dataclass (boxes, labels, masks, rboxes); transform protocol over every target type | `tests/data/test_targets.py` | 007 | ✅ |
| 009 | `feat(data): letterbox resize with exact inverse` | Aspect-preserving pad/resize + inverse map (A10) | `test_letterbox.py::test_roundtrip_subpixel` | 008 | ✅ |
| 010 | `feat(data): random affine for boxes and masks` | scale/translate/shear/degrees per Table S3; joint box+polygon transform, clipping | `test_affine.py::test_box_mask_consistency` | 009 | ✅ |
| 011 | `feat(data): mosaic assembly` | 4-image mosaic (R9), border handling, target remap | `test_mosaic.py::test_bounds_and_counts` | 010 | ✅ |
| 012 | `feat(data): mixup and copy-paste` | Table S3 probabilities, scale-aware policy | `test_mixup_copypaste.py` | 011 | ✅ |
| 013 | `feat(data): HSV jitter and horizontal flip` | hsv_h/s/v, fliplr=0.5 with target mirroring · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-013/#wp-013) | `test_photometric.py` | 010 | ✅ |
| 014 | `feat(data): COCO dataset and LightningDataModule` [DATA] | Detection + polygon parsing, scale-aware augmentation policy, `make check-data` · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-014/#wp-014) | `test_coco.py` (fixture-backed) + `check-data` on real COCO | 012,013 | ✅ |
| 015 | `test(data): round-trip goldens and debug visualizer` | Augmented-batch checksums; annotated grid dump script | `goldens/data_checksums.json` frozen | 014 | ✅ |

## 🏗️ Phase 2 — Architecture (WP-016…023)

| WP | Commit subject | Scope | DoD | Dep | Status |
| -- | -- | -- | -- | -- | -- |
| 016 | `feat(models): Conv, DWConv, Bottleneck primitives` | Conv-BN-SiLU, depthwise variant, residual bottleneck | `test_blocks.py::test_primitives` | 003 | ✅ |
| 017 | `feat(models): C3k2 block` | CSP split, n inner blocks, e ratio, c3k switch (A3) | `test_blocks.py::test_c3k2_shapes` | 016 | ✅ |
| 018 | `feat(models): PSABlock and C2PSA` | Attention + FFN block; split/concat wrapper (A3) | `test_blocks.py::test_c2psa` | 016 | ✅ |
| 019 | `feat(models): SPPF with shortcut` | 1x1 -> 3x MaxPool(5) -> concat -> 1x1, plus input-output shortcut (A4) | `test_blocks.py::test_sppf_shortcut` | 016 | ✅ |
| 020 | `feat(models): backbone` | Backbone stack with P3/P4/P5 taps | `test_backbone.py::test_tap_shapes` | 017,018,019 | ✅ |
| 021 | `feat(models): neck with attention tail` | Top-down/bottom-up; final C3k2 n=1 e=0.5 attn=True | `test_neck.py::test_output_shapes` | 020 | ✅ |
| 022 | `feat(models): dual detection head, reg_max=1` | o2o (300x6) + o2m (nc+4, 8400) branches, DFL-free ltrb regression (A9) · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-022/#wp-022) | `test_head.py::test_dual_head_shapes` | 021 | ✅ |
| 023 | `feat(models): scale registry, builder, param/FLOP fidelity gate` | 5-row dataclass registry, typed builders (ADR-001), fvcore counting · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-023/#wp-023) | `test_param_flops.py::test_det_vs_table7` — plus/minus 2% params / 5% FLOPs, all 5 scales; **golden frozen** | 022 | ✅ |

## ⚖️ Phase 3 — Assignment and losses (WP-024…030)

| WP | Commit subject | Scope | DoD | Dep | Status |
| -- | -- | -- | -- | -- | -- |
| 024 | `feat(losses): CIoU` | CIoU per R10 (A1), batched, autograd-safe | `test_ciou.py::test_against_closed_form` | 003 | ✅ |
| 025 | `feat(assign): anchor grid and Task-Aligned Assigner` | Centers at (i+0.5)\*stride (A11); t = s^1 * u^6 (A2); topk selection | `test_tal.py::test_alignment_and_topk` | 024 | ✅ |
| 026 | `feat(assign): STAL surrogate candidate filtering` | Eq. 4–6; per-dimension clamp d\<8 -> 16; original box preserved for scoring/regression | `test_stal.py::test_tiny_box_gains_candidates`, `::test_per_dim_clamp`, `::test_targets_unchanged` | 025 | ✅ |
| 027 | `feat(losses): detection branch loss` | CIoU + L1 (dfl-gain field, A13) + BCE, TAL-weighted | `test_detection_loss.py::test_components` | 026 | ✅ |
| 028 | `feat(losses): dual-branch composition` | o2m topk=10 / o2o topk=7->1 wiring; static alpha combination (schedule lands WP-035) | `test_dual_loss.py::test_one_positive_per_gt` | 027 | ✅ |
| 029 | `test(assign): synthetic assignment goldens` | 6x6 px GT: STAL >=1 candidate, vanilla TAL exactly 0 at stride 8 | `goldens/assignment_cases.json` frozen | 028 | ✅ |
| 030 | `test(train): single-batch overfit and gradient flow` | 200-step monotonic loss decrease; no NaN/Inf; all leaf grads populated · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-030/#wp-030) | `test_overfit_batch.py` | 029 | ✅ |

## 📉 Phase 4 — MuSGD (WP-031…033)

| WP | Commit subject | Scope | DoD | Dep | Status |
| -- | -- | -- | -- | -- | -- |
| 031 | `feat(optim): Newton-Schulz orthogonalization` | Pure function from R7/R8, 5 iterations (A5), fp32 under AMP | `test_newton_schulz.py::test_orthogonality` | 003 | ✅ |
| 032 | `feat(optim): MuSGD with parameter-type split` | >=2D: w_muon*Muon + w_sgd*SGD (A6, A7); 1D: pure SGD, no weight decay (A12) | `test_musgd.py::test_param_split`, `::test_step_shapes` | 031 | ✅ |
| 033 | `test(optim): toy convergence golden vs SGD` | Fixed synthetic regression + micro-CNN; MuSGD reaches threshold in fewer steps · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-033/#wp-033) | `goldens/optim_toy.json` frozen | 032 | ✅ |

## ⚡ Phase 5 — Lightning training loop (WP-034…040)

| WP | Commit subject | Scope | DoD | Dep | Status |
| -- | -- | -- | -- | -- | -- |
| 034 | `feat(ptl): LightningModule with task-conditional losses` | Automatic optimization; det losses active, seg/obb hooks inert | `test_module.py::test_training_step` | 030,032 | ✅ |
| 035 | `feat(ptl): ProgressiveLossSchedule hook` | Eq. 3 in `on_train_epoch_start`, (0.8,0.2)->(0.1,0.9) | `test_proglos.py::test_alpha_at_t0_mid_end` | 034 | ✅ |
| 036 | `feat(ptl): CloseMosaic callback` | Disables mosaic for final `close_mosaic` epochs | `test_close_mosaic.py::test_flip_epoch` | 034 | ✅ |
| 037 | `feat(ptl): EMA callback` | Decay schedule, checkpointed, used for eval | `test_ema.py::test_shadow_updates` | 034 | ✅ |
| 038 | `feat(ptl): LightningCLI entry and experiment configs` | `configs/` tier matrix (ADR-001); resolved config logged per run · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-038/#wp-038) | `test_cli.py::test_yaml_roundtrip`; all configs dry-parse | 035,036,037 | ✅ |
| 039 | `feat(ptl): deterministic checkpoint and resume` | Seeded resume reproduces the loss trajectory within tolerance · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-039/#wp-039) | `test_resume.py::test_trajectory_match` | 038 | ✅ |
| 040 | `test(lit): overfit-100 integration golden` [GPU] | n-scale on a 100-image subset -> >=0.95 recall at IoU 0.5 on train · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-040/#wp-040) | `goldens/overfit_micro_det.json` frozen | 039 | ✅ |

## 🎯 Phase 6 — Evaluation, release 0.1.0 (WP-041…046)

| WP | Commit subject | Scope | DoD | Dep | Status |
| -- | -- | -- | -- | -- | -- |
| 041 | `feat(decode): score-based top-k E2E decoding` | No IoU, no NMS, cap 300 (R3 sec. 4, A9) | `test_topk_e2e.py::test_no_nms_path` | 022 | ✅ |
| 042 | `feat(decode): NMS path for the dense branch` | Conf threshold + class-wise NMS (torchvision) | `test_nms_path.py` | 041 | ✅ |
| 043 | `feat(eval): pycocotools bbox evaluator, both paths` | One command evaluates E2E and non-E2E from one checkpoint | `test_coco_eval.py::test_dual_path_report` | 042 | ✅ |
| 044 | `test(eval): oracle round-trip` | Perfect predictions -> mAP 1.0; shuffled classes -> approx 0 | `test_coco_eval.py::TestOracleRoundTrip::test_oracle` (node id predates this row's own class, drifted stale by an earlier WP; corrected by [WP-128](https://borda.github.io/lucid-YOLO/work_packages/wp-128/)) | 043 | ✅ |
| 068 | `feat(data): COCO 2017 downloader module and CLI` | Official-host download into `check_data.py` layout; val-only default; `lucid-download` + `python -m lucid_yolo.data.download` (added 2026-08-02, user request) | `tests/data/test_download.py` offline suite | 014 | ✅ |
| 069 | `refactor(eval): torchmetrics MAP with faster-coco-eval backend` | Replace pycocotools evaluator internals with `torchmetrics.detection.MeanAveragePrecision(backend="faster_coco_eval")`; `DualPathEvaluator` API preserved; pycocotools dep dropped (added 2026-08-02, user request) | WP-044 oracle ladder green on new backend | 043,044 | ✅ |
| 070 | `perf(data): fused affine+letterbox single-warp` | Compose the letterbox affine into the random affine so the train geometric base resamples once (`FusedAffineLetterbox`); boxes/polygons byte-identical, image pixels non-antialiased (A32); 4.4x geometric-path speedup (added 2026-08-02, user request) | `tests/data/test_fused_warp.py`; data goldens recomputed | 011,013,014 | ✅ |
| 071 | `perf(data): packed + uint8 batch transport` | Ragged `list[Targets]` flattened to eight dense tensors (`PackedTargets`) and images quantized uint8 across the DataLoader IPC hop (A33); `on_after_batch_transfer` restores float images and ragged targets on the destination device · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-071/#wp-071) | `tests/data/test_coco.py` pack/quantize round-trips | 014 | ✅ |
| 072 | `feat(optim): A8 LR schedule — warmup + linear decay` | Per-step `LambdaLR`: linear warmup over `warmup_epochs` then linear decay `lr0 -> lr0*lrf` (A8, revised here); the overfit-100 recipe pins the schedule off so its golden stays frozen | `tests/optim/test_schedule.py`; overfit golden unchanged | 034,038 | ✅ |
| 073 | `perf(data): compact annotation store + val-loader worker cap` | `CocoDetectionDataset` precomputes per-image `Targets` and drops the raw JSON annotation dicts, whose refcount traffic materializes copy-on-write pages in every persistent worker until the host OOMs; the val loader gains its own worker cap (`min(num_workers, 4)`) · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-073/#wp-073) | `tests/data/test_coco.py` val-cap tests; dataset output unchanged (data goldens untouched) | 014 | ✅ |
| 076 | `perf(data): epoch-recycled loader workers` | `persistent_workers` defaults **off** (opt-in constructor/CLI knob): recycling the pool every epoch hard-resets the per-worker memory creep a compact store does not reach · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-076/#wp-076) | `tests/data/test_coco.py` persistent-workers default/opt-in tests | 014,073 | ✅ |
| 077 | `feat(ptl): epoch val mAP in the progress bar` | `validation_step` decodes the one-to-one branch from the loss forward and accumulates torchmetrics `MeanAveragePrecision`; `on_validation_epoch_end` logs `val/mAP`. A letterbox-coordinate proxy for run monitoring — the acceptance figure stays the standalone evaluation. Metric states non-persistent, so checkpoints are unaffected | `test_module.py` fast-dev-run logs `val/mAP`; state-dict-unchanged test | 041,043 | ✅ |
| 078 | `fix(losses): stride-normalized L1 term` | The L1 box term is measured in stride units — the head's native ltrb frame, the frame the legacy DFL-field gain is calibrated in — instead of pixels (A13, revised here; the Det-smoke attempt-1/2 root cause); `strides` threaded `module -> DualBranchLoss -> DetectionBranchLoss` | `test_detection_loss.py` stride-normalization test; overfit-100 golden revalidated | 027,028,034 | ✅ |
| 079 | `fix(data): per-worker, per-epoch augmentation RNG` | `worker_init_fn` re-seeds each worker's pipeline generator from `WorkerInfo.seed`. The generator was seeded in the **parent**, which never advances when workers are used, so every worker replayed one augmentation stream — and, workers being non-persistent since 076, restarted it every epoch. Determinism per seed preserved · [research log](https://borda.github.io/lucid-YOLO/work_packages/wp-079/#wp-079) · [engineering log](https://borda.github.io/lucid-YOLO/work_packages/wp-079/#wp-079-engineering) | `tests/data/test_coco.py` per-worker re-seed + thread-cap tests | 014,076 | ✅ |
| 080 | `feat(docs): training-curve vector figures` | `scripts/plot_training.py` renders a run's `metrics.csv` as a deterministic SVG: three panels plus a fourth for the segmentation terms, fixed hashsalt and suppressed date metadata, panels degrading gracefully on runs predating a column · [log](https://borda.github.io/lucid-YOLO/engineering/cross-cutting/#cross-svg) | figure regenerates from `lightning_logs/version_8`; `epoch_means` and `_figure_size` doctests; `scripts/_tests/test_audit_figure_captions.py` | 045 | ✅ |
| 081 | `docs: Det-smoke report section and detector model card` | `REPRODUCTION_REPORT.md` opens as the living D10 document with the 0.1.0 detection section (mechanism-to-module map, acceptance table, both root-cause narratives, assumption outcomes, four recorded deviations); the detector model card covers intended and non-intended use, COCO composition limits and the clean-room statement | both docs pinned in `test_docs_present.py` | 045,080 | ✅ |
| 082 | `test(losses): objective invariant gate` | Pins the coordinate frame the L1 term is measured in and its scale on a near-converged state, plus a loader-level test that spawns real workers and asserts two epochs do not replay one augmentation stream. Deliberately omits an absolute share bound · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-082/#wp-082) | `tests/losses/test_loss_invariants.py`; `test_coco.py` worker-epoch divergence | 078,079 | ✅ |
| 045 | `exp(det): Det-smoke tier and report section` [GPU][PRINCIPAL] | n-scale ~50 epochs against the Det-smoke criteria. **ACCEPTED 2026-08-06** (run v8, batch 128 x 50 ep, lr 0.02): val mAP50-95 **25.30** EMA-NMS vs >25, E2E deficit 1.46 vs ~1.5, stable training, gates green. Attempts 1-2 not accepted at 3.96 / 6.28 — root cause WP-078, compounded by [WP-079](https://borda.github.io/lucid-YOLO/work_packages/wp-079/); the full record is `REPRODUCTION_REPORT.md` and `.experiments/det_smoke/` | Det-smoke acceptance met; artifacts archived with seeds/configs | 044,040,072,078,079 | ✅ |
| 083 | `test(eval): synthetic-shapes generalization golden` | 2000 generated scenes split 1800/200, n-scale, 6 epochs at 320 px, held-out val through both decode paths — a generalization gate rather than the overfit gate's loop-composition one, with mosaic on for five of six epochs so a collapsed augmentation RNG is detectable. The shared annotation reader moves into `lucid_yolo.eval.annotations` rather than being copied · [research log](https://borda.github.io/lucid-YOLO/work_packages/wp-083/#wp-083) · [engineering log](https://borda.github.io/lucid-YOLO/work_packages/wp-083/#wp-083-engineering) | `goldens/gpu/shapes_regression_det.json` via `check_goldens.py --include-gpu`; `tests/eval/test_annotations.py` | 082 | ✅ |
| 084 | `ci(repo): commit-time gates, split workflows, dev group` | Three checks move to where they can catch something: the copyleft audit stays an always-run job, the commit-trailer validator becomes a `commit-msg` hook, and the `dev` extra becomes a PEP 735 dependency group. `ci.yml` splits into `lint.yml` and `ci-tests.yml` · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-084/#wp-084) | `Provides-Extra: None` on the installed dist; the `commit-msg` hook rejects a malformed message and passes a valid one; `scripts/_tests/test_audit_licenses.py` | 004 | ✅ |
| 085 | `test(repo): doctests join the offline gate` | `make test` collects `src` and `tests` with `--doctest-modules`, so the `Examples` sections the contract requires are executed for the first time — four had rotted and one had never passed at any seed · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-085/#wp-085) | `make test` fails on a deliberately broken Example — verified | 004 | ✅ |
| 086 | `docs(policy): releases ship no trained weights` | WP-046's definition of done required published weights; the project does not release them, since the reproduction's claim rests on frozen goldens and fidelity gates rather than on a binary anyone can retrain from the recipe. Recorded as D14 and the 046 row amended; no tooling changed, `release.yml` having always uploaded only the sdist and wheel | `test_docs_present.py`; `release.yml` unchanged | 004 | ✅ |
| 046 | `release: v0.1.0 detector` [PRINCIPAL] | O3 cleared; CHANGELOG; goldens frozen to `0.1`; no trained weights (D14). **Superseded 2026-08-10 by 054**: no `v0.1.0` tag was ever cut, so there is no 0.1.0 to document or freeze against — the detector's history ships inside the single `## [0.2.0]` section and the goldens froze to `0.2` | `release.yml` green on tag `v0.1.0` | 045 | ✅ |

## 🖌️ Phase 7 — Instance segmentation, release 0.2.0 (WP-047…054)

| WP | Commit subject | Scope | DoD | Dep | Status |
| -- | -- | -- | -- | -- | -- |
| 047 | `feat(models): mask coefficient branch` | K=32 tanh coefficients per location (A14, A16, A34), opt-in behind `num_coeffs`: `None` builds no stem, so the accepted detector's module tree, its 205,600 parameters and its forward output are bit-identical. Landed ahead of its listed dep 046, which is a release-ordering gate rather than a technical prerequisite | `test_segment_head.py::test_coeff_shapes`; off-by-default state-dict and parameter identity; `params_flops_det.json` unchanged | 046 | ✅ |
| 048 | `feat(models): multi-scale proto pathway` | Eq. 8 `F_proto = X1 + sum U(phi_l(X_l))` as a standalone `ProtoFusion`, wired into nothing yet so the accepted detector cannot move. P3 is added unprojected; P4/P5 take bare 1x1 projections into P3's width and interpolate to its exact size rather than a fixed scale factor, so odd and non-square maps stay aligned (A35) | `test_proto.py::test_fusion_eq8` (closed-form, mutation-checked); identity when projections vanish; odd/non-square target size | 047 | ✅ |
| 049 | `feat(models): prototype generation stack` | Eq. 9: `ProtoNet` maps the fused feature to K raw prototype maps at twice P3 (A15, A18). Output is deliberately unactivated — coefficients already carry tanh (A16), so squashing prototypes too would compress the linear mask combination twice | `test_proto.py::test_proto_resolution` (odd non-square included); unactivated output; upsample-before-final-conv proven structurally and by 2x2-block refinement | 048 | ✅ |
| 050 | `feat(models): auxiliary semantic branch (training-only)` | `SemanticAux`: dense per-class raw logits on `F_proto` at its own resolution, `forward` returning `None` whenever `self.training` is False — keyed on module mode, so the branch is provably absent at eval and export (A17). Carries the A30 prior-probability bias; `CLS_PRIOR_PROB` and `init_cls_prior_bias` give that formula one home | `test_aux_semantic.py::test_eval_mode_inactive` (under both grad-enabled eval and `no_grad`); single-conv parameter count; prior-init and raw-logit gates; `params_flops_det` unchanged | 049 | ✅ |
| 051 | `feat(losses): instance mask loss and BCE+Dice auxiliary` | `instance_mask_loss` — per-pixel BCE cropped to the GT box and box-area normalized, reusing the A11 pixel-centre convention rather than a second one — and `semantic_aux_loss`, BCE plus soft Dice at unit coefficients. Eq. 7 `assemble_masks` lands in `proto.py` beside the prototype producer, since the WP-053 decode assembles from the same expression (A36) | `test_mask_loss.py` (9 gates), `test_semantic_aux.py` (6 gates), `test_proto.py::TestAssembleMasks::test_*` (node ids moved under this class by [WP-128](https://borda.github.io/lucid-YOLO/work_packages/wp-128/)); all mutation-checked | 050 | ✅ |
| 052 | `feat(models): Segmenter wiring; aux branch absent from deploy()` | `Segmenter` composes the coefficient-enabled dual head, `ProtoFusion`, `ProtoNet` and `SemanticAux` on the shared backbone and neck; `deploy()` holds neither the o2m branch nor the aux head — absent, not disabled. The fused feature is computed once and shared, since recomputing it would silently double that subgraph in any FLOP tally · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-052/#wp-052) | `test_segmenter.py` (9 gates, all mutation-checked); `params_flops_det` unchanged | 051 | ✅ |
| 052b | `test(models): Table S9 seg fidelity gate` | All five scales against R1 Table S9 at **±3%** params — wider than detection's ±2%, the segmentation head's sizing resting on five registered assumptions (A14, A15, A18, A34, A35) rather than published structure — and ±5% FLOPs under A29's convention · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-052b/#wp-052b) | `test_param_flops.py::test_seg_vs_tableS9`, all 5 scales; `goldens/params_flops_seg.json` — **golden frozen** | 052 | ✅ |
| 053 | `feat(eval): segmentation mask decode` | Masks assembled post-top-k, then sigmoid, bilinear upsample, box crop, threshold — in that order, since binarizing before upsampling aliases the boundary (A37). `o2o_topk` becomes a wrapper over `o2o_topk_with_indices` so coefficients are gathered by the anchors the box path ranked rather than re-ranked, and `masks_to_original` inverts the letterbox through the same geometry the boxes use | `test_segment_decode.py` (11 gates, mutation-checked); box/mask frame agreement within 1 px | 052 | ✅ |
| 053b | `feat(eval): segm evaluation, both paths` | Ground-truth masks decoded from COCO polygons **and** RLE onto one grid, opt-in so detection-only callers pay nothing; torchmetrics scores bbox and segm in a single pass. Both decoders gain `decode_with_indices`, so each path's coefficients are gathered by the anchors it actually kept — indices in the wrong base are in-range, correctly shaped and point at the wrong objects | `test_seg_eval.py::test_bbox_and_segm` (perturb only the masks: segm moves, bbox does not); mask/box pairing across distinct labels; per-path index correspondence | 053 | ✅ |
| 087 | `feat(ptl): segmentation training path` | Wires the Phase 7 components into training, which `task="segment"` had never done. Composition moves behind shared `build.py` factories, not a `Detector` object; `DualBranchLoss` returns its assignments so mask supervision uses the positives the box loss chose; both branches' coefficients supervised, decode reading the o2o stem (A38) · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-087/#wp-087) | overfit-100 mask IoU **0.8146** (>= 0.7), golden frozen; `test_seg_training.py` incl. the `gt_index`-pairing gate; detection loss bit-exact at zero gains; Det-smoke checkpoint loads strict | 053 | ✅ |
| 087b | `perf(ptl): batched mask-loss gather, one sync not 64` | `_branch_mask_loss` pads to the batch's largest positive count and selects once, replacing a data-dependent boolean index run per image per branch that forced a device-to-host sync each time; positives keep their order, so the summation order and the value are the loop's own · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-087b/#wp-087b) | `test_seg_training.py::test_batched_mask_loss_equals_per_image_loop` — `torch.equal` against the per-image loop over uneven, zero and equal positive counts; the loop stays in the test file as the oracle the frozen goldens were measured against | 087 | ✅ |
| 087c | `perf(data): vectorised, batched polygon rasterisation` | Polygon rasterisation vectorised in three steps, each superseding the last: all of a ring's edges at once, then a whole image's rings in one crossing test, then edges flattened with an instance index instead of padded to the image's longest ring. Output is identical at every step, not merely close · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-087c/#wp-087c) | `test_rasterize.py::test_vectorised_crossing_test_matches_per_vertex_loop` (irrational radius, so nothing lands on a pixel centre), `::test_batched_rings_match_one_call_per_ring`, `::test_chunking_edge_axis_not_change_masks`; data checksum goldens unchanged | 087 | ✅ |
| 087d | `perf(data): rasterise mask targets in the loader workers` | Rasterisation moves into `collate_detection`, rasterising batches in DataLoader workers ahead of the step: the grid derives from the input canvas (A15), masks travel as a bool stack split by `boxes_per_image`, and the step rejects a stack rasterised for another grid. Each ring is tested only inside its own bounding window, replacing 087c's flattened-edge batching · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-087d/#wp-087d) | `test_seg_training.py::test_loader_rasterises_masks_step_would_have`, `::test_loader_rasterised_masks_give_identical_loss`, `::test_masks_rasterised_for_another_grid_rejected`; `test_cli.py::test_mask_targets_follows_model_task`; `test_rasterize.py::test_windowed_rasterisation_matches_full_grid_rule`; seg overfit and data checksum goldens unchanged | 087c | ✅ |
| 087e | `refine(ptl): epoch mask mAP during validation` | Validation decodes the kept detections' masks through the deployed decode — not a second implementation of it — and logs `val/segm_mAP`, so a segmentation run finally moves an epoch metric about its masks. Scored on the prototype grid and capped at COCO's own 100 detections, through a second `MeanAveragePrecision` rather than one metric holding both IoU types · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-087e/#wp-087e) | `test_seg_training.py::test_validation_logs_mask_map_beside_box_map`, `::test_detection_validation_logs_no_mask_metric`, `::test_mask_map_skipped_when_batch_carries_no_ground_truth_masks`, `::test_scored_masks_paired_with_boxes_own_anchors` | 087 | ✅ |
| 087f | `refine(configs): name tiers by what they are, not by letters` | The `<task>_tier_<letter>_<letter>` configs are renamed for what they are — `det_smoke`, `det_ablations`, `seg_smoke`, planned `obb_smoke` — and the tier names follow everywhere they are written, historical mentions included, with git history keeping the old ones. No behaviour changes · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-087f/#wp-087f) | `test_cli.py::test_configs_dir_non_empty`, `::test_config_dry_parses` over every shipped config, `::test_smoke_tier_resolves_variant_n_multipliers_and_gains` and the ablations counterpart; goldens unchanged | 087 | ✅ |
| 054 | `release: v0.2.0 segmentation` [GPU][PRINCIPAL] | Seg-smoke tier plus overfit-100. **ACCEPTED 2026-08-10** (run v9, batch 128 x 50 ep, lr 0.02, bf16-mixed): box mAP50-95 **26.12** EMA-NMS vs >25; segm/box ratio **0.728** NMS vs >=0.65, ratified as the standing Seg-tier criterion; mask IoU **0.8146** vs >=0.7; `val/loss` strictly decreasing over all 49 logged epochs. Froze `goldens/frozen/0.2` · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-054/#wp-054) | Seg-smoke acceptance; `release.yml` green on `v0.2.0` | 053,087 | ✅ |

## 🔄 Phase 8 — Oriented detection, release 0.3.0 (WP-055…064)

| WP | Commit subject | Scope | DoD | Dep | Status |
| -- | -- | -- | -- | -- | -- |
| 055 | `feat(data): rotated geometry primitives` | `rotated_geom.py`: long-edge canonicalization (`w >= h`, theta in \[-45,135) deg), point-in-rotated-rect, quad-box conversion both ways (A23). Three conventions chosen here and inherited downstream: theta turns from +x towards +y, containment is edge-inclusive, an exact square folds towards zero · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-055/#wp-055) | `test_rotated_geom.py::test_theta_pi_equivalence`, `::test_canonicalization`; ring-order invariance over all eight orderings of a quad; round-trip; a rotated box excludes a point its envelope includes; both range bounds walked two ulps either way | 054 | ✅ |
| 056 | `feat(data): DOTA parsing and long-edge conversion` [DATA] | `dota.py`: R18's 15 categories in published order, headers skipped, quad to canonical long-edge box via [WP-055](https://borda.github.io/lucid-YOLO/work_packages/wp-055/). Establishes the **instance-axis invariant**: `rboxes[i]` is the instance of `boxes[i]`/`labels[i]`, `boxes` the unclipped quad envelope. Malformed lines and unknown categories raise with file and line. New A39 defers `difficult` behind a required keyword · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-056/#wp-056) | `test_dota_parse.py` (31 tests, all against synthetic fixtures); `check-data` counts verified against a fixture root only — **the real 2,806 / 188,282 / 15 clause is unverified, DOTA is not on this machine** | 055 | ✅ |
| 057 | `feat(data): 1024 px overlapping crop tiling` [DATA] | `tiling.py`: integer window placement with the trailing window flush against the far edge, so coverage holds for any overlap and image; plus R18's partial-object rule — clip, `U = clipped area / original area`, drop `U == 0`, **flag** `U < 0.7` difficult, re-fit. `U` surfaced as `visible_fraction`. A21 revised: the 200 px overlap is attested by neither cited source · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-057/#wp-057) | `test_tiling.py::test_coverage_no_gaps` (8 sizes incl. 4000x3000 at the A21 defaults); shapely clipped-area oracle; straddle fractions summing to 1; threshold pinned either side of 0.7. **Real DOTA never tiled — not on this machine** | 056 | ✅ |
| 058 | `feat(data): rotated-aware augmentation` | `rotated_aug.py` holds the shared rotated arithmetic; `affine.py`/`mosaic.py`/`mixup.py` lose the `NotImplementedError` they raised on non-empty `rboxes`: a rotated box warps as four corners and is re-fitted. CopyPaste keeps its guard — its paste unit is a rasterized polygon and the oriented path carries none. New A40 records the three policy choices · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-058/#wp-058) | `test_rotated_aug.py::test_roundtrip` plus 59 more; canonical output over the whole angle sweep through every transform; mirrored corners compared as geometry rather than as angles; similarity exact against the derived shear bound; every 0.2 golden and both checksums unmoved | 057 | ✅ |
| 059 | `feat(losses): ProbIoU rotated loss` | Gaussian-box ProbIoU (R17, A19): both losses R17 proposes exposed side by side, since it suggests starting on one and switching and [WP-088](https://borda.github.io/lucid-YOLO/work_packages/wp-088/) owns that choice. R17's formulas are evaluated through cancellation-free identities, which is what makes float32 sufficient where float64 is unavailable (D12c). New A41 records the degenerate-box side floor · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-059/#wp-059) | `test_probiou.py::test_matches_numerical_integration_of_definition` (2e-14 relative), `::test_matches_literal_transcription_of_r17` (float64, 1e-12), `::test_box_against_itself_scores_exactly_one`, `::test_two_losses_one_quantity`, `::test_float32_accuracy_aspect_ratio_independent`, `::test_degenerate_boxes_stay_finite_forwards_and_backwards`; 75 tests | 055 | ✅ |
| 060 | `feat(losses): square-object angle loss` | `angle_loss.py` implements Eq. 14-15 as the auxiliary term for square and near-square boxes, whose orientation the rotated IoU loss cannot resolve. Three factors — TAL's `q_i`, log-Gaussian `omega` at lambda=3, double-angle penalty — normalized by `S = max(sum q_i, 1)`, pre-gain since A22's weight is a registered gap. New A42 records the degenerate-target exposure · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-060/#wp-060) | `test_angle_loss.py::test_wrap_range`, `::test_omega_profile`, `::test_sin2_extrema`, `::test_boundary_continuity` — 58 tests; continuity asserted as a Lipschitz bound across the wrap, not mere finiteness | 059 | ✅ |
| 061 | `feat(assign): rotated containment for TAL and STAL` | Oriented GTs reach the assigner through one optional `gt_rboxes` argument into `_candidate_mask`, the only stage that changes: candidacy becomes the point-in-rotated-rect test while the IoU, the alignment metric, targets and weights keep running on axis-aligned boxes (A25, revised here). The STAL surrogate gains a rotated form that re-canonicalizes (new A43) · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-061/#wp-061) | `test_rotated_assign.py::test_tiny_rotated_gt` — a 6x6 GT at 45 deg, invisible to vanilla TAL, gains 4 candidates; `::test_rotation_excludes_envelope_corners`; `::test_axis_aligned_assignment_bit_identical` over three assigners | 058,060 | ✅ |
| 062 | `feat(models): OBB head, direct angle, NMS-free decode` | A third per-level stem on each head branch emits one raw angle per anchor — theta = z, Eq. 13, Eq. 12's squashing gone, no activation. The oriented decode composes it with the **unchanged** `decode_ltrb`, canonicalizing the dense output (new A44; A23 now `active`); `o2o_rotated_topk` gathers theta by the selector's own indices, not a second ranking (new A45) · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-062/#wp-062) | `test_obb_head.py` (18 tests: term-by-term stem arithmetic, an unsquashed stem at 100 radians, half-turn continuity, disjoint branch stems, end-to-end through the deployed view); `test_param_flops.py::test_obb_vs_tableS11` at 1024 px over 15 classes — params ±3.5% (binding scale s at +3.27%), FLOPs ±5% (binding scale n at +4.84%) — **golden frozen** | 061 | ✅ |
| 063 | `feat(eval): rotated mAP on DOTA val` | Exact polygon-intersection rotated IoU — Sutherland-Hodgman clipping + shoelace, each pair re-centred on its midpoint **before** corner expansion (A24, now `active`), plus the accumulator over it. Four constants R1 never states are registered, not inherited from library defaults: A46, A47, zero-non-difficult-GT classes excluded, A48. Tile merge deferred to [WP-107](https://borda.github.io/lucid-YOLO/work_packages/wp-107/) · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-063/#wp-063) | `test_dota_eval.py::TestRotatedIou::test_vs_oracle` (600 pairs across the angle range, half near-coincident) plus 15 named edge cases; `TestRecallGridBoundary::test_boundary_recall_sampled_exactly`, reference-independent and hand-derived; `TestAccumulator::test_matches_axis_aligned_instrument_at_zero_angle` | 062 | ✅ |
| 088 | `feat(ptl): oriented-detection training path` | The Phase 8 counterpart of [WP-087](https://borda.github.io/lucid-YOLO/work_packages/wp-087/): oriented losses reach `_task_extra_loss`, rotated targets thread through the loaders, `obb_smoke.yaml` added, and A49/A50 are measured rather than argued. **Prerequisite for [WP-064](https://borda.github.io/lucid-YOLO/work_packages/wp-064/)'s OBB-smoke run** · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-088/#wp-088) | `test_obb_training.py`; overfit-100 rotated mAP50 >= 0.9 — **landed 2026-08-12 with the criterion unmet and the row left open**, then **closed 2026-08-12** at [WP-093](https://borda.github.io/lucid-YOLO/work_packages/wp-093/)'s revised gain, scoring 0.9390 at the recipe's own seed with `goldens/gpu/overfit_micro_obb.json` frozen there | 063 | ✅ |
| 094 | `feat(data): DOTA tiles into a trainable layout` | `scripts/build_dota_tiles.py` writes the COCO layout the oriented reader reads, carrying R18's class order into the label space — the disk-writing caller [WP-057](https://borda.github.io/lucid-YOLO/work_packages/wp-057/) left unwritten, without which `obb_smoke.yaml` pointed at a directory no code produced. Two encodings registered rather than invented quietly (A52, A53); the reader forwards `difficult` onto the A51 channel · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-094/#wp-094) | `tests/data/test_build_dota_tiles.py` — a round trip through `CocoDetectionDataset` rather than a JSON-shape check; `tests/data/test_coco.py::test_difficult_key_reaches_targets_channel` on both readings | 057,088 | ✅ |
| 095 | `feat(eval): per-tile oriented acceptance instrument` | `scripts/eval_obb.py` scores a finished checkpoint through the [WP-063](https://borda.github.io/lucid-YOLO/work_packages/wp-063/) accumulator on an explicit split, so A46, A47 and A48 all apply. Scoring runs in letterbox coordinates with the ground truth letterboxed alongside — an equality rather than a proxy — the number explicitly **per tile** in its own output. Checkpoint loading moves into `lucid_yolo.eval.checkpoint` · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-095/#wp-095) | `tests/eval/test_eval_obb.py` — end to end over a built layout, plus the frame-and-label-space gate that feeds the split's own ground truth back as perfect detections and requires 1.0 | 094 | ✅ |
| 096 | `refine(cli): one data CLI, one eval CLI` | Three commands where there was one console script, one `make` target, two unshipped files: `lucid-yolo` unchanged, `lucid-data` over `download`, `check`, `build-tiles`, and `lucid-eval`, which takes no task argument and reads its protocol from the checkpoint. Parsing moves to jsonargparse; flags become underscored, `lucid-download` survives as a deprecated alias · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-096/#wp-096) | `tests/cli/test_cli_surface.py` — every declared console-script target resolved the way the wrapper resolves it, both dispatch branches with the protocol functions stubbed, and the deprecated alias still running on its old flags | 094,095 | ✅ |
| 097 | `fix(data): report DOTA totals, require only stated ones` | `check_dota_root` compared summed train+val counts against R18's 2,806 / 188,282 / 15, which describe DOTA-v1.0 whole, so the first command an operator runs failed on every correct download. The three expectations default to `None`, reported as a `NOTE` and compared only when the caller states one; a DOTA expectation passed with `--dataset coco` now raises · [research log](https://borda.github.io/lucid-YOLO/work_packages/wp-097/#wp-097) · [engineering log](https://borda.github.io/lucid-YOLO/work_packages/wp-097/#wp-097-engineering) | `tests/data/test_dota_parse.py` — an unstated total is a note and a well-formed root passes on its layout alone; a stated one still fails and names both counts, through the library and through `lucid-data check`; a DOTA expectation aimed at COCO raises | 056,096 | ✅ |
| 098 | `refine(data): resolve a split's paths by convention` | `lucid_yolo.data.layout.CANDIDATES` states the split-path convention in one place and `resolve_split` returns the first whose directory **and** annotation file both exist, so `--data.data_root` is now sufficient for every tier. The datamodule had defaulted to COCO 2017's own spelling, which put this project's own tiled layout in the position of restating all four paths | `tests/data/test_layout.py` — every named layout resolves to itself, a half-present layout does not match, precedence is the table's order, and both consumers reach a tiled root from the root alone | 094,095 | ✅ |
| 100 | `perf(data): parallel tiling with a progress bar` | `tile_source_image` becomes the unit of work, dispatched through a `ProcessPoolExecutor` with `--workers` and each worker pinned to a single torch thread. Ids are assigned in the parent in source order and never in the workers, so the output JSON does not depend on which process finished first; a `tqdm.auto` bar reports per source image · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-100/#wp-100) | `tests/data/test_build_dota_tiles.py` — a four-image pooled build writes byte-identical JSON to the serial one, every tile image reaches disk, no placeholder id survives into the file, and the worker count is forwarded rather than accepted and dropped | 094 | ✅ |
| 101 | `feat(data): JPEG tiles and a completion-order bar` | `--suffix .jpg` with `--quality` writes tiles through the JPEG encoder, PNG staying the default because a tile is an exact crop of a lossless source; annotations are asserted byte-identical across the two encoders. Separately the progress bar counts completions rather than positions — ordering is a requirement of the numbering, not of the counting · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-101/#wp-101) | `tests/data/test_build_dota_tiles.py` — JPEG tiles are written and read back through the dataset, the annotations match the PNG build's exactly, and an unknown suffix fails at the flag rather than after the first image | 100 | ✅ |
| 102 | `perf(ptl): validation that is not CPU-bound` | Two validation defaults nobody had revisited at 1024 px: the val worker cap now applies only to a count this class chose itself and scales with the pixel count, and an oriented run logs `val/rotated_mAP` and no `val/mAP`, the axis-aligned figure reading the A44 composition's pre-rotation rectangle and so scoring correct and random orientations identically · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-102/#wp-102) | `tests/data/test_coco.py` — a named worker count reaches the val loader, an auto-chosen one is still capped, and the cap scales with the letterbox side; `tests/ptl/test_obb_training.py` — an oriented epoch logs the rotated metric and not the box one | 088 | ✅ |
| 103 | `fix(ptl): a val worker count that fits the host` | [WP-102](https://borda.github.io/lucid-YOLO/work_packages/wp-102/) deleted the memory guard where it should have deleted the silence. A count *inherited* from a named train count now passes the `_shm_capped_workers` budget and warns with the number and the override flag; a count stated as `--data.val_num_workers` is bounded by nothing · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-103/#wp-103) | `tests/data/test_coco.py` — an inherited count is bounded and warns, a named `val_num_workers` is not, and a fitting inherited count still reaches the loader whole | 102 | ✅ |
| 104 | `perf(eval): match every IoU threshold in one pass` | The ten IoU thresholds share the overlap matrix and the score order and differ only in what counts as a hit, so the availability state becomes `(T, G)` and the walk happens once instead of ten times; detections reaching no ground truth at the lowest threshold skip it entirely · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-104/#wp-104) | `tests/eval/test_dota_eval.py` — a ground truth claimed at a loose threshold is still free at a strict one, and a detection reaching nothing is a false positive that consumes nothing; the existing exact-value, difficult-flag and COCOeval cross-check tests pin the output unchanged | 103 | ✅ |
| 105 | `fix(eval): a report that survives, and a pass you can watch` | Two defects the first oriented tier evaluation exposed, both in `lucid-eval` rather than in what it measures: `--output` now creates its parent, having raised **after** the scoring pass printed its numbers, and a pass of known length draws a `tqdm.auto` bar wrapped at the CLI rather than inside `DualPathEvaluator` · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-105/#wp-105) | `tests/eval/test_eval_obb.py` — the report is written into a directory that does not exist yet | 104 | ✅ |
| 106 | `docs(obb): the oriented tier's record` | The 0.3.0 artifacts, matching what 0.1.0 and 0.2.0 carry: an oriented model card, the four-panel `obb_smoke_training.svg`, the reproduction-report section, standalone per-tile evaluations for EMA and raw weights. `plot_training.py` gains the oriented panels — an obb run logs no `val/mAP` ([WP-102](https://borda.github.io/lucid-YOLO/work_packages/wp-102/)), so panel 1 falls back to the rotated pair. The three cards consolidate into `docs/model_cards/` | `scripts/_tests/test_audit_docs_present.py` — every card in `docs/model_cards/` is a required doc, and every required card exists | 105 | ✅ |
| 064 | `release: v0.3.0 oriented detection` [GPU][PRINCIPAL] | OBB-smoke tier accepted 2026-08-14: run v10 at per-tile rotated mAP50 0.5242 and mAP50-95 0.2914 on DOTA-v1.0 val (EMA, 10,132 tiles), overfit-100 wiring gate at 0.9390 against a 0.9 floor, O4 resolved, 0.2 goldens green and 0.3 frozen beside them. The whole-image tile-merge policy this row also owned is **not** delivered here and moves to [WP-107](https://borda.github.io/lucid-YOLO/work_packages/wp-107/) · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-064/#wp-064) | OBB-smoke acceptance; `make gate` green; changelog section and frozen goldens for 0.3 | 063 | ✅ |

## 🔮 Phase 9 — Inference and generalization (WP-089…092, 074…075, 099, 107)

Deliberately after all three task phases rather than inside each. A predict path is where preprocessing and coordinate-frame mistakes surface, so building all three against one settled decode surface catches disagreements between them that a per-phase predict cannot see. It also keeps the release train clean: 0.1.0 already shipped, and retrofitting a detector-only predict into it now would mean carrying segmentation code in a detection release.

| WP | Commit subject | Scope | DoD | Dep | Status |
| -- | -- | -- | -- | -- | -- |
| 089 | `feat(predict): detection inference on one image` | Checkpoint and image to detections in original coordinates, as a library call and a fourth `lucid-predict` command. Reuses `Letterbox` and `to_letterboxed_original` rather than re-deriving the inverse, plus the evaluator's read-scale-letterbox chain as `read_letterboxed_image`. Both decode paths selectable; a non-`detect` task refused **in the library** by name · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-089/#wp-089) | `tests/predict/test_predict.py` — a detection planted on a 64x128 image's canvas lands where the letterbox geometry says it must, on both decode paths, so a pad-blind or axis-swapped inverse cannot pass by symmetry; a `segment` and an `obb` checkpoint are each refused naming the task; `--output` creates its parent ([WP-105](https://borda.github.io/lucid-YOLO/work_packages/wp-105/)) | 046 | ✅ |
| 090 | `feat(predict): segmentation inference` | Extends predict with masks in original coordinates, reusing `decode_instance_masks` and `masks_to_original`. A second entry point `predict_segmentation` returns a row-aligned `SegmentedPrediction`, not a union return, so the detect path's `(N, 6)` contract is unbroken. Masks assembled while the boxes are still on canvas (the A11 crop frame); reports carry COCO RLE · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-090/#wp-090) | `tests/predict/test_predict_seg.py::test_mask_matches_box` — the planted mask's extent equals the planted rectangle clipped by the returned box, so a mask that merely filled its box fails and so does one the A11 crop never touched; both decode paths, each reading its own branch's coefficients; an `obb` checkpoint still refused by name | 089, 054 | ✅ |
| 090b | `fix(eval): masks for an empty instance axis` | `decode_instance_masks` raised from `F.interpolate`, which treats the instance axis as channels and rejects a zero-length one, for as long as it has existed — unreachable from the evaluator, reachable from predict. Returns the empty stack instead, keeping the shape contract a caller stacking per-image results needs; [WP-090](https://borda.github.io/lucid-YOLO/work_packages/wp-090/)'s call-site guard becomes an optimisation · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-090b/#wp-090b) | `tests/eval/test_segment_decode.py::test_no_kept_detections_returns_empty_stack` — `(B, 0, H, W)`, still boolean | 090 | ✅ |
| 092 | `fix(eval): exact recall sampling for the detection instrument` | Gives `evaluate_bbox` the exact recall sampling [WP-063](https://borda.github.io/lucid-YOLO/work_packages/wp-063/) built for the oriented instrument (A46): torchmetrics builds its 101 thresholds with a float32 `linspace` that overshoots `k/100` at **36** of them, so a class whose attained recall lands on one forfeits `1/101` of its AP — small, one-directional and always downward, across every COCO number reported through this path | `test_coco_eval.py` — a constructed class whose recall lands on a float32-forfeited boundary, asserted against a hand-derived `(k+1)/101`; the pin on the dependency's own defect; the guard that fires if the metric stops honouring `rec_thresholds` | 063 | ✅ |
| 091 | `feat(predict): oriented-detection inference` | Extends predict with rotated boxes as the A45 `(N, 7)` tuple the oriented evaluator emits and consumes. Canonicalization is **not** added here — `decode_rboxes` ends in `canonicalize` (A23) and a letterbox inverse preserves `w >= h` and theta. Only the `e2e` path existed at this row: `--decoder nms` was refused, not silently substituted; 091b lifted the refusal · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-091/#wp-091) | `tests/predict/test_predict_obb.py` — four plants describing one rectangle (already-canonical, a half turn above the range, below the `-pi/4` floor, and short-edge-first) all return the same canonical box **after** the inverse, plus a 500-radian plant asserted on range and long-edge order rather than on a value that would restate `canonicalize`; an `obb` head with no angle stems raises rather than returning axis-aligned boxes in an oriented tuple's shape | 089, 064 | ✅ |
| 091b | `feat(decode): rotated suppression decoder` | `RotatedNMSDecoder` over the **one-to-many** branch, lifting [WP-091](https://borda.github.io/lucid-YOLO/work_packages/wp-091/)'s refusal: greedy class-wise suppression by `rotated_iou` (A24) over a canonicalized decode (A23). Emits the same A45 `(B, 300, 7)` tuple as `o2o_rotated_topk`. Framed as the o2m path's baseline, **not** a repair to the e2e path. Threshold A61; the axis-aligned value it inherits, A62 · [research log](https://borda.github.io/lucid-YOLO/work_packages/wp-091b/#wp-091b) · [engineering log](https://borda.github.io/lucid-YOLO/work_packages/wp-091b/#wp-091b-engineering) | `tests/decode/test_rotated_nms.py` — two 20x2 bars crossing at one centre have envelope IoU 1.000 against rotated 0.0535, so both survive here while `NMSDecoder` on the identical plant keeps one; the threshold read from the registered constant so register and code cannot drift, and a pair at 0.667 asserted on both sides of it so the parameter is consulted rather than fixed at an extreme | 091 | ✅ |
| 093 | `fix(losses): stable angle regression for elongated boxes` | A22's angle gain revised 1.0 to 0.25 on a five-seed dose-response, taking the oriented overfit gate from 1 of 5 seeds clearing the 0.9 floor to 4 of 5, the run-to-run spread from 0.667 to 0.096, and the mean elongated angular error from 25.47 to 11.85 degrees · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-093/#wp-093) | Mean absolute elongated-target angular residual below 15 degrees, and overfit-100 rotated mAP50 at or above 0.9. **Partially met, and the shortfall is recorded rather than dissolved**: 4 of 5 seeds, not 5 of 5 — seed 1 lands at 0.8638 with 19.84 degrees. Only dropping R1's angle term entirely reached 5 of 5, and that is a paper deviation the register does not authorise | 088 | ✅ |
| 107 | `feat(eval): whole-image merge for overlapping tiles` | Tile detections mapped back to source coordinates through the A53 window provenance, seam duplicates resolved by **core ownership** (A59) — ownership, not suppression: nothing is ranked by confidence. The oriented evaluator reports both figures, per-tile and whole-image, cap lifted for the merged unit (A60). The step [WP-064](https://borda.github.io/lucid-YOLO/work_packages/wp-064/) owned and 0.3.0 shipped without · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-107/#wp-107) | `tests/eval/test_tile_merge.py` — an object straddling a seam yields one detection, not two, and the duplicate it removes is worth a sixth of the score; a whole-image score over a single-tile image equals the per-tile score with no tolerance, on four metric keys each asserted strictly inside `(0, 1)` so the equality is not between saturated values; the one-shot-per-object cost pinned so it cannot be quietly fixed into NMS; the flattering residual measured at 0.165 of `map_50` rather than argued away | 064 | ✅ |
| 108 | `docs(roadmap): the Scope column says what a package does` | Thirty-two Scope cells carried the reasoning behind their package rather than its scope, the worst at 1709 characters against a 286 median. Compressed toward that median, with what survives compression moved to the linked log — 12 rows had no log section at all, which is why they grew. The median is written into the header as guidance and nothing gates it · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-108/#wp-108) | `scripts/_tests/test_audit_docs_present.py` — the six-column and status gates still pass on every rewritten row, and the header's package count matches the table | 107 | ✅ |
| 074 | `feat(data): RF100-VL incremental downloader and merged COCO layout` [DATA] | Extend `lucid-data download` with an `rf100-vl` mode over the `rf100vl` package (Roboflow API key; 100 COCO-JSON datasets, Apache-2.0): per-dataset download, remap into a union label space, re-id, append into one merged COCO layout. Splits preserved; `RF20-VL` subset flag (added 2026-08-04, user request). **Dropped 2026-08-18**: closed without its DoD met rather than left open indefinitely on an upstream PR neither party owns — the licence question RESEARCH_LOG already flagged as "most likely to kill the row" (the 100 datasets carry their own individual licences, unresolved by either route) never resolved, and the deferral had no end condition this project controls · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-074/#wp-074) | offline unit suite over a synthetic two-dataset fixture; merged-layout `check_data.py` parity | 068,073 | ⊘ |
| 075 | `exp(det): RF100-VL generalization tier` [GPU][PRINCIPAL] | Fine-tune/eval the detector on the merged RF100-VL (RF20-VL smoke first); recipe, per-domain metric breakdown, report section. **Dropped with 074** — the merged layout it trains on does not exist and 074 is closed, not merely paused | acceptance criteria set with the [WP-074](https://borda.github.io/lucid-YOLO/work_packages/wp-074/) design; artifacts archived with seeds/configs | 045,074 | ⊘ |
| 091c | `refine(decode): rotated geometry in the geometry module` | `rotated_iou` lived in `eval/dota_eval.py`, imported by `decode/rotated_nms.py` ([WP-091b](https://borda.github.io/lucid-YOLO/work_packages/wp-091b/)), putting an evaluation module on the decode path's import graph for pure geometry. Moves to `data/rotated_geom.py` beside `canonicalize` and `polygons_to_rboxes`, the old name a **permanent documented re-export**, not a deprecation. The precision argument travels with it · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-091c/#wp-091c) | the existing `test_dota_eval.py` and `test_rotated_nms.py` suites pass **unchanged** — not even an import line — against the new home; 20/20 goldens unmoved, which is the only evidence a move of a scoring-path function changed nothing, since a silently perturbed kernel would still clear the shapely oracle's 1e-4 tolerance | 091b | ✅ |
| 091d | `refine(data): one rotated-box shape check` | Two private validators in `rotated_geom.py` doing one job, differing only in the letter naming the row count. `_check_rboxes` is deleted and `rotated_iou` guards through `_check_2d`, so every rejection the module raises spells that count `N`; the pinned regex in `tests/eval/test_dota_eval.py` moves with it and gains the argument name · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-091d/#wp-091d) | `tests/data/test_rotated_geom.py` and `tests/eval/test_dota_eval.py` agree on one message; a malformed input still names the offending shape | 091c | ✅ |
| 091e | `refine(losses): the shape a loss will accept` | `losses/probiou.py`'s `_check_rboxes` passes **arbitrary leading dimensions** where `rotated_geom` requires exactly 2-D, and the looser form is kept: the arithmetic is elementwise, so a pairwise `(4, 1, 5)` against `(1, 3, 5)` is well defined. The message tail is pinned, and a four-dimensional plant asserts the divergence rather than leaving it inferred · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-091e/#wp-091e) | a plant with a genuine extra leading dimension states which way the contract was settled — accepted, or rejected with the shape named; the tail pinned once decided, so the next reader cannot re-open it by accident | 091d | ✅ |
| 099 | `feat(data): YOLO-format dataset reader` | A YOLO-format reader — `data.yaml` plus a `labels/<split>/*.txt` tree of normalized `cls cx cy w h` rows, oriented rows eight normalized polygon coordinates — producing the same `Targets` container as the COCO path. Grounded in R32; five gaps registered A54-A58; a YOLO root resolves through its **own** candidate table. **Partially met**: dispatch moved to 099b · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-099/#wp-099) | `tests/data/test_yolo_parse.py` against synthetic fixtures (A26): round-trip with every modality aligned and `rbox_envelopes(rboxes) == boxes`; normalized-to-pixel against hand-computed values on a non-square 100x40 image, so an x/y transposition cannot pass; an oriented rectangle whose long edge runs at 45 degrees, so a reader returning the envelope fails; and five distinct rejections each asserted against the full `file:line:` prefix, with the bad row placed third so the number is a real count | 098 | ✅ |
| 099b | `feat(data): a datamodule that dispatches on layout` | `DetectionDataModule` picks the COCO or YOLO reader by which layout `data_root` satisfies, so a YOLO root trains through `lucid-yolo fit`. `detect_layout` evaluates **both** candidate tables; satisfying both refuses (A63). A `layout` override covers what inference cannot. `mask_targets` raises; argument failures at construction, filesystem verdicts at `setup()` · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-099b/#wp-099b) | `tests/ptl/test_datamodule.py` — a YOLO root and a COCO root under the same flag each reach their own reader; a root satisfying neither names both conventions and all thirteen paths tried; a root satisfying both refuses and names the override that settles it; `copy_paste` suppressed on a polygon-free root with the RNG draw still consumed, so the frozen data goldens are unmoved | 099 | ✅ |
| 099d | `docs(data): the two layouts and what an annotation looks like` | `docs/DATASETS.md` covered provisioning only. Adds a reference section: both directory conventions as the candidate tables probe them, one worked annotation per task (detect, segment, obb) per container (COCO, YOLO), what YOLO cannot express (rings, hence `mask_targets`), the oriented container's non-schema keys (A53) · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-099d/#wp-099d) | `scripts/_tests/test_audit_docs_present.py` — the section exists; every layout claim traceable to `data/layout.py`, `data/yolo.py`, `data/coco.py` or `data/dota.py` | 099b | ✅ |
| 099c | `feat(data): YOLO layout in the check module and a worked config` | `check_dataset` learns the YOLO tree, and an unstated `--dataset` probes instead of defaulting to `coco`: a pre-flight answering a different question than its run reports a missing `train2017` for a root that trains fine. `dota` stays named, no run reading it and no probe covering it. `configs/det_yolo_smoke.yaml` is the worked `--data.layout` example · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-099c/#wp-099c) | `tests/data/test_check.py` — a YOLO root validates with no `--dataset`; a missing label file names split and stem, a malformed row names `{path}:3:`, and a class the `data.yaml` does not declare fails as `outside 0..1` | 099b | ✅ |
| 099e | `feat(data): the splits a check is asked for` | `check_dataset` gains `splits`, threaded through `_check_root` into `check_yolo_root` / `check_dota_root`; unstated keeps each layout's own `YOLO_SPLITS` / `DOTA_SPLITS`, named at the branch that knows the layout. `coco` refuses the flag, its two splits being the published fact its counts come from; an empty tuple raises in both root checkers · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-099e/#wp-099e) | `tests/data/test_check.py::TestCheckDatasetSplits` — a train-only YOLO root passes with `--splits '[train]'` and fails without it; `coco` and `()` each raise, the latter from the library entry too | 099c | ✅ |

## 📦 Phase 10 — Consolidation, release 0.4.0, then rolling (WP-065…067, 109, 110)

| WP | Commit subject | Scope | DoD | Dep | Status |
| -- | -- | -- | -- | -- | -- |
| 065 | `docs(report): consolidated multi-task reproduction note` | A section reading the three tiers against each other, appended not merged (D10 is append-only): the shared trunk and what each head cost, a 23-row union of the per-tier assumption tables marking the 11 an accepted run carried without isolating, project-wide deviations split from per-tier ones, and four hypotheses with the test each needs · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-065/#wp-065) | `scripts/_tests/test_audit_docs_present.py::test_report_sections` — every tier heading plus the consolidation, so no section can be renamed out of an append-only file | 064 | ✅ |
| 066 | `test(export): the graph answers what the checkpoint answers` | Two gates on the deployed one-to-one graph, all three heads: the exported ONNX carries no `NonMaxSuppression` and does carry `TopK` at a static `(1, 300, 6)` / `(1, 300, 7)`; and run under onnxruntime it reproduces the checkpoint-loaded module's decode — class ids exact, boxes within 4.6e-05. `onnx` and `onnxruntime` are dev-group only · [research log](https://borda.github.io/lucid-YOLO/work_packages/wp-066/#wp-066) · [engineering log](https://borda.github.io/lucid-YOLO/work_packages/wp-066/#wp-066-engineering) | `tests/models/test_onnx_export.py::test_e2e_graph_ops` and `::test_e2e_export_matches_checkpoint`, each over detect, segment and obb | 065 | ✅ |
| 109 | `fix(scripts): audit the binaries a wheel ships` | A third check in `scripts/audit_licenses.py` (the path as it stood here; [WP-129](https://borda.github.io/lucid-YOLO/work_packages/wp-129/) moved the script to `scripts/lint/audit_licenses.py`), over the files each wheel's `RECORD` declares, matched against a named table of copyleft native libraries. Catches the wheel that declares a permissive license, ships license documents naming no copyleft, and vendors a GPL binary regardless — `av`'s `libx264`, met while sourcing [WP-067](https://borda.github.io/lucid-YOLO/work_packages/wp-067/)'s example (D16) · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-109/#wp-109) | `scripts/_tests/test_audit_licenses.py::test_shipped_x264_flagged_though_nothing_declares_it` and `::test_live_environment_ships_no_unallowed_copyleft_binary` | 004 | ✅ |
| 110 | `refine(cli): drop the deprecated download alias` | The removal 0.3.0 put in writing in three places — `[project.scripts]`, AGENTS.md sec. 2, the 0.3.0 changelog — carried out on schedule: the console script, the argparse parser that served only it, and its `__main__` twin. `lucid-data download` is untouched. Every operator-facing string naming the alias names the surviving spelling now, the `--verify` repair hint included — asserted runnable rather than merely present · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-110/#wp-110) | `tests/cli/test_cli_surface.py::test_deprecated_download_alias_gone` and `tests/data/test_download.py::TestCliVerify::test_repair_hint_names_command_still_parses` (node id moved under `TestCliVerify` by [WP-128](https://borda.github.io/lucid-YOLO/work_packages/wp-128/)) | 089, 096 | ✅ |
| 067 | `release: v0.4.0 consolidated note and examples` [PRINCIPAL] | The worked example is drawn with `matplotlib` (R29), not `supervision`, which D16 refused: it hard-requires `av`, whose wheel ships a GPL `libx264` under BSD-3-Clause metadata. `scripts/draw_predictions.py` draws a checkpoint's own answer for all three tasks — boxes, per-instance masks, rotated quadrilaterals rather than their upright envelopes. Tag `v0.4.0` is the one piece left, deferred to the user (not blocking later WPs); the roadmap reopens for the next 0.MINOR once it lands · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-067/#wp-067) | `scripts/_tests/test_draw_predictions.py`, and `release.yml` green on `v0.4.0` | 066 | ✅ |

## 🔁 Phase 11 — Rolling, toward 0.5.0 (WP-111…119, 127…130)

Opened at the 0.4.0 release, and deliberately opened with two rows rather than a plan: both are deferrals this repository already recorded in writing, so they are the queue's own backlog rather than a new direction. What a 0.5.0 is for is decided when there is a run behind it.

| WP | Commit subject | Scope | DoD | Dep | Status |
| -- | -- | -- | -- | -- | -- |
| 111 | `exp(obb): a whole-image oriented figure` [GPU][PRINCIPAL] | The OBB-smoke checkpoint (`version_10`) re-scored through `merge_whole_images` ([WP-107](https://borda.github.io/lucid-YOLO/work_packages/wp-107/)) on real DOTA-v1.0 val, 458 source images, CUDA (operator-run): whole-image rotated mAP50-95 **0.3146** against the per-tile **0.2914**, every one of the four metrics higher rather than lower. [WP-107](https://borda.github.io/lucid-YOLO/work_packages/wp-107/)'s own "can only lower" claim holds for core ownership alone; A60's lifted detection cap on the merged pass, exercised here for the first time, is the leading reason and is not isolated from the merge in this measurement · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-111/#wp-111) | A whole-image rotated mAP50-95 in the reproduction report, beside the per-tile figure rather than replacing it (D10) | 107, 064 | ✅ |
| 112 | `refine(export): one exportable decode path` | `src/lucid_yolo/export.py` -- `E2EExportGraph` plus `DetectExportGraph`/`SegmentExportGraph`/`OrientedExportGraph`, reproducing `predict.py`'s `predict_image`/`predict_segmentation`/`predict_oriented` `"e2e"` compositions call for call, promoted out of the test file [WP-066](https://borda.github.io/lucid-YOLO/work_packages/wp-066/) left them private to. One necessary divergence documented rather than hidden: a traced graph's fixed output shape means `SegmentExportGraph` gathers mask coefficients for every one of its `k` rows, padding rows included, which only stays safe when the canvas's anchor count exceeds `k` · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-112/#wp-112) | `tests/models/test_onnx_export.py` passes against the shared composition rather than its own wrappers | 066 | ✅ |
| 113 | `docs(all): an emoji on every top-level heading` | Every H1 and H2 across README.md and `docs/**` carries one topical emoji, chosen per section rather than decoratively repeated, so a long register scrolls as landmarks instead of undifferentiated grey. The four README headings other sections link to gain explicit `<a id=>` anchors, since an emoji changes the slug a renderer derives · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-113/#wp-113) | `scripts/_tests/test_audit_docs_present.py` passes with its section assertions keyed on titles rather than decoration; every in-page link still resolves | 067 | ✅ |
| 113b | `feat(scripts): absolute README links for PyPI` | `scripts/absolutize_readme.py` rewrites the README's repository-relative links to absolute ones at packaging time: figures through `raw.githubusercontent.com`, documents through `github.com/.../blob`, both pinned to the tag being released. Opt-in per invocation (`--ref` or `LUCID_YOLO_RELEASE_REF`), reverted after a local build, wired into `release.yml` ahead of `uv build`. Lettered off 113 and landed in its commit: it exists because that upgrade gave the README figures worth publishing · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-113b/#wp-113b) | `scripts/_tests/test_absolutize_readme.py`, including that an ordinary build cannot trigger the rewrite and that the workflow rewrites before it builds | 067 | ✅ |
| 114 | `docs(site): a MkDocs Material site over docs/` | The registers are published twice from one source: as plain markdown on GitHub and as a searchable Material site, with `mkdocs.yml` owning no prose beyond `docs/index.md` — a front page that routes a reader to the register answering their question and quotes the reproduced numbers rather than restating them. `--strict` on every pull request, GitHub Pages on every push to main, and the licence audit run in that job because the docs group is in no other workflow's environment. mdformat and check-yaml each split into two instances, one for the tree and one for the dialect `docs/` now renders in · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-114/#wp-114) | `scripts/_tests/test_audit_docs_site.py` — every page in the nav, every nav entry on disk, `tables` declared, identity fields matching `pyproject.toml`, and the audit ahead of the build | 113 | ✅ |
| 114b | `refine(deps): cap mkdocs below the unlicensed major` | The `docs` group pins `mkdocs>=1.6,<2`, a licence bound rather than a compatibility one: MkDocs 2.0 is described by the Material team's own build-time notice as "Currently unlicensed", and unlicensed is stricter than the AGPL this project bans — the default is no grant at all. The licence audit cannot catch it, because it matches a GPL-family pattern against a declared licence and a distribution declaring nothing matches nothing · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-114b/#wp-114b) | `scripts/_tests/test_audit_docs_site.py::test_mkdocs_is_capped_below_the_unlicensed_major`, keyed off the version operator so it cannot silently move to the `mkdocs-material` pin beside it | 114 | ✅ |
| 115 | `feat(scripts): an undeclared licence is a finding, per tier` | `audit_licenses.py` matched declared licences only, so a distribution declaring **nothing** passed silently. Two tiers by exposure -- a base dependency's unreadable licence fails, a dev/docs-only one flags -- plus a prose reader for the common permissive texts and an extras-aware closure walk · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-115/#wp-115) | A base-tier unreadable licence exits 1; a dev/docs one flags and exits 0; `scripts/_tests/test_audit_licenses.py` covers both tiers | 114b | ✅ |
| 115b | `fix(scripts): allowlist a licence that cannot be read` | Lettered off 115 and landed in its commit: the new check's first run found `cuda-toolkit` shipping no licence field or document at all, verified from the wheel. Resolved by `UNREADABLE_ALLOWLIST` (D17) rather than a weaker check, and the suite stopped reading the installed environment · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-115b/#wp-115b) | `scripts/_tests/test_audit_licenses.py` -- the allowlist excuses only what it names, every entry cites a decision id, no test touches the installed environment | 115 | ✅ |
| 116 | `refine(docs): docformatter over every docstring` | A PEP 257 formatter beside the three that already run, configured in `[tool.docformatter]` and hooked ahead of ruff-format so the two converge in one pass. Wrapping off in both axes to preserve hand-argued `Args:` blocks; 32 summaries needed rephrasing where the splitter cut mid-abbreviation (`Eq.`, `sec.`) · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-116/#wp-116) | `pre-commit run docformatter --all-files` passes and reformats nothing on a second run; `make gate` green with the doctests unchanged | 003 | ✅ |
| 116b | `fix(deps): pin docformatter's hook to a compatible interpreter` | The hook carried no `language_version`, so pre-commit built its env against whatever `python3` resolved off `PATH` -- drifted to a 3.10 install below this project's own `>=3.11` floor, and docformatter's config reader needs `tomllib` (3.11+) to parse `pyproject.toml`. Pinned to `python3.11`, the guarantee `mypy` and `license-audit` already get from `entry: .venv/bin/python` · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-116b/#wp-116b) | `pre-commit run docformatter --all-files` passes regardless of `PATH` order; `make gate` green | 116 | ✅ |
| 117 | `test(tests): doctest every helper, document every test` | A fresh `ast` count corrects the row this replaced: 261 non-fixture helper functions in `tests/**/test_*.py`, all carrying a docstring already, but only 1 of 261 carries a runnable Example, though `--doctest-modules tests` ([WP-085](https://borda.github.io/lucid-YOLO/work_packages/wp-085/)) exercises all of them. 23 of 678 `test_` functions carry no docstring at all, every one of them in `tests/data/test_download.py`; others name the assertion rather than the hypothesis it defends -- now 0 of 261 missing an Example, 0 of 678 undocumented · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-117/#wp-117) | Every non-fixture helper carries a runnable Example; every `test_` docstring states the scenario under test. `scripts/lint/audit_test_doctests.py` ([WP-129](https://borda.github.io/lucid-YOLO/work_packages/wp-129/)) enforces the first via a pre-commit hook; `make gate` green | 085,116 | ✅ |
| 128 | `refine(tests): regroup flat-naming test functions into classes` | Solves 117's ambiguity with a discriminator, not a bigger eyeball pass: a shared-prefix candidate confirms only if the prefix is a contiguous token subsequence of something the file actually calls (an `ast.Call` target), so house-style sentences sharing a topic noun no longer false-positive against genuine mechanical enumeration. New `scripts/lint/audit_flat_test_groups.py` (own script, not `audit_test_doctests.py` (129) — that script's own docstring disclaims the job) + `flat-test-group-audit` hook enforce it. 9 files regrouped under `class Test<Subject>:`, matching `tests/eval/test_tile_merge.py`'s precedent; node-id citations in this table (rows 044, 051, 110) and `docs/ASSUMPTIONS.md` A45 updated to match | `scripts/lint/audit_flat_test_groups.py`, `scripts/_tests/test_audit_flat_test_groups.py` (10 gates incl. live-tree sanity); `flat-test-group-audit` pre-commit hook clean; `make gate` green | 117 | ✅ |
| 129 | `refine(scripts): split scripts/ into core and lint, doctest audit as a hook` | 117's enforcement lived as a pytest meta-test scanning repo content. Split into `scripts/lint/audit_test_doctests.py`, a `test-doctest-audit` pre-commit hook gated on `tests/.*/test_.*\.py` (not `always_run`, since it's repo content not installed-environment state like `license-audit`). `audit_licenses.py` and `check_commit_trailers.py` move into `scripts/lint/` too, so the directory root is core tooling and `lint/` holds enforcement; golden/training/release scripts stay put -- their dotted `scripts.<module>` paths are baked into checked-in golden JSON `producer` fields, so moving them would be a data migration, not a rename · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-129/#wp-129) | `pre-commit run --all-files` green with every hook path updated; `scripts/_tests/test_audit_test_doctests.py` covers the script's functions in isolation (pytest owns the functional core, the hook owns enforcement); `make gate` green | 117 | ✅ |
| 130 | `refine(scripts): consolidate scripts/-testing tests, wire remaining lint hooks` | Every `scripts/`-testing test -- `tests/meta/`'s remaining audits, `tests/scripts/`, `tests/train/test_shapes_regression.py`, `tests/integration/test_overfit_micro.py` -- moves into `scripts/_tests/test_<script_name>.py`. Five checkers still inline in `tests/meta/` split into `scripts/lint/` alongside [WP-129](https://borda.github.io/lucid-YOLO/work_packages/wp-129/)'s; `release_guard.py`/`check_goldens.py` gain manual-stage hooks, `release_guard.py`'s `--tag` now optional (`git describe --exact-match`); every local hook gets a distinct emoji. `explicit_package_bases`/`mypy_path` and `--import-mode=importlib` fix import collisions on `scripts.X` and bare `conftest`. `scripts/README.md` maps every script to its test · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-130/#wp-130) | `pre-commit run --all-files` green, all eleven local hooks pass; `make test` 2235 passed; `make gate` green | 129 | ✅ |
| 130b | `fix(ci): emoji on every hook, three now run on --all-files` | Lettered off 130: the emoji requirement only reached the eleven `repo: local` hooks, leaving the eleven third-party ones unmarked -- every hook now carries a distinct `name:` emoji, 22 total, none repeated. Separately, `commit-trailers` (`commit-msg` stage) and `golden-check`/`release-guard` (`stages: [manual]`) never ran under `pre-commit run --all-files` -- `golden-check`/`release-guard` gain `pre-commit` alongside `manual` (cheap off a release tag); `commit-trailers` can't, since `--all-files` has no commit message to read, so sibling hook `commit-trailers-history` re-validates every commit ahead of `origin/main` via the script's `--range` mode instead · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-130b/#wp-130b) | `pre-commit run --all-files` shows all 22 pre-commit-stage hooks, each with a unique emoji; `commit-msg` stage still validates the message being written; `make gate` green | 130 | ✅ |
| 118 | `refine(docs): split the log by claim, trim the table to match` | `RESEARCH_LOG.md` split into itself (fidelity: model, loss, data protocol, eval numbers, paper ambiguity) and a new `ENGINEERING_LOG.md` (repo tooling: CI, packaging, licensing, formatting, CLI, docs). The split is by claim rather than by work package -- 5 entries whose finding was genuinely both (066, 079, 083, 091b, 097) are cut into two cross-linked halves rather than forced into one bucket, the rest move whole. And rows 115/115b/116 -- whose Scope columns had grown to a paragraph of debugging narrative each -- are trimmed to what shipped, since every fact in them already lives in a log entry · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-118/#wp-118) | `scripts/_tests/test_audit_docs_present.py::test_every_log_link_resolves` passes against both files; `mkdocs.yml` nav and `docs/index.md` route to both; `make gate` green | 003 | ✅ |
| 119 | `refine(cli): drop the root configs symlink` | [WP-038](https://borda.github.io/lucid-YOLO/work_packages/wp-038/)'s repo-root `configs -> src/lucid_yolo/configs` symlink was a compatibility shim so old commands kept working once configs moved to package data; no code depended on it -- every resolver already computes the path package-relatively, `_resolve_config_args` already lets a bare `--config det_smoke.yaml` reach the packaged file from a checkout or a wheel alike. The symlink was legacy. Dropped, and the one documented example that used the root path directly (`--config configs/det_smoke.yaml`) now shows the bare-name form the resolver was built for · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-119/#wp-119) | `make gate` green with the symlink absent; `_resolve_config_args` resolves a bare name to the real package path | 038 | ✅ |
| 127 | `refine(ptl): task and variant in the checkpoint path` | Lightning's own default writes `lightning_logs/version_N/checkpoints/epoch=X-step=Y.ckpt` -- nothing in the path or filename names the task or scale, so identifying a run means opening its `hparams.yaml`. Surfaced re-scoring [WP-064](https://borda.github.io/lucid-YOLO/work_packages/wp-064/)'s OBB-smoke checkpoint for row 111: `version_10` told nothing until cross-referenced against `hparams.yaml`'s `task: obb`. `DetectionCLI.instantiate_trainer` now injects a `ModelCheckpoint` whose `filename` template carries `{task}_{variant}` ahead of Lightning's own `{epoch}-{step}` suffix, unless a config already places its own; `dirpath` stays at Lightning's default, so no already-written checkpoint moves or is renamed · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-127/#wp-127) | `tests/ptl/test_callbacks.py` -- a fit run's checkpoint path names its task and variant; existing `version_N` numbering and epoch/step naming unchanged otherwise | 040 | ✅ |

## 🕺 Phase 12 — Keypoint detection, release 0.5.0 (WP-120…126, 131…140)

Generic K-point task, not "human pose": K a constructor argument like the class count, a third per-level stem after the OBB angle precedent ([WP-062](https://borda.github.io/lucid-YOLO/work_packages/wp-062/)), loss is RLE (R14) whose formulation is person-agnostic. COCO human pose (R12) supplies the demo dataset only -- K=17, the OKS sigma table, the skeleton edge list are dataset metadata, never model code. Smoke tier gates on RLE's mechanism claim (uncertainty-weighted regression beats plain coordinate regression), not R1's Table 9 absolute mAP, matching the det/seg/obb precedent that this project targets relative claims trained from scratch, not the paper's headline numbers (design doc §5.11, §7).

| WP | Commit subject | Scope | DoD | Dep | Status |
| -- | -- | -- | -- | -- | -- |
| 120 | `feat(data): keypoint targets and fliplr index swap` | `Targets` gains `keypoints`/`keypoint_vis` -- `(N, K, 2)` float32 coordinates and matching `(N, K)` int64 visibility, K generic, threaded through `clone`/`filter`/`concat`/`empty` on the shared instance axis (`polygons`' 0-or-N convention, not `rboxes`' independent one). `HorizontalFlip` gains `keypoint_flip_pairs`, a caller-supplied left/right index-swap map (`None` mirrors coordinates only) rather than a hardcoded COCO table -- A64 records COCO's own 17-point pairing as the value [WP-121](https://borda.github.io/lucid-YOLO/work_packages/wp-121/) wires in, not something this row assumes · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-120/#wp-120) | `tests/data/test_targets.py::TestKeypoints`, `tests/data/test_photometric.py::TestHorizontalFlip` (mirror, swap, out-of-range pair); `make gate` green | 008,013 | ✅ |
| 121 | `feat(data): COCO keypoint parsing` | `CocoDetectionDataset` gains opt-in `keypoints=True`, parsing each retained annotation's flat COCO `keypoints` field into [WP-120](https://borda.github.io/lucid-YOLO/work_packages/wp-120/)'s `Targets.keypoints`/`keypoint_vis` (crowd/RLE-skipped annotations excluded, mismatched per-instance K raises naming the image); visibility carried through unchanged. Reader-only, as the row's own DoD always scoped it -- no datamodule or `HorizontalFlip` wiring lands here; A64 corrected in the same commit, since it had prematurely named this row for that wiring · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-121/#wp-121) | `tests/data/test_coco.py::TestKeypointParsing`; `make gate` green | 014,120 | ✅ |
| 121b | `fix(data): a segmentation ring only an oriented reading actually needs` | Validating a fuse-augmentations `Task.KEYPOINTS` export surfaced `_parse_annotation` required a usable `segmentation` ring for **every** reading mode, not only `oriented=True` -- a keypoints-only export with no ring parsed to zero instances. Relaxed: absent ring is fatal only for `oriented=True`; a present-but-unusable one (RLE dict, empty list, \<3 points) stays excluded in every mode, preserving [WP-014](https://borda.github.io/lucid-YOLO/work_packages/wp-014/)'s crowd/RLE policy and `TestKeypointParsing`'s RLE-exclusion case. A plain/keypoints reading now returns `Targets.polygons == []` rather than dropping instances, matching the dataclass's 0-or-N contract; `instance_mask_targets` now guards `mask_targets=True` · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-121b/#wp-121b) | `tests/data/test_coco.py::TestRinglessAnnotations`; regression run across every `CocoDetectionDataset` caller (175 tests); `make gate` green | 121 | ✅ |
| 122 | `feat(models): keypoint head stem and decode composition` | `DualDetectionHead` gains opt-in `num_keypoints` (a third stem, `_build_coeff_stem`'s shared-width precedent -- no Table S11 measurement covers a keypoint task), emitting raw `(x, y)` offsets plus raw R14 per-axis sigma, point-major, both unactivated -- sigma's positivity mapping is deferred to [WP-123](https://borda.github.io/lucid-YOLO/work_packages/wp-123/)'s RLE loss (A65, AGENTS.md sec. 6). `decode_keypoints` composes only the coordinates with `decode_ltrb`'s anchor-centre/stride convention. `_DetectionBranch`'s internal tuple widened 4->6; the three `build.py` call sites unpacking it positionally needed the matching fix, caught by `make gate`, not by codex's sandboxed run · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-122/#wp-122) | `tests/models/test_keypoint_head.py`; `make gate` green | 062,120 | ✅ |
| 123 | `feat(losses): RLE loss` | `RLELoss(nn.Module)` -- the only loss in this repo carrying trainable weights, so a module the trainer registers rather than a pure function: a hand-written 6-layer RealNVP flow (Eq. 12, R14 sec. 4's stated Lfc=3/Nn=64/Leaky-ReLU conditioner) over the standardized residual, summed with a fixed unit Laplace NLL (Eq. 8, `log s` dropped per R14's own implementation). Resolves A65 (sigma via sigmoid, R14 sec. 3.3, applied at the loss rather than the head) and records A66 (visibility policy: `v=0` excluded, `v>=1` included). Written by hand from R14's equations per AGENTS.md sec. 6 -- no other WP in this phase was delegated to codex for the same reason · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-123/#wp-123) | `tests/losses/test_rle_loss.py` -- invertibility, log-det vs. autograd's own Jacobian, the flow's density integrates to ~1 over a grid, the gradient-shortcut claim, sigma range, visibility masking, toy convergence; `make gate` green | 122 | ✅ |
| 124 | `feat(eval): OKS evaluation wiring` | Installed `torchmetrics==1.9.0` has no keypoint `iou_type` (verified against source, correcting this row's premise) -- `evaluate_keypoints` drives the already-a-dependency `faster_coco_eval.COCOeval_faster(iouType="keypoints", ...)` directly, one-shot rather than `DualPathEvaluator`-streaming since no keypoint decode pipeline exists yet. `keypoints_to_predictions` adapts a fixed-size `(B, N, K, 2)` batch ([WP-122](https://borda.github.io/lucid-YOLO/work_packages/wp-122/)'s decode shape) the way `detections_to_predictions` adapts boxes. `COCO_KEYPOINT_OKS_SIGMAS` (R12's 17-point table) is named explicitly rather than left to the library default, so it stays citable and version-independent even though it currently equals that default · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-124/#wp-124) | `tests/eval/test_keypoint_eval.py` -- adapter padding/remap, perfect-match and far-miss OKS oracles, empty-input all-zero contract, `v=0` exclusion (A66); `make gate` green | 069,121,123 | ✅ |
| 125 | `exp(ptl): Pose-smoke tier` `[GPU][PRINCIPAL]` | n-scale smoke run on COCO human pose, RLE paired against [WP-135](https://borda.github.io/lucid-YOLO/work_packages/wp-135/)'s Laplace-NLL control: e2e OKS AP 0.2738 vs 0.2527, same-sign gap as R14 Table 7's own 70.5-vs-67.4 AP at full scale -- acceptance is the mechanism's direction of effect, not R1 Table 9's absolute mAP=63.0, which nano width and 50 epochs were never going to reach. First real-data run for A64's dataset-supplied flip pairing (already resolved by 132, exercised live here for the first time) and A68's un-tuned `keypoint_gain = 1.0`, both landing on a working head rather than a mechanism failure · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-125/#wp-125) | Pose-smoke acceptance; `make gate` green | 124,132 | ✅ |
| 126 | `docs(report): keypoint tier report, model card, and the fourth task everywhere` | The fourth task's record, matching det/seg/obb: `model_cards/keypoints.md` (intended use, COCO composition limits, clean-room statement, an ethical-considerations section stronger than the segmenter's own — a skeleton is a more identifying readout than a mask), `REPRODUCTION_REPORT.md`'s `0.5.0` section (mechanism table, acceptance stated on [WP-125](https://borda.github.io/lucid-YOLO/work_packages/wp-125/)'s mechanism claim rather than a pose figure, assumption outcomes for A64/A65/A66/A68/A69/A70/A71/A72/A73), and `plot_training.py`'s fourth panel taught to draw the keypoint loss term (RLE or its Laplace-NLL control, whichever a run's `metrics.csv` names), rendered for run v11. The general docs said "three tasks" throughout and named no keypoint task at all -- README, `index.md` and TRAINING.md now carry the fourth, framed as **keypoints not pose** (`K` generic, human pose one instantiation, gate runs a 7-point symbol schema). Stale figures corrected while there: 63->73 assumptions, 20->14 frozen goldens, the roadmap-111 whole-image number now quoted rather than called pending · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-126/#wp-126) | `scripts/_tests/test_audit_docs_present.py`; `scripts/lint/audit_figure_captions.py`; `make gate` green | 125 | ✅ |
| 131 | `feat(data): keypoints fixture, geometric-only det/obb/seg categories` | fuse-augmentations' `class_names(class_mode)` spans the whole `Shape` union regardless of `SyntheticConfig.shapes` -- animal shapes landing ([WP-121b](https://borda.github.io/lucid-YOLO/work_packages/wp-121b/)) would move det/obb/seg's frozen `*_num_categories` goldens from 4 to 16, though those fixtures only draw the 4 geometric shapes. Reported upstream; fix (`class_names(class_mode, shapes=...)`) is what `shapes=DEFAULT_SHAPES` on `generate_detseg_fixtures`/`generate_obb_fixtures` narrows -- goldens unchanged. `generate_keypoints_fixtures` is new: 12 images, `animal_shapes(2)` (duck, elephant). Pinned by git commit not PyPI version (R21) -- version string doesn't move release to release, PyPI forbids re-uploading `0.10.0.dev0` · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-131/#wp-131) | `tests/fixtures/test_fixtures_load.py::test_keypoints_set_loads`, `::test_keypoints_generation_deterministic`; `make gate` green, 27/27 goldens unchanged | 121b | ✅ |
| 133 | `feat(configs): pose-smoke draft, changelog Unreleased entry` | `CHANGELOG.md` had no `## [Unreleased]` section -- 27 commits since 0.4.0, incl. `0.5.0.dev0` bump and [WP-132](https://borda.github.io/lucid-YOLO/work_packages/wp-132/)'s keypoint path, carried zero entries. Backfilled from `git log 0a3ef8b..HEAD`, user-visible changes only. Closing rule inline: a commit setting `__version__` to a plain, non-dev, non-rc value closes the section. Two date mismatches left as known discrepancy: `[0.2.0]`'s `2026-08-10` vs. `eb81caa`'s `2026-08-11`, `[0.4.0]`'s `2026-08-15` vs. `0a3ef8b`'s `2026-08-16`, both one day earlier. `configs/pose_smoke.yaml` here: launch config extending `det_smoke.yaml`/`seg_smoke.yaml` (`task: keypoints`, `num_keypoints: 17`); [WP-125](https://borda.github.io/lucid-YOLO/work_packages/wp-125/)'s acceptance (`[GPU][PRINCIPAL]`) untouched · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-133/#wp-133) | `CHANGELOG.md` carries `## [Unreleased]` covering every commit since the last plain-version bump; `pose_smoke.yaml` dry-parses (`tests/ptl/test_cli.py::test_config_dry_parses[pose_smoke.yaml]`) | none | ✅ |
| 132 | `feat(ptl): keypoint training path` | Phase 12 built every keypoint component but never composed them ([WP-120](https://borda.github.io/lucid-YOLO/work_packages/wp-120/) targets, [WP-121](https://borda.github.io/lucid-YOLO/work_packages/wp-121/) reader, [WP-122](https://borda.github.io/lucid-YOLO/work_packages/wp-122/) head stem, [WP-123](https://borda.github.io/lucid-YOLO/work_packages/wp-123/) RLE loss, [WP-124](https://borda.github.io/lucid-YOLO/work_packages/wp-124/) OKS scorer). This row composes them (analogue of [WP-087](https://borda.github.io/lucid-YOLO/work_packages/wp-087/)/WP-088), overfit-100 golden as DoD, at A68's `keypoint_gain = 1.0`. `pad_keypoints` joins `pad_rboxes`. Transforms crop/filter, needing A70's clamp-vs-demote-to-`v=0` rule (A66's `v=0` = no annotation). Flip ran with no `keypoint_flip_pairs`; A64 resolves via dataset schema. Two faults: RLE residual in absolute pixels vs. sigmoid-bounded `sigma_hat` (A71); coupling log-scale raw-linear vs. RealNVP's tanh-scaled form (A72). Gated on R21's `SymbolShape` (K=7 vs. 16, 5-of-7 slots for A66) · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-132/#wp-132) | `tests/ptl/test_keypoint_training.py`; `goldens/gpu/overfit_micro_kp.json` frozen at train OKS AP 0.3357 over 548 instances, floor 0.30 -- small next to the sibling gates because OKS at A67's sigma is a cliff on symbols a few dozen pixels across (ground truth fed back as prediction scores 1.0; a uniform +3 px displacement scores 0.269), so the floor is set from that measured slope rather than guessed; 21/21 goldens; `make gate` green | 121,122,123,124,131 | ✅ |
| 134 | `feat(eval): keypoints wired into lucid-eval, canonical OKS ground truth` | [WP-124](https://borda.github.io/lucid-YOLO/work_packages/wp-124/) built the OKS scorer, [WP-132](https://borda.github.io/lucid-YOLO/work_packages/wp-132/) the training path, unjoined: no caller, `cli/eval.py` knew three tasks. A keypoints checkpoint through `lucid-eval` fell to the detection path: `map=0.006288` vs `val/mAP=0.48` -- category-count fault: `detect_eval` hardcodes `instances_val2017.json` (80 categories, 79 with no prediction; `0.006288*80=0.503`). `pose_eval.run` scores person-keypoints. `DualPathEvaluator` gains `o2o_keypoints`/`o2m_keypoints`, gathered by anchor indices (`PAD_ANCHOR_INDEX` clamped/zeroed). Points invert letterbox via `Letterbox.inverse_map` (A10). Ground truth canonical: `area`/`iscrowd`/`num_keypoints`/`bbox` from annotation. K!=17 refused -- A67 reversed · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-134/#wp-134) | `tests/eval/test_keypoint_eval.py::TestDualPathKeypoints`, `::TestCanonicalGroundTruthMetadata`, `::TestPoseProtocolRefusesForeignSchemas`; `tests/eval/test_annotations.py::test_keypoint_load_carries_annotations_own_metadata`; `tests/cli/test_cli_surface.py::test_eval_dispatches_on_checkpoints_own_task[keypoints]`; `make gate` green | 124,132 | ✅ |
| 135 | `feat(losses): Laplace-NLL ablation baseline for RLE` | [WP-125](https://borda.github.io/lucid-YOLO/work_packages/wp-125/) accepts on RLE's mechanism claim (direction of effect vs a non-flow baseline), not a number. `LaplaceNLLLoss` is R14 Table 7's control: Eq. 8 with the flow term `G_phi` removed, else held (`sigma_hat` sigmoid, reparameterization, visibility mask) -- R14 measured 67.4 AP vs RLE's 70.5. `keypoint_loss` switch (`rle`/`laplace_nll`) on `DetectionLitModule`, default `rle` so config/checkpoint/figure stays unchanged; attribute keeps `rle_loss` name either way (prefixes checkpoint keys). Ablation carries zero parameters, draws no RNG. `pose_smoke_ablation.yaml` is `pose_smoke.yaml` with one line changed, rest identical for a controlled pair. Tool only -- [WP-125](https://borda.github.io/lucid-YOLO/work_packages/wp-125/)'s paired run is `[GPU][PRINCIPAL]`, user's machine · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-135/#wp-135) | `tests/losses/test_keypoint_nll_loss.py`; `tests/ptl/test_keypoint_training.py::TestKeypointLossSelection`; `make gate` green | 123,132 | ✅ |
| 136 | `refine(configs): unify config names, fold det_yolo_smoke into docs` | Every packaged config unified onto `<task>_<variant>_<tier>[_<detail>].yaml`: `det_smoke`->`det_nano_smoke`, `seg_smoke`->`seg_nano_smoke`, `obb_smoke`->`obb_nano_smoke`, `pose_smoke`->`pose_nano_smoke`, `det_ablations`->`det_small_ablations` (`variant: s` spelled out), `overfit_100`->`det_nano_overfit_100`, `pose_smoke_ablation`->`pose_nano_smoke_laplace_nll` (avoids `det_ablations`'s different sense). `det_yolo_smoke.yaml` dropped, not renamed: content beyond `data:`/`num_classes` duplicated docs/DATASETS.md, now a three-flag worked example in docs/TRAINING.md. Old names stay cited as shipped ([WP-129](https://borda.github.io/lucid-YOLO/work_packages/wp-129/) precedent); live docs and functional code updated to the real names · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-136/#wp-136) | `tests/ptl/test_cli.py` (glob-discovered, hardcoded name assertions updated); `make gate` green | 040,088,099c,125,135 | ✅ |
| 137 | `feat(ptl): keypoint OKS joins the epoch validation loop` | A `task="keypoints"` module logged no per-epoch pose signal -- `val/mAP` scored its box branch only. `val/oks_mAP` now accumulates points via `gather_keypoints` (public, shared with `DualPathEvaluator`) and scores once at epoch end via `evaluate_keypoints`, beside `val/mAP` (`val/segm_mAP`'s relationship, not "obb"'s replacement). Sigma: `COCO_KEYPOINT_OKS_SIGMAS` at K==17, else A67's uniform sigma (moved into `coco_eval.py`, one place not two). `hotcoco` evaluated and rejected: its `COCO` object cannot satisfy torchmetrics' construction contract; a hand-driven path costs back `_StreamingScorer`'s memory bound. Benchmarked 13x faster, ~1 min saved over a multi-hour run · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-137/#wp-137) | `tests/ptl/test_keypoint_training.py::TestKeypointValidation`; `make gate` green | 077,087,102,122,132,134 | ✅ |
| 138 | `feat(eval): hotcoco as lucid-eval's default bbox/segm engine` | Corrects [WP-137](https://borda.github.io/lucid-YOLO/work_packages/wp-137/): hotcoco is RLE-encoded per batch (matching torchmetrics own `_get_safe_item_values`), not held dense -- A73 revised in place. `lucid-eval --eval_backend {auto,hotcoco,faster_coco_eval}`, default auto (prefers hotcoco, falls back if unusable, recorded in `info`). `_HotcocoStreamingScorer` fixes 5 real hotcoco traps (params/dataset copy-on-read, bytes-vs-str RLE counts, uint8-only encode, iou_type keyword, fd-level stdout) matching rf-detr PR #1402 exactly. Bit-for-bit parity vs faster_coco_eval, bbox+segm+empty+end-to-end. Torchmetrics-subclass fork (WP B) deferred -- no DDP here, rf-detr own verdict \<2% of an epoch either way. keypoints/module.py untouched · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-138/#wp-138) | `tests/eval/test_coco_eval.py::TestHotcocoParity`; `make gate` green | 043,069,096,134,137 | ✅ |
| 139 | `feat(models): keypoint detector builder and params/FLOPs golden` | Detection, segmentation and oriented detection each ship a composite class, a `build_*` factory, a `.deploy()` NMS-free view and a frozen size golden; keypoints shipped none of the four -- the model existed only inline inside `module.py`, so the release headlining it would have frozen a regression contract with no keypoint entry. `KeypointDetector`/`build_keypoint_detector`/`_DeployedKeypointDetector` in `models/build.py`, `kp_params_flops` beside its three siblings, `goldens/params_flops_kp.json`. Measured at detection's own 640 px / 80-class protocol, so the difference from `params_flops_det.json` is exactly what the point stems cost: **+110,652 params, +0.1238 GFLOPs** at `n`. Golden only, no published-table gate -- R14 states a loss and an evaluation protocol and no architecture · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-139/#wp-139) | `tests/models/test_param_flops.py::test_kp_params_flops_golden`; `tests/models/test_keypoint_head.py::TestKeypointDetector`; `make gate` green | 122,132 | ✅ |
| 140 | `release: v0.5.0 keypoint detection` [PRINCIPAL] | The fourth task, released: `__version__` to a plain `0.5.0`, the `## [Unreleased]` section [WP-133](https://borda.github.io/lucid-YOLO/work_packages/wp-133/) opened closed as `## [0.5.0]`, and `make freeze-goldens MINOR=0.5` copying the eight live metric sets into `goldens/frozen/0.5/` -- one more set than 0.4 froze, the keypoint size golden [WP-139](https://borda.github.io/lucid-YOLO/work_packages/wp-139/) added. Tags `v0.4.0` and `v0.5.0` are both still uncut and both deferred to the user (not blocking): `release.yml` fires on a pushed `v0.*` tag and re-runs the guard, which is the operator's to trigger · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-140/#wp-140) | `scripts/release_guard.py --tag v0.5.0` passes tag and changelog; `make gate` green with the 0.5 freeze in place | 126,139 | ✅ |
| 140b | `docs: the principal, the contract's end, and two phases` | Lettered off 140: governance following the release rather than any package. `[HUMAN]` becomes `[PRINCIPAL]` project-wide -- the tag always meant whoever *drives* the project rather than whoever *executes* it, and anatomical uses plus the pre-existing `operator` meaning *whoever runs the command* both stay untouched. D18 records that the WP contract is a reproduction instrument that ends with the reproduction, what relaxes, and that a new task restarts it, with contributor admission built as three layers. Phases 13 and 14 write down the `fuse-augmentations` migration and the keypoint parity 0.5.0 owes · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-140b/#wp-140b) | `scripts/lint/audit_docs_present.py`, `scripts/lint/audit_docs_site.py`; `make gate` green | 140 | ✅ |

Beyond the keypoint phase the train continues on the same discipline — Phases 13 and 14 below, then classification and the export matrix — each a new phase of WPs and its own gated 0.MINOR. RF100-VL generalization is **not** on that list: [WP-074](https://borda.github.io/lucid-YOLO/work_packages/wp-074/)/075 were closed on 2026-08-18 over the unresolved per-dataset licence question, and nothing has replaced them, so this project currently has no cross-dataset generalization measurement planned. No 1.0, ever (ADR-002).

## 🛡️ Phase 13 — Contributor admission, augmentation guard and keypoint parity, release 0.6.0 (WP-141…153)

Everything that must exist before the augmentation stack can move, and nothing that moves it. Phase 14 does the moving, and is gated on this release.

Opening to contributors is an admission problem. What must never enter this tree is code under any licence the project cannot redistribute under Apache-2.0 — copyleft, source-available, paid, proprietary, or unreadable (D13, D17) — and Ultralytics is the loudest instance of that class rather than the class itself. No attestation *detects* such code, so three layers are built because each fails differently: [WP-142](https://borda.github.io/lucid-YOLO/work_packages/wp-142/) records who submitted it and under what claim of right, [WP-143](https://borda.github.io/lucid-YOLO/work_packages/wp-143/) states what that claim covers in the general terms the allowlist actually uses, and [WP-144](https://borda.github.io/lucid-YOLO/work_packages/wp-144/) makes a contributor name what an algorithmic change derives from. Only the third yields something a reviewer can check — a provenance id resolvable against `docs/PROVENANCE.md` — rather than something taken on trust.

The guard is deliberately not a comparison. The swap is authorized by expectations frozen against **stated parameters**, never seeds: `fuse-augmentations` will not draw the same numbers from the same seed as the local sampler, so a seed-frozen value would break at the swap for reasons unrelated to correctness. Comparing the old implementation against the new would also enshrine legacy behaviour as the definition of correct and keep that code alive as its own reference, blocking the deletion the check exists to authorize. Keypoint parity ships in the same release because 0.5.0 shipped a task that can neither export nor predict, and a release that closes that gap is a better thing to measure a swap against than one that does not.

| WP | Commit subject | Scope | DoD | Dep | Status |
| -- | -- | -- | -- | -- | -- |
| 141 | `docs: contributor guide and the tracking relaxation` [PRINCIPAL] | `CONTRIBUTING.md` and issue templates, and D18 taking effect: with the reproduction complete, a change altering no shipped behaviour, no public symbol, no golden and no documented assumption lands as an ordinary gated commit rather than a tracked WP. Names the three admission layers WP-142…144 build and what each is for. Three preconditions sit outside this row — O3 cleared, the repository made public, [WP-125](https://borda.github.io/lucid-YOLO/work_packages/wp-125/) accepted | `CONTRIBUTING.md` in the docs nav (`scripts/lint/audit_docs_site.py`); `make gate` green | 125 | ✅ |
| 142 | `ci: DCO sign-off required on pull requests` | Layer one of three: every commit in a pull request carries a `Signed-off-by:` line, checked in CI rather than by an external app, so the check is a file in this repository and survives whatever happens to a third-party service. Establishes ownership and right-to-submit — the contributor's own representation, on the record, per commit. It does not establish what the work derives from, which is why two more layers follow | `scripts/lint/check_dco.py` doctests; `scripts/_tests/test_check_dco.py`; `make gate` green | 141 | ✅ |
| 143 | `docs: the licence attestation covers every non-permissive source` | Layer two. The pull-request template's clean-room line names Ultralytics only, which understates the rule it enforces: the admissible set is the permissive allowlist (D13), so what a contributor must attest to not having copied from is *any* copyleft (AGPL, GPL, LGPL, SSPL), source-available (BSL, Elastic, PolyForm), paid or proprietary source, and any source whose licence cannot be read (D17). Ultralytics stays enumerated as the prime-directive instance, not as the definition | `scripts/lint/audit_docs_present.py`; `make gate` green | 141 | ✅ |
| 144 | `ci: provenance trailers enforced on contributor commits` | Layer three, and the only one that produces a reviewable artifact instead of a promise. `check_commit_trailers.py --range` already validates `WP:`/`Provenance:`/`Assumptions:`/`Gate:` against `docs/PROVENANCE.md`; CI runs it over a pull request's own commits, which the squash-merge exemption in `ci-tests.yml` currently skips. A contributor must then name what an algorithmic change derives from, and an unnamed derivation on a loss or an assigner becomes visible at review rather than after release | `.github/workflows/lint.yml` PR job; `scripts/_tests/test_check_commit_trailers.py`; `make gate` green | 141 | ✅ |
| 145 | `docs: ADR-005 — the fuse boundary and the battle-test contract` | Records what the two repositories agree on: the boundary (`fuse` owns resampling and the coordinate transport that rides with it; this project owns task semantics, multi-image assemblies and supervision targets), the five-step migration protocol, the reciprocal battle-test contract, and the four conditions under which a runtime dependency on `fuse` is accepted. No code · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-145/#wp-145) | `scripts/lint/audit_docs_present.py`, `scripts/lint/audit_docs_site.py`; `make gate` green | — | ✅ |
| 146 | `fix(tests): reconcile the R21 pin with the LetterShape family` | The pin never lagged -- `pyproject.toml` has read `fedde0c1` since [WP-132](https://borda.github.io/lucid-YOLO/work_packages/wp-132/) while R21 still named `0a0cc640`, so the register is the stale side and this row corrects it there. What `LetterShape` did to the fixtures is measured rather than argued: both generator-derived goldens produce identical values at either commit, so `0a0cc640` is the whole of [WP-132](https://borda.github.io/lucid-YOLO/work_packages/wp-132/)'s re-freeze and no golden moves here · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-146/#wp-146) | `tests/data/test_data_goldens.py`; goldens unchanged, measured at both commits; `make gate` green | — | ✅ |
| 147 | `refine(data): split sampling from application on stochastic transforms` | Seven stochastic transforms gain a public frozen parameter dataclass modelled on `AffineParams` and a public apply-given-parameters method, leaving `__call__` as sample-then-apply. This is what lets an expectation be frozen against stated parameters instead of a seed. Guarded by a temporary same-codebase equivalence test — `__call__` under a seeded generator equals `sample()` then `apply()` — which [WP-149](https://borda.github.io/lucid-YOLO/work_packages/wp-149/) deletes · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-147/#wp-147) | `tests/data/test_aug_seam.py`; `make gate` green | 146 | ✅ |
| 148 | `test(data): derived and invariant expectations for every transform` | `tests/data/test_aug_contract.py` — tier A expectations correct by construction (a quarter turn of a known box, an identity affine, a flip composed with itself) and tier C invariants that are parameter-free and constrain the modalities against each other (a box bounds its own polygon, keypoint visibility survives what its coordinates survive). No characterization values, so it reviews as mathematics · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-148/#wp-148) | `tests/data/test_aug_contract.py`; `make gate` green | 147 | ✅ |
| 149 | `test(data): frozen expectations and the augmentation-invariants golden` | Tier B pins what has no closed form: `tests/data/test_aug_frozen.py`, each case stating explicit parameters, literal expected geometry and why no derivation exists, plus `goldens/aug_invariants.json` through a new `scripts/golden_producers.py:aug_invariants` producer. Deletes [WP-147](https://borda.github.io/lucid-YOLO/work_packages/wp-147/)'s temporary equivalence test. Coverage reaches every transform, including those staying local · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-149/#wp-149) | `tests/data/test_aug_frozen.py`; `goldens/aug_invariants.json` discovered by `check_goldens.py`; `make gate` green | 148 | ✅ |
| 150 | `refine(tests): rescope seed-driven assertions as tier-D determinism` | Existing seed-driven assertions move to `tests/data/test_aug_determinism.py` and are labelled for what they are: they pin whichever implementation is live and are re-frozen at each swap, never carried across one. Separating them stops a tier-D failure at a swap reading as a regression in the behaviour tiers A–C actually guard · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-150/#wp-150) | `tests/data/test_aug_determinism.py`; `make gate` green | 149 | ✅ |
| 151 | `feat(models): keypoint detector ONNX export` | Detection, segmentation and oriented detection each export a `.deploy()` view; keypoints ships none, so the fourth task is the only one whose released model cannot leave PyTorch. Adds the export path and the parity assertions the other three carry, folding the keypoint decode in the way the OBB angle branch established ([WP-062](https://borda.github.io/lucid-YOLO/work_packages/wp-062/)) · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-151/#wp-151) | `tests/models/test_onnx_export.py` keypoint case; `make gate` green | 139 | ✅ |
| 152 | `feat(cli): predict_keypoints closes the inference surface` | `predict.py` exposes `predict_image`, `predict_segmentation` and `predict_oriented`; the keypoint task has none, so a released 0.5.0 checkpoint has no shipped way to run on an image. Adds `predict_keypoints` with the letterbox inverse and skeleton overlay the other three establish, K read from the checkpoint rather than assumed to be 17 · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-152/#wp-152) | `tests/cli/test_cli_surface.py` keypoint case; `make gate` green | 151 | ✅ |
| 152b | `docs: Phase 14 regrouped into five packages and a release` | Lettered off 152: planning that follows the keypoint parity rather than any package. Phase 14's thirteen rows become six, at the principal's direction. The `Y-n` plan codes are unchanged and now map several to a row; the `FA-n` dependencies are unchanged, since merging rows changes what one commit contains and not what upstream has to ship first. Each row still groups swaps sharing an upstream capability and a test surface, and the golden re-freeze stays alone in the one row holding no swap, so §6.5's rule survives. `_WP_FLOOR` rises to 159 -- the ratchet exists to make a shrinking roadmap a deliberate edit, and this is that edit · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-152b/#wp-152b) | `scripts/lint/audit_docs_present.py`; `make gate` green | 152 | ✅ |
| 153 | `release: v0.6.0 augmentation guard and keypoint parity` [PRINCIPAL] | The guard and the parity, released before a single swap lands, so the version a swap is measured against is a published one. `__version__` to `0.6.0`, the `## [Unreleased]` section closed, `make freeze-goldens` into `goldens/frozen/0.6` · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-153/#wp-153) | `scripts/release_guard.py`; `make gate` green | 150,152 | ✅ |
| 153b | `docs: fuse 0.12 validated against the Phase 14 rows` | Lettered off 153: validation that follows the release rather than any package. `fuse` 0.12 ships every capability this phase waits on — FA-1 through FA-5, FA-7 and FA-8 — so the phase is unblocked, and three of its rows described upstream inaccurately. [WP-154](https://borda.github.io/lucid-YOLO/work_packages/wp-154/) pinned an `align_corners` flag 0.12 declined to add, [WP-156](https://borda.github.io/lucid-YOLO/work_packages/wp-156/) attributed geometric fusion to `clip_policy` (a colour-chain clamp that shares nothing with it), and [WP-157](https://borda.github.io/lucid-YOLO/work_packages/wp-157/) planned a deletion of `rotated_aug.py` that upstream's deliberate refusal to clip rotated boxes makes impossible. Corrected here from measurement, with the phase preamble recording that the tag follows the roll-out. No DoD moves and no row splits: those follow the decisions this validation put to the principal · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-153b/#wp-153b) | `scripts/lint/audit_docs_present.py`, `scripts/lint/audit_docs_site.py` both clean; docs-only, no code touched, gate not re-run | 153 | ✅ |
| 153c | `docs: Phase 14 rows split around the conventions and the measurements` | Lettered off 153: the row splits and DoD moves [WP-153b](https://borda.github.io/lucid-YOLO/work_packages/wp-153b/) deferred until the principal had decided §8.12's two open questions. Six rows become eight — [WP-154](https://borda.github.io/lucid-YOLO/work_packages/wp-154/) splits into the pin bump and the convention alignment, and a new WP-155b measures the letterbox delta on the evaluation path — under one rule: no commit both delegates and moves a frozen value. Three DoD lines added for traps that fail silently, and [WP-158](https://borda.github.io/lucid-YOLO/work_packages/wp-158/) stops claiming a tier-D re-freeze it cannot perform. Phase 14's scope prose, three times the register's median, moves to the log · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-153c/#wp-153c) | `scripts/lint/audit_docs_present.py`, `scripts/lint/audit_docs_site.py` both clean; docs-only, no code touched, gate not re-run | 153b | ✅ |

## 🔗 Phase 14 — Augmentation upstreaming to `fuse`, release 0.7.0 (WP-154…159)

The augmentation stack leaves this repository. `fuse-augmentations` becomes the engine, `lucid-yolo` becomes its first real-project consumer, and what this project keeps is architecture, task semantics, supervision targets, experimentation, export and inference. The plan of record is `.plans/active/todo_augmentation-upstreaming.md`: its `FA-n` codes name work in the other repository and never appear as WPs here, and its `Y-n` codes map onto the rows below several to a row. No swap starts before its `FA` ships as a pinned version — the boundary is a dependency bump, never a same-session edit spanning two repositories. Every `FA` this phase needs shipped in `fuse` 0.12; the principal's direction is that the phase proceeds on a commit pin and that `v0.12.0` is tagged from that same commit once the roll-out here is clean, which is ADR-005's reciprocal battle-test contract rather than an exception to it — this project is the first real consumer, so the tag follows the integration that proves it. The pin is provisional until then, and [WP-159](https://borda.github.io/lucid-YOLO/work_packages/wp-159/) is what exchanges it for a version range with a ceiling. Every row here is measured against the guard frozen in Phase 13, which is why that phase releases first.

Nine rows rather than thirteen — six after [WP-152b](https://borda.github.io/lucid-YOLO/work_packages/wp-152b/) regrouped them, two more once [WP-153c](https://borda.github.io/lucid-YOLO/work_packages/wp-153c/) split what the `fuse` 0.12 measurement showed could not share a commit, one more once [WP-154](https://borda.github.io/lucid-YOLO/work_packages/wp-154/) hit [WP-132](https://borda.github.io/lucid-YOLO/work_packages/wp-132/)'s wall a second time and [WP-154c](https://borda.github.io/lucid-YOLO/work_packages/wp-154c/) was opened to close the mechanism that kept reopening it. Each row is still one commit, so the commits are larger and each groups swaps that share an upstream capability and a test surface — a row is now "this much of the engine moved", not "this one function moved".

No commit both delegates and moves a frozen value, which is what §6.5's rule protects and what the split buys. Four rows re-freeze and none of them delegates: [WP-154](https://borda.github.io/lucid-YOLO/work_packages/wp-154/) carries what upstream's rebuilt synthetic fixtures force, [WP-154b](https://borda.github.io/lucid-YOLO/work_packages/wp-154b/) carries the two conventions this project chooses to give up, WP-155b carries whatever the letterbox resampling moves on the evaluation path, and [WP-158](https://borda.github.io/lucid-YOLO/work_packages/wp-158/) closes. The delegation rows — 155, 156, 157 — are then held to the strict reading: a tier-A, B or C value that moves inside one of them is a defect, and the row that moved it is where the defect is.

| WP | Commit subject | Scope | DoD | Dep | Status |
| -- | -- | -- | -- | -- | -- |
| 154 | `chore(deps): fuse 0.12 becomes the pinned engine` | The dependency bump and the re-freeze it forces, with nothing delegated. R21 advances from `fedde0c1` to `a5a276a`, the commit `v0.12.0` is tagged from once this roll-out is clean. Two breaks are loud and repaired here: `tests/fixtures/synthetic.py` imports `animal_shapes`, which upstream's restructure removed, and the synthetic fixtures themselves moved against goldens whose tolerance is zero. Every changed golden traces to a named upstream change or stops the row · [scope](https://borda.github.io/lucid-YOLO/engineering/phase-14-augmentation-upstreaming/#wp-154-scope) | `scripts/check_goldens.py` green with each changed value traced case by case, `tests/fixtures/synthetic.py` collecting; `make gate` green | 153c | ✅ |
| 154c | `fix(goldens): freeze-goldens stops re-copying what it cannot re-freeze` | [WP-154](https://borda.github.io/lucid-YOLO/work_packages/wp-154/) hit [WP-132](https://borda.github.io/lucid-YOLO/work_packages/wp-132/)'s wall a second time — `goldens/frozen/0.5` and `0.6` both re-acquired the two generator-derived goldens `make freeze-goldens` was told never to keep, because the target was a blind `cp` with no notion of the distinction. A `"freezable": false` schema field on the two live goldens, `scripts/freeze_goldens.py` skipping them at copy time, and `check_goldens.py` rejecting one found under `frozen/` regardless, so `0.7` cannot repeat what `0.5` and `0.6` both did · [scope](https://borda.github.io/lucid-YOLO/engineering/phase-14-augmentation-upstreaming/#wp-154c-scope) | `scripts/_tests/test_freeze_goldens.py`, `scripts/_tests/test_check_goldens.py` — a `freezable: false` golden is skipped at freeze time and rejected if found frozen anyway; `make gate` green | 154 | ✅ |
| 154b | `refine(data): fuse's geometric conventions replace this project's` | Absorbs FA-1, FA-2 and FA-3, and gives up the two conventions §8.12 measured as genuinely different — the composition centre and the mirror axis — deliberately before any delegation, so the frozen literals they move travel with a local edit rather than with a swap. Asserts what is not changed: the datamodule's generator reaching `fuse`'s own draws, and constant fill arriving as a `fill=` parameter that requires `padding_mode="zeros"` · [scope](https://borda.github.io/lucid-YOLO/engineering/phase-14-augmentation-upstreaming/#wp-154b-scope) | `tests/data/test_aug_determinism.py`, `tests/data/test_aug_frozen.py` with the moved literals traced to the two convention changes; `make gate` green | 154 | ✅ |
| 155 | `refine(data): letterbox and instance filter come from upstream` | Absorbs FA-4 and FA-5. `Letterbox` keeps `inverse_map` and `forward_affine` unchanged in signature and semantics, so its six evaluation and prediction consumers are untouched, and delegates the resize-and-pad internals. `RandomAffine` and `MosaicAssembly` consume one upstream keep mask instead of two local copies that must agree, with both thresholds passed explicitly because upstream's defaults drop nothing. Deletes `_resize_pad`, `_LetterboxGeom` and both `_keep_mask` · [scope](https://borda.github.io/lucid-YOLO/engineering/phase-14-augmentation-upstreaming/#wp-155-scope) | `tests/data/test_letterbox.py`, `tests/data/test_affine.py`, `tests/data/test_mosaic.py`, with one case per call site asserting both thresholds reach upstream; `make gate` green | 154b | ✅ |
| 155b | `refine(eval): the letterbox resampling delta measured and re-frozen` | The row decision 6 asked for, holding no swap. Delegating the letterbox keeps its geometry exactly and changes its pixels, so only image-derived metrics move — on the evaluation path, behind a green coordinate suite. This row measures that delta against the 0.6 frozen goldens, records the number, and re-freezes what moved; nothing moving is a legitimate outcome and is recorded as one · [scope](https://borda.github.io/lucid-YOLO/engineering/phase-14-augmentation-upstreaming/#wp-155b-scope) | `scripts/check_goldens.py`, `tests/data/test_letterbox.py`; the measured delta recorded in `docs/ENGINEERING_LOG.md`; `make gate` green | 155 | ✅ |
| 156 | `refine(data): the affine reduces to ranges over a fuse segment` | Absorbs FA-3 and FA-5 into the warp itself and takes the local fusion with it; what remains locally is R1 Table S3's ranges and the sampling of them. Polygon rings ride the composed matrix via `return_matrix=True` rather than a duplicate warp path, which makes operation ordering and the single-geometric-segment condition requirements of this row rather than incidental to it. Deletes `_warp_image`, `_theta_from_pixel_matrix`, `_transform_box_corners` and `FusedAffineLetterbox` · [scope](https://borda.github.io/lucid-YOLO/engineering/phase-14-augmentation-upstreaming/#wp-156-scope) | `tests/data/test_affine.py`, `tests/data/test_fused_warp.py`, `tests/data/test_aug_contract.py`, the single-geometric-segment and not-`None` conditions both asserted as tier-C invariants; `make gate` green | 155b | ✅ |
| 157 | `refine(data): rotated boxes and flip pairs delegate upstream` | Absorbs FA-7 and FA-8, the two task-convention modalities, together because each passes a table or a callback upstream rather than moving a rule there. `rotated_aug.py` and `_mirror_targets` both shrink to what upstream declines to own rather than being deleted outright, `canonicalize` is passed as a callback since angle periodicity is a task convention and not a resampling concern, and `rotated_geom.py` is untouched · [scope](https://borda.github.io/lucid-YOLO/engineering/phase-14-augmentation-upstreaming/#wp-157-scope) | `tests/data/test_rotated_aug.py`, `tests/data/test_photometric.py`, `tests/data/test_aug_frozen.py`, with a mirrored-visibility case asserting the flags follow their points; `make gate` green | 154b | ✅ |
| 158 | `docs: the augmentation stack after upstreaming` | The closing sweep, and the last of the four rows that re-freeze — like the other three, it holds no swap. `data/__init__.py`'s 54-symbol export list is reduced to what survives, and `docs/ASSUMPTIONS.md`, `docs/PROVENANCE.md`, the model cards and `README.md` are rewritten to describe the boundary rather than a stack this project no longer implements. Structural fixture metrics the swaps moved are re-frozen; tiers A through D are not · [scope](https://borda.github.io/lucid-YOLO/engineering/phase-14-augmentation-upstreaming/#wp-158-scope) | `scripts/lint/audit_docs_site.py`, `scripts/check_goldens.py`, `tests/data/test_targets.py`; `make gate` green | 156,157 | ✅ |
| 159 | `release: v0.7.0 augmentation engine upstream` [PRINCIPAL] | The swaps released. `__version__` to `0.7.0`, the `## [Unreleased]` section closed, `make freeze-goldens` into `goldens/frozen/0.7`, and `fuse-augmentations` promoted from a development pin to a runtime dependency with a version ceiling, under the four conditions ADR-005 records | `scripts/release_guard.py`; `make gate` green | 158 | ✅ |

## 🧭 Phase 15 — Audit remediation, release 0.8.0 (WP-160…176)

Phase 14 closed at its own release; what followed it was rolling work, and it belongs here. The six rows already landed — the dependency-tier guard, the nominative README, the golden sweep's move off the commit path, and the three validation-path measurements — open this phase rather than trail the last one.

The rest is remediation. An adversarial read of the whole tree on 2026-09-04 — seven readers over separate dimensions plus one external reviewer over all of it — returned 125 gaps and 26 boundaries that are correct but under-stated. The rows below group those gaps by the file surface each touches rather than by the reader that found them, so a row is one revertable change to one part of the tree and not one reader's list. Two findings block this phase's own release and are its first two rows: the scorer `lucid-eval` selects by default implements neither COCO's crowd-ignore rule nor its area buckets, and nothing schedules the accelerator half of the gate. The audit itself is `.plans/active/audit_adversarial_2026-09-04.md`, and its per-finding ids appear in the Scope cells below.

Two rows move a value pinned in `goldens/frozen/0.2` through `0.7`. Under sec. 7 that is a standing prohibition and under sec. 4 an escalation trigger, and the two overrides already on record are dated exceptions rather than precedent. Neither row starts without its own `docs/ESCALATION.md` entry and a ruling, and the DoD of each says so. Three further rows need an accelerator and cannot go green offline.

Ordering follows what moves goldens: the numerics rows land before [WP-167](https://borda.github.io/lucid-YOLO/work_packages/wp-167/) re-freezes the `goldens/gpu/` tree, and the documentation sweep lands after the code it describes.

| WP | Commit subject | Scope | DoD | Dep | Status |
| -- | -- | -- | -- | -- | -- |
| 160 | `ci(release): the guard reads dependency tiers` | The release guard checked the tag, the changelog section and the gate, and nothing about what a consumer installing the distribution receives — which is what a release *is*. [WP-159](https://borda.github.io/lucid-YOLO/work_packages/wp-159/) found the gap by walking into it: Phase 14 moved five modules under `data/` onto `fuse-augmentations` while its requirement sat in the `dev` dependency group, so an install omitting that group raised `ImportError` from `lucid_yolo.data`, and the fact sat legible in `pyproject.toml` for five work packages with nothing to surface it. A fourth check walks the shipped package's imports, resolves each to its distribution, and refuses a tag when one is absent from `[project].dependencies` — naming the group that does declare it, so the refusal diagnoses. Publishability is deliberately not checked: the runtime table carries a direct reference by decision (D20), and a check refusing it would re-litigate rather than protect · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-160/#wp-160) | `scripts/_tests/test_release_guard.py`, the group-only case failing against the pre-WP-159 tier layout and passing after it; `make gate` green | 159 | ✅ |
| 161 | `docs: the README names the paper nominatively` | `PROVENANCE.md`'s usage discipline admits exactly one form — a nominative reference to *the YOLO26 paper, arXiv:2606.03748* — and the README's own banner had drifted to *the Ultralytics YOLO26 paper*, attaching a vendor's name to the method rather than to the citation, which is the conflation sec. 3.5 exists to prevent. The id becomes the link it always described. The name stays in the two non-affiliation sentences, where naming the party is what makes a disclaimer one · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-161/#wp-161) | `pre-commit run license-headers --all-files` with both disclaimer fragments intact; `scripts/lint/audit_docs_present.py`; `make gate` green | 158 | ✅ |
| 162 | `ci: the golden sweep leaves the commit path` | [WP-130b](https://borda.github.io/lucid-YOLO/work_packages/wp-130b/) put `golden-check` on every commit calling it "one already-fast golden recompute"; measured it is 34.6 s, and `make gate` charged it twice — once inside `precommit`, once via `golden`. The hook goes to `stages: [manual]`, leaving this file to the linting and quick checks it is for. `lint.yml`'s `--all-files` run was CI's only full sweep, so the check moves to a `ci-tests.yml` step rather than disappearing with the stage · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-162/#wp-162) | `pre-commit run --all-files` no longer invokes `check_goldens.py`; `pre-commit run --hook-stage manual golden-check` still does; `ci-tests.yml` carries the 43/43 sweep; `make gate` green | 160 | ✅ |
| 163 | `perf(ptl): validation ground truth crosses to the host once` | Every validation accumulator moved its ground truth one image at a time — two tensors per image for detection, three for oriented and keypoints, and a mask stack for segmentation — so a batch of 16 paid the accelerator's fixed per-transfer latency 32 to 48 times for a payload that fits in one transfer. `_host_split` concatenates along the batch axis, moves once, and slices the result back into the same per-image pieces. Values, dtypes and order are unchanged, so no metric and no golden moves. Ported from a survey of RF-DETR's per-image device-to-host sync fixes; measured here rather than assumed · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-163/#wp-163) | `tests/ptl/test_module.py::TestHostSplit`, `tests/ptl/test_module.py::test_validation_ground_truth_matches_per_target_transfer`; `make gate` green | 158 | ✅ |
| 164 | `perf(ptl): the mask stack is not copied to drop nothing` | Validation decoded all 100 detection rows and then wrote `masks[keep]`, a second full copy of a 100×160×160 boolean stack per image. In the deployed configuration that copy drops **nothing**: `TopKDecoder()` takes `conf_threshold=0.0` and the anchor count exceeds `k`, so every row carries a live score. Counting the live rows on the host copy already present and slicing to that count leaves the copy out and, where a caller does set a threshold, shrinks the decode too. Values and order are unchanged, so no metric and no golden moves. Corrects [WP-163](https://borda.github.io/lucid-YOLO/work_packages/wp-163/)'s rejection of this candidate, whose "MPS regresses" reading was three measurement faults compounding · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-164/#wp-164) | `tests/decode/test_topk_e2e.py::test_scored_rows_leading_run`, `tests/ptl/test_seg_training.py::test_zero_scored_rows_dropped_before_decode_rather_than_after`; measured +11.7% CPU and +15.4% MPS on the deployed all-rows-live shape, every trial positive on both devices; `make gate` green | 163 | ✅ |
| 165 | `perf(ptl): validation scores reach COCO once per image` | `torchmetrics`' epoch-to-COCO conversion hoists boxes and labels to one `.cpu().tolist()` per image and then reads each detection's score individually, one tensor index and transfer per *annotation*. The parameter is already optional upstream, so `_HoistedScoreBackend` passes `scores=None`, lets `super()` build the document, and attaches the scores from one read per image. It **delegates rather than reimplements** — every field but `score` is upstream's own — and `tests/ptl/test_coco_backend.py` asserts document equality against the stock backend on the installed version, so drift fails the gate instead of moving a metric. The private-attribute swap is confined to one factory. Accepted deliberately at a coupling cost flagged and overruled: a private method carries no compatibility promise · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-165/#wp-165) | `tests/ptl/test_coco_backend.py` (box, mask-with-skipped-image, ground-truth-without-scores document equality, plus end-to-end mAP equality); measured `compute()` over 320 images x 100 detections 187.7 ms to 169.5 ms (+9.7%), every trial faster; `make gate` green | 164 | ✅ |
| 165b | `docs: Phase 15 opened and the audit grouped into rows` | Lettered off 165: the phase and the rows it runs. Phase 14 ends at its own release and its six rolling successors move here. An adversarial read of the tree returned 125 gaps grouped below by file surface rather than by the reader that found them, so each row stays one revertable change. Two rows move a frozen value and open with an escalation entry instead of a commit · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-165b/#wp-165b) | `scripts/lint/audit_docs_present.py` with `_WP_FLOOR` at 176 and its six references raised; docs-only, no code touched, gate not re-run | 165 | ✅ |
| 166 | `fix(eval): crowd and area reach the default scorer` | C-01 and C-02. `_accumulate_boxes` hardcodes `iscrowd: 0` and recomputes `area` as box width times height, while `annotations.py` parses both from the file and hands them over — so the engine `lucid-eval` picks by default implements neither COCO's crowd-ignore rule nor its area buckets, and the `faster_coco_eval` path fed identical targets implements both. Measured on synthetic cases: `map` 1.0000 against 0.5050 for one crowd annotation, and the size buckets inverted. The defect is this project's, not the library's — hotcoco returns COCO's answer when the fields reach it. Carries the module's own consistency items with it: the two engines' `maxDets`, the OKS category set, and the redirect that discards native warnings · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-166/#wp-166) | `tests/eval/test_coco_eval.py::TestHotcocoParity` with a case carrying `iscrowd=[0, 1]` and an `area` that is not the box area, agreeing on `map` and on all three size buckets — failing before the fix; `make gate` green | 165b | ✅ |
| 167 | `ci(gates): the accelerator half gets a schedule and the floors a gate` [GPU] | C-03, H-01, H-02. The Makefile says `gate-gpu` "is that schedule"; nothing invokes it — no cron, no dispatch, no tag hook — and `RESEARCH_LOG.md` already records `shapes_regression_det.json` outstanding. The four task floors are compared once, inside `main()`, so three of them are enforced only by a human at a prompt, and two gpu tolerances admit values the published DoD rejects: OKS AP down to 0.2857 against a 0.30 floor, rotated mAP50 down to 0.8890 against 0.90. The comparison moves into `run_overfit` so producer and floor cannot disagree, and each band clamps at its own floor · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-167/#wp-167) | a scheduled or dispatchable `gate-gpu` workflow, or a run recorded under `.reports/`; `scripts/_tests/test_overfit_micro.py` floor cases for segmentation, oriented detection and keypoints; `goldens/gpu/` re-frozen with every band above its floor; `make gate-gpu` green | 170,173 | ✅ |
| 168 | `ci(gates): the gates and the ratchets certify what they claim` | H-03, H-04, H-08, H-10 and the checker items under them. CI runs 1544 tests where `make gate` runs 2661 — no `--doctest-modules`, no `src scripts` paths — so every `scripts/_tests/` test and some 879 doctests never run remotely. Nothing verifies a frozen golden is unmodified: edit its values and tolerances together and the gate is green, which is the rule two principal overrides were granted against. `_ASSUMPTION_FLOOR` sits at 26 against 73 rows and tests `max(ids)` where the work-package floor tests `len`. The licence audit matches the GPL family against a policy that bans six further classes · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-168/#wp-168) | `ci-tests.yml` invoking `make test`; `goldens/frozen/MANIFEST.sha256` with a hook asserting every digest and `freeze_goldens.py` the only writer; `_ASSUMPTION_FLOOR` at 73 on a `len` predicate; `scripts/_tests/test_audit_licenses.py` rejecting an SSPL distribution and one declaring no licence; `release_guard` asserting tag against `__version__` and a non-empty frozen set; `make gate` green | 165b | ✅ |
| 169 | `fix(assign): alignment weights survive zero-metric ties and low overlap` | H-16 and H-17, both measured. `topk` over an all-zero alignment metric fills from the global ties, so intersecting with the candidate mask can return no positive for a ground truth that had four — the state a head is in at initialization, `decode_ltrb` applying no non-negativity and `oriented_loss` recording that raw distances routinely decode inverted. Separately `eps = 1e-9` is an absolute floor under `t_max = s·u⁶`, so at IoU 0.01 the best anchor's weight is 2000 times too small and a positive trains as background while `fg_mask` says foreground. Both move `assignment_cases.json`, live and frozen from 0.2 — measured, neither does · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-169/#wp-169) | a real ground truth with `c` candidates and an all-zero metric yielding `min(k, c)` positives; `tests/assign/test_tal.py` weight assertion parametrized over `u_max` in {1.0, 0.5, 0.1, 0.01}; a principal ruling recorded in `docs/ESCALATION.md` **before** any frozen value moves; `make gate` green | 165b | ✅ |
| 170 | `fix(losses): degenerate geometry stops answering plausibly` [GPU] | H-18, H-19 and the degenerate-input medium block. A collapsed box draws a 3e5 CIoU gradient through `atan(w/(h + eps))` and an inverted one draws none at all, so the term can never un-invert a box; `atan2` removes both. A NaN rotated box scores IoU exactly 0, so suppression never fires and it is emitted as a detection with a real score. Negative extents canonicalize into a valid pair and return IoU 1 against a documented zero. Invisible keypoints reach the RealNVP flow before the mask does, so one non-finite unlabeled coordinate makes every gradient NaN while the loss reads healthy · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-170/#wp-170) | gradient-magnitude bounds rather than finiteness in `tests/losses/test_ciou.py`; NaN and Inf cases for `rotated_iou` and `RotatedNMSDecoder`; a gradient-independence assertion on the masked keypoint; float16 value and gradient cases for ProbIoU; `goldens/gpu/` re-frozen with each moved value traced; `make gate` green | 165b | ✅ |
| 171 | `fix(cli): entry points validate values, not only types` | H-20 and the eight siblings the audit grouped under it. Nine public entry points check a type and not a domain, so an out-of-range value reaches arithmetic or dispatch and answers plausibly instead of failing. `--limit -5` scores every image except the last five and prints the truncated count as though it were the request; `--batch_size 0` raises from a progress-bar total; a misspelled `decoder` takes the one-to-many path with another branch's boxes and, on the oriented path, another branch's headings; `img_size=641` crashes inside the neck; an unknown task is scored as detection · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-171/#wp-171) | `tests/cli/test_cli_surface.py` refusing a negative limit, a zero batch size, an out-of-range confidence threshold, an unknown decoder spelling, a canvas not divisible by the maximum stride, and an unsupported task — each with a message naming the flag; `make gate` green | 165b | ✅ |
| 172 | `fix(data): every merge path keeps every modality` | The data-layer block. `CopyPaste._merge` rebuilds `Targets` without `rboxes` or `difficult`, so a rotated destination loses its modality through the `apply` seam [WP-147](https://borda.github.io/lucid-YOLO/work_packages/wp-147/) made public — the exact failure `_reject_rboxes` exists to prevent — and a `difficult` flag A48 makes load-bearing is refilled all-`False`. `HorizontalFlip` mirrors about `W-1` and never re-clips, so the last stage of the train pipeline hands the assigner a coordinate a pixel outside the canvas. Train and val COCO label spaces are derived independently and never compared · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-172/#wp-172) | `tests/data/test_mixup_copypaste.py` driving `apply` directly for both the rotated refusal and `difficult` survival; a train/val category-map assertion raising on the symmetric difference; the flip's convention stated and asserted; `aug_invariants.json`'s move ruled on in `docs/ESCALATION.md` before it lands; `make gate` green | 165b | ✅ |
| 173 | `fix(models): named branch outputs and a schedule that reaches its floor` [GPU] | H-21 and the model-surface block. `_DetectionBranch.forward` returns six tensors as a bare positional tuple destructured in five places, four of them with underscore placeholders — the failure its own docstring argues against, one layer below the dataclass built because a tuple was judged unsafe there. `count_flops` on a `deploy()` view flips the parent into train mode through shared submodule references. The LR factor's last real step is `total - 1`, so a 100-step run floors at 0.0209 rather than `lrf`, and `warmup_steps >= total_steps` never decays at all · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-173/#wp-173) | a frozen `BranchOutput` dataclass read by attribute at all five call sites; `tests/optim/test_schedule.py` asserting the factor at `total_steps - 1` equals `lrf` and that warmup is clamped below the total; a mode-restoration case for `count_flops`; `goldens/gpu/` re-frozen for the schedule change; `make gate` green | 165b | ✅ |
| 174 | `test(eval): the keypoint tier's shipped surface is executed` | H-05, H-06 and the checkpoint block. `predict_keypoints` has zero executed lines in the gate — its Example asserts `callable`, and the one test naming it replaces it with a stub — while `pose_eval.py`, the keypoint tier's acceptance scorer, sits at 37% behind the same Example pattern. `_overlay_ema` is unexecuted too, and resolves its callback key by substring in one place and exactly in another, copying only the shadow's own keys so a module tensor missing from the shadow silently keeps its raw value · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-174/#wp-174) | `tests/predict/test_predict_kp.py` planting a known point set and asserting the letterbox inverse against hand-computed coordinates; `tests/eval/test_pose_eval.py` covering the `K != 17` refusal; `_overlay_ema` unit-tested against a hand-built payload including the shadow-less raise; an unconditional `hotcoco_available() == (True, None)` assertion; `make gate` green | 165b | ✅ |
| 175 | `docs: the record describes the repository that exists` | The documentation sweep, and the register work under it. The README names 0.4.0 as current against `__version__` 0.7.0, offers an install line that resolves to nothing, and says the paper publishes no oriented parameter table while `test_obb_vs_tableS11` gates exactly that table on every commit. Five documents count three tasks where four ship, and the vendor-bound paper phrase [WP-161](https://borda.github.io/lucid-YOLO/work_packages/wp-161/) corrected in the README survives in `NOTICE`, `docs/index.md` and all four model cards. The blueprint, the source of 49 in-code citations, has no register row — measured, 133 across 36 files · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-175/#wp-175) | a register row for the blueprint stating authorship, publicity and licence status; a second audit row covering `975ff50..HEAD`; the corrected paper phrase added to `audit_license_headers.py`'s required fragments; a README-version check beside `audit_version_single_source.py`; `scripts/lint/audit_docs_site.py` clean; `make gate` green | 166,168,169,170,171,172,173,174 | ✅ |
| 176 | `release: v0.8.0 the audit remediations` [PRINCIPAL] | The phase released. `__version__` to `0.8.0`, the `## [Unreleased]` section closed, `make freeze-goldens` into `goldens/frozen/0.8`. Two of this phase's rows moved a value pinned in every frozen set from 0.2 forward, so this freeze is also where those moves stop being recorded exceptions and become the baseline a later release is held against · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-176/#wp-176) | `scripts/release_guard.py`; `make gate` green | 175,167 | ✅ |

## ⚡ Phase 16 — Training throughput (WP-177…)

| WP | Commit subject | Scope | DoD | Dep | Status |
| -- | -- | -- | -- | -- | -- |
| 177 | `perf(optim): batch equal-shape MuSGD matrices` [GPU] | The n-scale optimizer has 127 matrix parameters but only 41 exact `(shape, dtype, device)` keys. `_step_group` maps its existing `_parameter_update` over each key holding two or more, turning the separate Newton--Schulz products into batched products without changing the formula, coefficients, requested step count, normalization, branch weights or decay. Vector parameters, `w_muon == 0`, a key holding one parameter and every backend outside CUDA keep their existing path. This changes floating-point scheduling, not optimizer arithmetic · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-177/#wp-177) | exact-shape, dtype and parameter-group boundary tests against separate updates; real-data n-scale L4 training-step median over at least five runs clears 3%; `make gate` and `make gate-gpu` green | 176 | 🔄 |

## 📐 Phase 17 — R1 pose-objective parity (WP-178…181)

Opened by issue 2 on the tracker: R1 sec. 3.4.2 publishes the pose objective this project's keypoint tier was recorded as having no source for (D22). [WP-178](https://borda.github.io/lucid-YOLO/work_packages/wp-178/) corrects the record and lands the missing term switched off; the three rows after it are the runs that would make the tier a full reproduction rather than a partial one, and each needs an accelerator or a principal. None changes the shipped 0.5.0 objective until its own run says so.

| WP | Commit subject | Scope | DoD | Dep | Status |
| -- | -- | -- | -- | -- | -- |
| 178 | `fix(keypoints): R1 does publish the pose objective` | D22 withdraws D21's rationale clause; A65, A66, A68 corrected in place; A75 (OKS loss form, derived from R12 since R1 gives none and cites a paper outside the allowlist), A76 (R1 Eq. 11 carries `log sigma` twice, R14 Eq. 8 once — the code keeps R14's), A77 (point stem measured 13.0% / 26.5% under R1 Table S10 at `n`). `losses/oks_loss.py` lands `1 - OKS` per instance and `DetectionLitModule` gains `oks_gain`, default `0.0`, so the shipped objective is bit-exact. Registers struck in place with the date, the reproduction report corrected by an appended note, card and docstrings rewritten with one · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-178/#wp-178) | `tests/losses/test_oks_loss.py`; `tests/ptl/test_keypoint_training.py` (`oks_gain = 0` bit-exact, `oks_gain = 1` adds exactly the logged term); `make gate` green | 177 | ✅ |
| 179 | `exp(ptl): Table 9 weight split at smoke scale` [GPU][PRINCIPAL] | The pose smoke recipe at `oks_gain = 24, keypoint_gain = 1` (R1 Table 9's best split, on this project's box/cls scale — A68 records the frame mismatch) against the [WP-125](https://borda.github.io/lucid-YOLO/work_packages/wp-125/) RLE-alone arm at one seed; then `(24, 0)` and `(0, 2)` if the first pair separates. Acceptance is whether OKS + RLE beats RLE alone on e2e OKS AP, the direction Table 9 reports (63.0 vs 61.9) — not the figure | Paired same-seed runs, result in RESEARCH_LOG; A75 moves to `validated` or records the reverse sign; `oks_gain` default revisited only on that evidence | 178 | ⬜ |
| 180 | `feat(models): keypoint stem width against R1 Table S10` [GPU] | Choose the point stem's hidden width the way A20 chose the angle stem's: measure `_stem_width` alternatives against Table S10's implied stem increment at every scale, select where the table discriminates, ship `test_kp_vs_tableS10` mirroring `test_obb_vs_tableS11` at 640 px / 1 class / 17 points, re-freeze `params_flops_kp.json`. A behaviour change to a landed head: needs the pose smoke run repeated at the new width before the default moves | `test_kp_vs_tableS10` passes at every scale within the tolerances the measurement supports; A77 revised with the measured ratios; paired smoke run recorded | 178 | ⬜ |
| 181 | `exp(losses): Eq. 11's doubled log sigma, paired` [GPU][PRINCIPAL] | The pose smoke recipe at one seed with `RLELoss` carrying R1 Eq. 11's second `log sigma` term, against the [WP-125](https://borda.github.io/lucid-YOLO/work_packages/wp-125/) arm; A76 resolves to whichever form the run supports, or stays open if they do not separate | Paired same-seed runs, result in RESEARCH_LOG; A76 status moves | 178 | ⬜ |
| 182 | `perf(train): channels_last on CUDA` | `DetectionLitModule.setup` converts the parameters to `channels_last` when the trainer's accelerator is CUDA — before device placement, so the EMA shadow cloned at `on_fit_start` inherits the layout — and `on_after_batch_transfer` makes the images match on CUDA alone. No flag (D23). Measured +8% eager train step on an RTX PRO 6000, variant `n`, COCO b16; CPU and MPS bit-identical | `tests/ptl/test_memory_format.py` (every task × `n`/`s` steps to a finite loss in `channels_last`; `detect`/`n` loss allclose across layouts; CPU batch and CPU trainer keep the default layout); `make gate` green | 178 | ✅ |
| 183 | `refine(losses): dense box terms over every anchor` | `DetectionBranchLoss._box_losses` and `oriented_branch_terms` score CIoU / ProbIoU, L1 and the angle term densely over all `(B, A)` anchors, selected by `fg_mask` through `torch.where`, instead of gathering `pred[fg_mask]` — a data-dependent shape that broke the `torch.compile` step graph seven times. Loss-identical (absdiff 0.0 on a COCO batch), +2% eager; the gates WP-184 builds on | `tests/losses/test_detection_loss.py::TestDenseBoxTermsMatchMaskedReference` and `tests/losses/test_oriented_loss.py::TestDenseOrientedTermsMatchMaskedReference` (value and gradient against the pre-change masked oracle to `1e-6`, empty image, all-background batch, degenerate background predictions); `test_detect_step_reproduces_pre_change_snapshot` unchanged; `make gate` green | 182 | ✅ |
| 184 | `perf(train): opt-in torch.compile of the detect objective` | `DetectionLitModule` gains kw-only `compile_step` (default off): on a CUDA accelerator under `task="detect"`, `setup` compiles the extracted `_detect_objective` — forward, both decodes, assignment, dual loss — as one Inductor region through the bound method, so state-dict keys are untouched; every other backend or task warns once and stays eager (D24). `tal.py` skips `_check_labels` under tracing and writes `_resolve_conflicts`'s one-hot as an `arange` comparison so `N` is not pinned; batch and padded-GT dims are marked dynamic (enforcing). Measured x1.23 det step on an RTX PRO 6000, `n`, COCO b16; mAP parity is A78, validated 2026-09-20 on an A100 (paired 4-epoch run: x1.25, 1 recompile, loss parity `3.6e-5` with TF32 off) | `tests/ptl/test_compile.py` (region traces as 1 graph / 0 breaks with and without `capture_scalar_outputs`, with GTs and with none; the compiled step matches eager over five batch shapes through the enforcing marks; `setup` installs the wrapper once on a mocked CUDA accelerator, warns and stays eager off CUDA and for other tasks; keys unchanged); `test_module.py::test_compile_step_leaves_state_dict_keys_unchanged`; `test_cli.py::test_compile_step_flag_reaches_module`; `make gate` green | 183 | ✅ |
| 185 | `fix(cli): lucid-yolo exits zero on success` | `lucid_yolo.cli.train.main` returned the constructed `DetectionCLI`; the console-script wrapper passes that to `sys.exit`, which prints any non-`int` and exits `1`, so every completed `lucid-yolo fit` reported failure to `&&`, `subprocess.run(check=True)` and CI. `main` returns `0` like `lucid-data`, `lucid-eval` and `lucid-predict`; `__main__` raises `SystemExit(main())` as they do. Surfaced by the WP-184 Colab gate | `tests/cli/test_cli_surface.py::test_train_command_exits_zero_after_subcommand_ran`; `make gate` green | 184 | ✅ |
| 186 | `test(goldens): reseal the keypoint and oriented accelerator goldens after the dense box terms` | `make gate-gpu` on an A100 after [WP-183](https://borda.github.io/lucid-YOLO/work_packages/wp-183/) is 53/55: `overfit_micro_kp` 0.4919 and `overfit_micro_obb` 0.9088 against bands at 0.5747 and 0.9649 ± 0.05. Same-machine per-commit control: baseline 0.5696 / 0.9783, WP-182 0.5486 / 0.9388, [WP-183](https://borda.github.io/lucid-YOLO/work_packages/wp-183/) 0.4919 / 0.9088 — the dense form's summation order on a 100-epoch overfit, not a change in arithmetic (det, seg and shapes hold). Principal override (ESCALATION 2026-09-19): reseal both at the measured values; the oriented band clamps to 0.0088 | both `goldens/gpu/` rows rewritten at the measured values with `clamped_tolerance`; ESCALATION entry with its resolution; `make gate` green; `make golden-gpu` 55/55 on the A100 is the round-4 check | 183 | ✅ |
| 187 | `fix(train): compiled step survives the alpha schedule` | A 2-epoch `det_nano_smoke` on an A100 with `compile_step` on recompiled `_detect_objective` every epoch on the guard `self.loss.alpha == 0.8`: the WP-035 hook rewrites that Python float per epoch and Dynamo guards on its value, so a 50-epoch run crosses `cache_size_limit` (8) by epoch 7 and silently trains eager. `DualBranchLoss.__call__` gains kw-only \\a kw-only `alpha` tensor argument (`None` by default); `\_detect_objective`takes a 0-d`float64`alpha tensor built by`\_shared_step`on the host, blended with the float form's rounding so eager and compiled totals stay bit-identical;`DualLossOutput.alpha\` stays the attribute float. Alpha is the one schedule-written scalar the region reads (D24 amended) | `tests/ptl/test_compile.py::TestAlphaScheduleDoesNotRecompile` (three alpha values, zero added compiled frames, the 0.1 total bit-equal to eager) and `::test_tensor_alpha_blends_exactly_as_eager_float`; `tests/losses/test_dual_loss.py::test_tensor_alpha_blends_bit_equal_to_attribute_and_records_attribute`; explain test still 1 graph / 0 breaks; `make gate` green | 184 | ✅ |
| 188 | `feat(docs): notebooks rendered from jupytext sources` | Notebooks are committed as `notebooks/<name>.py` jupytext percent-format sources — linted, SPDX-headed, run by the gate — and the `.ipynb` is a build product: `make notebooks` converts each into gitignored `docs/notebooks/`, `mkdocs-jupyter` renders it unexecuted (`execute: false`), and the `Docs` workflow publishes the converted files with the site at `notebooks/<name>.ipynb` on `gh-pages`, which is where the Colab badge in each title cell points — site publishing switches from the Pages artifact flow to `ghp-import` onto that branch (plain push, never forced) so one branch carries both. `no-ipynb-tracked` refuses a `.ipynb` in the index; `docs-site` resolves a `notebooks/<x>.ipynb` nav entry against its `.py` source and requires an entry per source; `license-headers` scopes `notebooks/`. `tests/notebooks/test_notebooks_run.py` runs every source under `LUCID_NOTEBOOK_FAST=1` on CPU in `make test` and in full under `gpu`+`data` in `make test-gpu`. Ships with zero sources; [WP-189](https://borda.github.io/lucid-YOLO/work_packages/wp-189/) adds the first (D25) · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-188/#wp-188) | `scripts/_tests/test_audit_docs_site.py` (derived nav entries, source coverage), `scripts/_tests/test_audit_no_notebooks_tracked.py`, `tests/notebooks/test_notebooks_run.py` (skips cleanly on an empty glob); `make docs` strict-green; `make gate` green | 114 | ✅ |
| 189 | `refine(viz): plotting and overlay helpers move into the package` | The figure cores of `scripts/plot_training.py` and `scripts/draw_predictions.py` move to the private `lucid_yolo._viz` package (`curves`, `overlay`), and the scripts become thin CLIs that select the `Agg` backend and save; two notebook entry points are added — `plot_curves(metrics_csv)` for a run's `metrics.csv` and `show_predictions(checkpoint, images, annotations)`, one panel per image with predictions solid and ground truth dashed, the ground truth read through `CocoDetectionDataset` (`file_names`, `targets_at`) rather than a second JSON parse. `matplotlib` moves from the `dev` group to `[project].dependencies`: `pytorch-lightning[extra]` already required it, so nothing a consumer installs changes, and [WP-168](https://borda.github.io/lucid-YOLO/work_packages/wp-168/)'s release guard reads declarations. No backend is selected inside the package, so a notebook keeps its inline display | `tests/viz/` (`test_curves`, `test_overlay`, `test_import_surface` — no shipped module imports `_viz`; importing it leaves the backend untouched); `scripts/_tests/test_release_guard.py` green on the live tree; `make gate` green | 188 | ✅ |
| 190 | `docs(notebooks): development gates, as the commands` | `notebooks/development_gates.py`, the first notebook on the [WP-188](https://borda.github.io/lucid-YOLO/work_packages/wp-188/) rails: `scripts/overfit_micro.py --task <detect,segment,obb,keypoints>` and `scripts/shapes_regression.py --task det` run as the `!` commands TRAINING.md and the Makefile name, TRAINING.md's argument quoted beside them; the verdict is the PASS/FAIL line each prints. Under `LUCID_NOTEBOOK_FAST=1` every command carries a probe override (`--epochs 1`; `--images 40 --epochs 1` for shapes) and the overfit script reports PROBE with its score, floor not enforced — `--epochs` is new, modelled on the shapes script's, and the floor is enforced only at the tuned budget. The script also gains `TASK_ALIASES` and `resolve_task`, because the `--task detect` / `segment` / `keypoints` spellings TRAINING.md, the model cards and the reproduction report have always written named no gate — three of the four documented commands exited 1 — and the notebook is what noticed | `tests/notebooks/test_notebooks_run.py[development_gates]` (fast, ~26 s on Apple silicon); `scripts/_tests/test_overfit_micro.py::TestRunOverfit::test_reports_capped_run_without_floor`, `::test_documented_task_names_resolve_to_wired_gate`; `make docs` strict with the page in the nav; Colab badge run [HUMAN] after push | 188 | ✅ |
| 191 | `docs(notebooks): one demo per task` | `notebooks/demo_{detect,segment,obb,keypoints}.py`, one shape four times: badge and bootstrap, data (`lucid-data download` of COCO val2017 for the three COCO tasks, the DATASETS.md manual step and `lucid-data build-tiles` for DOTA), `lucid-yolo fit` on the task's `*_nano_smoke.yaml` for two epochs with `warmup_epochs 1`, `plot_curves` over the run's `metrics.csv`, `lucid-eval`, and `show_predictions` on four images with predictions solid and ground truth dashed — every figure one `lucid_yolo._viz` call ([WP-189](https://borda.github.io/lucid-YOLO/work_packages/wp-189/)), every command the console script TRAINING.md names. The COCO demos train on val2017 as both splits (the A78 validation run's layout) and say so; the numbers are a pipeline proof, not the cards'. Under `LUCID_NOTEBOOK_FAST=1` each swaps its dataset for the synthetic slice, trains one epoch on a few batches on CPU, prints the last `metrics.csv` row and runs `lucid-predict` on two images, importing nothing from `_viz`; the DOTA demo exits with the DATASETS.md instruction when `DOTA_ROOT` is unset. Each model card links its demo | `tests/notebooks/test_notebooks_run.py` fast cases (11–19 s CPU each); full cases `gpu`+`data` under `make test-gpu`; `_viz` cells replayed on FAST artifacts (Figures returned, dashed ground truth drawn); `make docs` strict with four pages in the nav; Colab badge runs [HUMAN] after push | 189, 190 | ✅ |
| 192 | `perf(eval): complete oriented scoring uses the worker policy` [DATA][GPU] | Complete oriented scoring uses the worker policy when batches outnumber workers; positive `--limit` or a tiny complete split runs in-process with 0 workers on every host (explicit 0 stays 0 with `/dev/shm`). Reports record `workers` · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-192/#wp-192) | `tests/eval/test_eval_obb.py` (public full/limited dispatch through the public builder seam, tiny-complete fallback, tensor-for-tensor loader parity, real-pool report equality, and the reported worker count); non-gated [DATA][GPU] benchmark evidence in [work_packages/wp-192.md#wp-192](https://borda.github.io/lucid-YOLO/work_packages/wp-192/#wp-192); `scripts/lint/audit_docs_present.py` with `_WP_FLOOR` at its exact live count of 192 and its test fixtures raised; `make gate` green | 191 | ✅ |
| 192b | `perf(data): cache decoded training images` [DATA][GPU] | COCO and YOLO readers can retain one RGB `uint8` tensor per distinct training file before worker startup; every draw still creates fresh float storage and runs the unchanged random pipeline. Opt-in as `cache_train_images` because host RAM grows with the uncompressed split (D27); validation stays uncached · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-192b/#wp-192b) | cache decode-count/deduplication/immutability tests; COCO/YOLO train-only routing tests; 100-draw bit-level public DOTA parity; focused data/datamodule/docs suite plus ruff, mypy and repository audits green; `make gate` green; non-gated [DATA][GPU] benchmark evidence in [work_packages/wp-192b.md#wp-192b](https://borda.github.io/lucid-YOLO/work_packages/wp-192b/#wp-192b): five paired isolated-CPU reader runs and five paired L4 `Trainer.fit` runs | 192 | ✅ |
| 193 | `perf(train): fuse post-backward state updates` [DATA][GPU] | MuSGD's exact-shape CUDA buckets evaluate the existing quintic with native batched products, and EMA groups floating tensors by device and dtype for one foreach interpolation per group. No coefficient, iteration count, branch or decay changes · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-193/#wp-193) | Native-batch/scalar and foreach/scalar regression trajectories; public COCO finite training; five paired isolated-L4 complete intervals with a median above 8%; `make gate` green; `make gate-gpu` still outstanding — this row reassociates the same CUDA Newton--Schulz path [WP-177](https://borda.github.io/lucid-YOLO/work_packages/wp-177/) left open, so its status does not move to done until that check runs | 192 | 🔄 |
| 193b | `perf(optim): batch compatible MuSGD matrices and momentum` [DATA][GPU] | CUDA matrices sharing a smaller dimension of at most 256 run one native Newton--Schulz batch on zero-extended columns, cropped back before their own scale, gains and decay; independent CUDA momentum buffers advance through three foreach operations. Overlapping memory, parameter subclasses and other devices keep the scalar route. No coefficient, iteration count or schedule changes · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-193b/#wp-193b) | `tests/optim/test_padded_musgd.py` (mixed-width scalar oracle, storage budget, row region, DLPack overlap, alias and restored-state guards, foreach recurrence on CPU and CUDA); five paired isolated-L4 complete `Trainer.fit` runs with a median reduction of 11.19%; full `make gate` and `make gate-gpu` not run by the author, so the status does not move to done until they do | 193 | 🔄 |
| 194 | `refine(tests): static checks to hooks, plain test names` | File-content tests leave pytest: the `_viz`/matplotlib import boundary becomes ruff `TID251`, the top-k and packaged-recipe text pins the `content-pins` hook, and live-tree "hook passes" copies are dropped. Test names lose `a an the its their is are does that` · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-194/#wp-194) | `scripts/_tests/test_audit_content_pins.py`; collection count unchanged; `make gate` green | 193 | ✅ |
| 195 | `perf(data): batch rotated-box clipping and refitting` [DATA][GPU] | Finite float32 CPU augmentation boxes without gradients share the existing clip and orientation-preserving fit in equal-vertex-count groups. Ring order and instance order are preserved; other inputs keep the scalar route · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-195/#wp-195) | `tests/data/test_rotated_clip_batch.py`; unchanged public-DOTA training states and five paired isolated-L4 complete fits; focused data tests and full pre-commit; full `make gate` not run | 194 | 🔄 |
| 196 | `docs(site): tabs, method pages, one file per WP` | The single left menu listed every register at once, three pages ran past 500 lines, the site never explained the method, and findings and results were copied between pages. A four-tab top bar (Home, Results, Models, Project); two Method pages with animated SVGs (how the detector works; NMS against NMS-free). The report splits into one page per section; each WP's findings move to `work_packages/wp-<id>.md`, which the research and engineering phase pages include by section (D28), and the Project tab lists phases first. Model cards share one layout with an animated SVG, and include the report's result tables rather than copying them. Every WP named in the docs links to its page, each WP page opens with a roadmap-derived header (logs, dependencies, neighbours), and the README's Mermaid diagram becomes the animated SVG. For search engines and agents, a build hook gives every page a distinct title and description, Open Graph tags with a static preview card, JSON-LD on the home page, a markdown copy at `<page>/index.md`, and `llms.txt` / `llms-full.txt`; the README links the site and `CITATION.cff` lands · [decision](https://borda.github.io/lucid-YOLO/DECISIONS/) | `make docs` strict-green (every anchor and include resolves); `audit_wp_links.py` (`wp-links`) refuses an unlinked mention or a stale WP header; `audit_docs_site.py` honours `not_in_nav`; `audit_docs_present.py` checks each roadmap log link against its own target file and `check_work_package_files` refuses unnumbered or unincluded WP files; `audit_figure_captions.py` reads the report pages; `_WP_FLOOR` 196, `_DECISION_FLOOR` 28; `make gate` green | 195 | ✅ |
| 197 | `perf(data): defer training HSV and quantization to CUDA` [DATA][GPU] | Opt-in `device_hsv` keeps gain sampling and flipping in the workers, then batches the existing colour operations and quantization on CUDA. Float32 image transport spends four times the default image bytes; validation and the default pipeline stay unchanged · [log](https://borda.github.io/lucid-YOLO/work_packages/wp-197/#wp-197) | `tests/ptl/test_device_hsv.py`; public COCO complete-fit comparisons, state-byte parity and focused checks; full `make gate` not run | 196 | 🔄 |

## ⛓️ Critical path and parallelism

Strictly sequential: 001 -> 007 -> 015 -> 023 -> 030 -> 033 -> 040 -> 046. WP-016…019 (blocks), WP-024 (CIoU), and WP-031 (Newton–Schulz) have no interdependencies and may be executed in any order once WP-003 lands. Phases 7 and 8 are strictly gated on releases 0.1.0 and 0.2.0 respectively — task heads never land on an unproven detector.

## ⚠️ Failure budget

If a WP's DoD cannot be met after two documented assumption iterations, escalate (AGENTS.md sec. 4). Fidelity-gate WPs (023, 052, 062) are the expected escalation sites: they are where the papers' block-level ambiguity meets a hard published number, and where an honest reproduction earns its credibility.
