# 📋 Assumption Register

Anywhere the papers underdetermine the implementation, the chosen assumption, its public source, and its validation plan are recorded here **before** the corresponding code lands. Rule: any new gap discovered during implementation gets an entry before the code merges. Assumption revisions after a release ship as PATCH (if within golden tolerances) or the next 0.MINOR (if results move).

**Adding a row also raises the floor.** `_ASSUMPTION_FLOOR` in `scripts/lint/audit_docs_present.py` counts the rows this register is allowed to hold and never fall below; the `docs-present` hook reports a shrink against it. It is a ratchet: raise it to the new row count in the same commit that adds the row, never lower it. `_DECISION_FLOOR` (`DECISIONS.md`) and `_WP_FLOOR` (`ROADMAP.md`) work the same way and want the same edit. The floor sat at 26 against 73 rows until [WP-168](https://borda.github.io/lucid-YOLO/work_packages/wp-168/), which is 47 rows of slack — a register can only be certified by a number somebody keeps current.

Status legend, **rewritten 2026-09-06 to describe what the column actually holds** — the previous wording defined `revised` as "superseded" and `validated` as "validation plan executed and passed", and the register contradicts both:

- `open` — the assumption governs, and the evidence that would settle it is not in yet: either the code has not landed, or the validation it names needs a run nobody has done. Not "unused": most `open` rows are shipping code.
- `active` — in the codebase, with its validation plan run as far as an offline gate can take it.
- `validated` — reserved for the rows whose validation was a *measurement against an independent oracle or a paired run*, rather than a unit test that pins the choice. It is a strong claim and only a handful of rows carry it.
- `revised` — the assumption's **value** changed after code shipped against the old one, and the row is the current one either way. It does **not** mean superseded: the sole row carrying it is in force today. Most revisions do not use this status at all — a revised row usually keeps `active` or `open` and records the change inline, dated, in its own Assumption cell.

Two consequences of that last point, both load-bearing when reading the column: **a row's status does not tell you whether it has been revised** (several `active` and `open` rows carry dated inline revisions, and several say VALIDATED inline while staying `active`), and **the four statuses are not a lifecycle** — nothing progresses `open → active → validated` on a schedule. Read the Assumption and Validation cells; the status is a coarse filter, not a summary. Counts are deliberately not stated here: they move with every row that lands, and a tally written into prose is the thing that goes stale — `scripts/lint/audit_docs_present.py` is what counts the register, and it counts rows rather than statuses.

| ID | Gap in papers | Assumption | Public source | Validation | Status |
| -- | -- | -- | -- | -- | -- |
| A1 | Box IoU loss variant unnamed | CIoU | R9, R10 | Ablation-neutral check at the smoke tier | open |
| A2 | TAL alpha/beta not restated | alpha=1, beta=6 | R4 | Overfit + ablations-tier trend | open |
| A3 | C3k2/C2PSA sub-block internals beyond Fig. S2 | Bottleneck/PSA internals per YOLO11 lineage; nested `C3k` inner-bottleneck count **1** (`_C3K_INNER_UNITS`, revised 2026-08-01 from 2 by [WP-023](https://borda.github.io/lucid-YOLO/work_packages/wp-023/) iteration 2 — at depth=1.0 the `l`/`x` variants carry two nested units per `c3k=True` stage and `n=2` overshot the R1 Table 7 FLOP budget; `n=1` lands all five scales within +/-5% FLOPs while keeping params within +/-2%) | R11, R1 Fig. S2 | Param/FLOP gate (Phase 2) — `tests/models/test_param_flops.py::test_det_vs_table7` passes all 5 scales | active |
| A4 | SPPF shortcut exact form | Input added to output of pooling stack | R3 sec. 4 | Param gate; ablate if mismatch | open |
| A5 | Newton–Schulz iteration count | 5 | R7, R8 | Orthogonality unit test | open |
| A6 | Muon step scaling | 0.2 * sqrt(max(A,B)) update-RMS scaling | R7 Eq. 4 (verbatim: 0.2 * O * sqrt(max(A,B))) | Toy convergence test | active |
| A7 | muon_w+sgd_w != 1 semantics | Independent additive gains | R1 Tables S4/S7 | Documented; sensitivity note | open |
| A8 | LR schedule shape (lrf semantics) | Per-step linear decay to final LR = lr0\*lrf, preceded by a linear warmup over `warmup_epochs` (default 3) from `lr0/warmup_steps` (revised 2026-08-03 by WP-072: warmup added — Det-smoke attempt 1 ran the constant-LR deferral and plateaued at val mAP50-95 3.96 vs the >25 criterion; overfit-100 golden keeps the schedule off, `lrf: 1.0`/`warmup_epochs: 0`) | gap; decay convention widely restated in third-party YOLO-application literature; warmup is generic from-scratch practice (A31 lineage) | `tests/optim/test_schedule.py` endpoints; **Det-smoke attempt 3 VALIDATED 2026-08-06** (warmup + decay to lr0\*lrf over the full 50-epoch budget, monotonic val descent, acceptance met); trend-neutral in paired ablations-tier runs | active |
| A9 | One-to-one output tuple (...,6) | [x1,y1,x2,y2,score,class]; seg appends K coefficients | R1 sec. 3.2.1 | Eval round-trip test | open |
| A10 | Resize semantics | Letterbox, aspect-preserving. Since [WP-155](https://borda.github.io/lucid-YOLO/work_packages/wp-155/) the fit, the forward matrix and the resample are R21's — `letterbox_geometry`, `letterbox_matrix`, and a letterbox-only `Compose` segment that runs one `grid_sample` from the source canvas to the letterboxed one in place of an `F.interpolate` followed by an `F.pad`. What this row is actually about stays local: `Letterbox.forward_affine` and `Letterbox.inverse_map` keep their signatures and their semantics, and the inverse stays closed-form (`inv_r`, `-pad * inv_r`) rather than a numerical inversion of the forward matrix, because the round trip below has to be **exact** and a numerical inversion leaves a residual in it. Delegating moved pixels and not geometry, which is the whole content of the boundary here: `letterbox_image_mean` moved 0.5024 → 0.4994 and not one coordinate golden did (measured and re-frozen by WP-155b) | YOLO-lineage convention in third-party literature (e.g. R11); the letterbox segment itself is R21 (ADR-005) | Bbox/mask round-trip test — `tests/data/test_letterbox.py` | open |
| A11 | Anchor center placement | (i+0.5)\*stride | R4, R5 | Assignment unit test | open |
| A12 | Weight-decay exclusions | 1D params excluded | Standard practice (e.g. R7 discussion) | Documented | open |
| A13 | From-scratch loss gains; coordinate frame of the L1 term the legacy DFL gain field scales | Pretrain-style gains (7.5/0.5/6.0); the L1 term is measured in **stride units** — the head's native ltrb parameterization — not pixels (revised 2026-08-04 by WP-078: with pixel-frame L1 the term ran 8-32x large and was 97.4% of the val objective at Det-smoke attempt 2's endpoint, starving classification — mAP50-95 6.3, mar_100 24% despite well-localized boxes; decomposition arithmetic reproduces the logged 200.7 val total exactly) | R1 Table S2; head ltrb frame per R4/R6 lineage | `test_detection_loss.py::test_l1_stride_normalization_divides_by_anchor_stride`; **Det-smoke attempt 3 VALIDATED 2026-08-06** — stride-frame L1 moved classification from 1.7% to 23.0% of the objective and val mAP50-95 from 6.28 to 25.30 at half the epoch budget | active |
| A14 | Prototype count K | K=32 | YOLACT R16 | Param/FLOP gate vs Table S9 | open |
| A15 | Proto spatial resolution | 160x160 at 640 input (2x upsample of P3) | YOLACT convention R16 | Param/FLOP gate; mask-quality check | open |
| A16 | Coefficient activation + instance-mask loss | tanh coefficients; per-pixel BCE on box-cropped masks, box-area normalized | YOLACT R16 | Overfit micro-set mask IoU | open |
| A17 | Aux semantic head structure | A single 1x1 `nn.Conv2d` from the fused-feature width (`ProtoFusion.out_channels`) to `nc` raw logits, evaluated at `F_proto`'s own resolution with no resampling, carrying the A30 prior-probability bias init (it is a dense sigmoid classifier over a mostly-background map — exactly A30's setting, unlike the tanh coefficient stems). `forward` returns `None` whenever `self.training` is False — keyed on module mode, never on grad mode — so the branch is provably absent at eval and export, and the fused-parameter gate has exactly one convolution to account for | R1 sec. 3.4.1 ("training-only branch") | Fused-model param gate proves removal; `tests/models/test_aux_semantic.py` pins the eval-mode `None`, the single-conv parameter count, and the prior init | active |
| A18 | Proto-generation stack internals | Three 3x3 ConvBNAct units at the fusion width, 2x nearest upsample, one 3x3 ConvBNAct, 1x1 convolution to K raw prototypes | YOLACT R16; R1 Eq. 9 | Param/FLOP gate | active |
| A19 | Rotated IoU loss variant | ProbIoU | R1 sec. 3.4.3 lineage; R17 | OBB-ablations trend (Table 11 ranking) | open |
| A20 | Angle branch structure **and hidden width** (R1 sec. 3.4.3 says only "separate branch ... to predict the orientation angle"; Fig. S2 draws one stem shape and no widths, and Table S11 reports whole-model sizes from which a branch width can only be inferred) | A third per-level stem beside the box and class stems on **each** head branch, shaped like them — two depthwise-separable units then a 1x1 — and emitting **one** scalar per location. Widened 2026-08-11 by [WP-062](https://borda.github.io/lucid-YOLO/work_packages/wp-062/): the hidden width is `max(16, channels // 2)` (`_angle_stem_width`), **not** A28's shared `max(16, channels // 3)`, which the Table S11 gate rejected. Both divisors were measured on all five scales as the ratio of the isolated angle-branch cost to the increment Table S11 implies over Table 7 once the 80-to-15 class saving is credited back — `// 3`: n 0.76, s 0.98, m 0.66, l 0.66, x 0.63; `// 2`: n 1.22, s 1.62, m 1.09, l 1.09, x 1.04. Table S11 rounds params to 0.1 M, so that implied increment carries +/-59% at n and +/-21% at s and neither scale can discriminate; at m and l (+/-8%) and x (+/-3.5%) it can, and it selects `// 2`. **Neither divisor is uniformly right** — `// 3` undershoots the three large scales, `// 2` overshoots s at 1.62 — so the published angle cost is not one fixed fraction of the level width, and this row stays `open`: a rule chosen where the evidence is sharp, not a claim of Table S11 parity | R1 sec. 3.4.3 ("separate branch"), R1 Fig. S2 (stem shape); the width itself: gap, inferred from R1 Table S11 | `test_param_flops.py::test_obb_vs_tableS11` at the table's 1024 px and 15 classes (params +/-3.5%, FLOPs +/-5%); `test_obb_head.py::test_angle_stem_parameter_count_exact` pins each level term by term, `::test_angle_branch_uses_wider_stem_than_box_and_class_stems` pins the split against being tidied away | open |
| A21 | DOTA crop overlap (R1 sec. 4.5.3 says only "overlapping 1024x1024 crops") | Overlap is a **parameter** of `tile_windows`, defaulting to the 200 px this row has recorded since the register was written, so the assumption still governs. Flagged 2026-08-11 by [WP-057](https://borda.github.io/lucid-YOLO/work_packages/wp-057/): 200 px is attested by **neither** cited source — R18 sec. 4 publishes its own protocol as 1024 px patches "with a stride set to 512", i.e. a 512 px overlap, and R13 (MMRotate, arXiv:2204.13317) fixes the long-edge-135 angle definition but names no crop overlap at all. The value is therefore a **sensitivity item for the OBB tier**: re-running the tier at overlap 512 against the default 200 is the check that settles it. Window placement is overlap-agnostic by construction — the last window of each axis sits flush against the far edge — so full coverage holds for any overlap in `[0, patch)` and the sweep costs only tiles, not code | R18 sec. 4 (1024 px patch, stride 512); R13 (angle definition only, no overlap); the 200 px value itself: gap | `tests/data/test_tiling.py::test_coverage_no_gaps` — no-gap coverage over sizes that divide evenly, leave a remainder or are shorter than the patch, including the A21 defaults on a DOTA-sized image; OBB-tier sensitivity run pending ([WP-063](https://borda.github.io/lucid-YOLO/work_packages/wp-063/)/088) | active |
| A22 | Scalar weight of R1 Eq. 15's angle term in the total objective (R1 states the term and its internal `lambda`, but never its weight against the box and classification terms) | **0.25**, revised 2026-08-12 by [WP-093](https://borda.github.io/lucid-YOLO/work_packages/wp-093/) from the 1.0 this project assumed. The inherited value does not merely underperform, it *destabilises*: on the oriented overfit slice at 100 epochs across five seeds, `angle_gain = 1.0` cleared the 0.9 floor on 1 of 5 with a run-to-run spread of 0.667 and a worst seed of 0.3077, while `0.25` cleared 4 of 5 with spread 0.096. Mean angular error on elongated targets halves, 25.47 to 11.85 degrees. The failure is specific and mechanical: `sin^2(2 d_theta)` is zero at **both** 0 and 90 degrees, so for an elongated box the term scores a quarter turn as perfect and only the rotated IoU term objects, and its gradient `2 sin(4 d_theta)` reverses at 45 degrees. R1 intends this to be harmless — "elongated boxes receive smaller omega_i and remain primarily constrained by the rotated IoU loss" — but at the paper's own `lambda = 3` a 2:1 box still carries `omega = 0.948`, a five percent reduction, so the stated intent is not delivered by the stated formula in that band. The remedy is this gain and **not** `lambda`, which R1 does state and ablates (Table 11: `lambda = 5` scores worse than omitting the term entirely). Dose-response is not monotone per seed — seed 3 is best at 1.0 and worst at 0.5 — which is why a gain was chosen on aggregate behaviour rather than by picking the best-looking seed | R1 Eq. 15 (the term and `lambda`); the weight itself: gap | `test_obb_training.py::test_angle_gain_default_registered_a22_value` pins the value, since every other test passes gains explicitly and the default was previously unread. Measured lead-side, 35 training runs over four paired campaigns, five seeds each: gains 1.0 / 0.5 / 0.25 / 0.0 cleared 1 / 4 / 4 / 5 of 5 with mean elongated angular error 25.47 / 19.09 / 11.85 / 12.49 degrees. Still open at the OBB-smoke and DOTA tiers, where aspect ratios reach 5:1 and 10:1 and `omega` genuinely falls (0.750, 0.555) — the regime this fixture's 2:1 targets do not cover | revised |
| A23 | Rotated decode normalization (R1 Eq. 13 makes the predicted angle the raw pre-activation, so the head can emit any real number at any magnitude, and R1 never says where that number is brought back into a range) | Normalization happens **after** the decode, not in the head: `decode_rboxes` ends in `rotated_geom.canonicalize`, returning the unique long-edge representative with `w >= h` and theta on `[-pi/4, 3*pi/4)` (R13's long-edge-135 definition, [WP-055](https://borda.github.io/lucid-YOLO/work_packages/wp-055/)'s conventions). Two consequences are deliberate. The **dense** `(B, A, 5)` output is canonicalized rather than only the ranked detections, so a caller that decodes without ranking — a loss, an assigner, an export graph — cannot reach an un-normalized angle by a shorter path. And because theta and theta + pi describe the same rectangle, the decode is continuous across that identification: two raw values a half turn apart yield the same box rather than two boxes | R13 (angle definition); the placement after the decode: gap | `test_obb_head.py::test_decode_canonicalizes_adversarial_raw_angles` over raw magnitudes far outside any range, `::test_decode_continuous_across_half_turn_identification`, `::test_angle_output_unsquashed_beyond_legacy_range` (100 radians out of the stem, evidence the Eq. 12 squashing is gone rather than merely unobserved) | active |
| A24 | Rotated evaluation IoU and its scoring protocol (R1 Tables 10-11 report rotated mAP50-95 on DOTA-v1.0 val and define neither the IoU, the matching rule, the recall interpolation, the detection cap nor the class-averaging convention) | Exact polygon intersection: Sutherland-Hodgman clipping of one quadrilateral against the other plus the shoelace area, vectorized in torch on `rboxes_to_polygons` corners (`eval/dota_eval.py`, [WP-063](https://borda.github.io/lucid-YOLO/work_packages/wp-063/)). Clipping is edge-inclusive, matching [WP-055](https://borda.github.io/lucid-YOLO/work_packages/wp-055/)'s `points_in_rboxes` (A25); a shared edge or corner bounds zero area and scores 0 either way. Working dtype follows the input — no float64, because evaluation runs on MPS, the constraint A41 records for ProbIoU. Conditioning is fixed structurally instead: IoU is translation-invariant, so each pair is re-centred on its own midpoint **before** its corners are expanded. The order is load-bearing and measured — worst error against a float64 shapely evaluation over 300 overlapping pairs is 1.4e-07 at every offset from 0 to 1e6 when re-centring precedes expansion, against 2.7e-05 at 1e4 and 1.4e-03 at 1e6 when it follows. Zero-area, negative-extent and reversed-winding boxes clamp to zero area and yield IoU 0 rather than NaN. Averaging follows R12's COCO conventions: ten thresholds 0.50 to 0.95 step 0.05, classes without ground truth excluded from the mean — refined to **non-difficult** ground truth, an all-difficult class having an empty recall denominator. Reported as `map`, `map_50`, `map_75`, `mar_300`, the first three sharing `coco_eval.py`'s names. The small/medium/large breakdown is deliberately absent: COCO's area ranges are an R12 constant with no oriented counterpart, and inventing one would be a threshold without a source | R18 sec. 4 and its devkit (difficult convention); R12 (threshold grid, zero-GT exclusion); R13 (long-edge convention); R31 (the oracle); the polygon kernel and the re-centring order: gap | `tests/eval/test_dota_eval.py::TestRotatedIou::test_vs_oracle` — 600 random pairs across the canonical angle range against a shapely oracle at 1e-4, half near-coincident so the near-parallel regime is reached; `::test_vs_oracle_edge_cases` covers 15 named pairs including both range bounds of the square fold, zero-area and degenerate lines; lead-side re-measurement with the oracle reading back the same float32 values the kernel receives, holding 1.4e-07 to 2.3e-07 at offsets 0, 1e3, 1e4 and 1e6. OBB-tier confirmation pending [WP-088](https://borda.github.io/lucid-YOLO/work_packages/wp-088/) | active |
| A25 | STAL/TAL containment for rotated GT (R1 sec. 3.3.3 states STAL generically over box dimensions and never restates it for oriented boxes) | Point-in-rotated-rect containment, edge-inclusive, inherited from `points_in_rboxes`; the STAL clamp applied to the rotated `(w, h)` at the same `s_min = 8`, `s_ref = 16` the axis-aligned path uses, R1 giving no separate oriented thresholds. **Revised 2026-08-11 ([WP-061](https://borda.github.io/lucid-YOLO/work_packages/wp-061/))** with two scoping facts the original wording left implicit and that are now load-bearing: the rotated box decides **candidacy only** — the IoU, the alignment metric `t = s^alpha · u^beta`, `target_boxes` and `align_weights` all keep running on the axis-aligned `gt_boxes`, so entry `(b, n)` of the two must describe the same object and the oriented path is **not** end-to-end until rotated IoU lands in the metric ([WP-062](https://borda.github.io/lucid-YOLO/work_packages/wp-062/)/088); and `gt_rboxes` is per call rather than per GT, so a batch is wholly oriented or wholly axis-aligned, an axis-aligned object inside an oriented batch encoding as `theta = 0` | R1 sec. 3.3.3 (generic formulation); the oriented reading and the carried-over thresholds: gap | `test_rotated_assign.py::test_tiny_rotated_gt` (a 6x6 GT at 45 deg: vanilla TAL 0 candidates, STAL 4), `::test_rotation_excludes_envelope_corners` (a 16x16 GT at 45 deg drops the four envelope corners the axis-aligned test keeps), `::test_axis_aligned_assignment_bit_identical`. Lead-side check across the edit, on a scene with anchors exactly on GT boundaries: 20 assignment tensors, 6144 elements, bit-identical | open |
| A28 | Detection-head stem structure and hidden width (Fig. S2 shows the stem pair, not widths) | Both box and class stems are **depthwise-separable** (two `DepthwiseConv+ConvBNAct` units + 1x1 output), shared hidden width `max(16, channels // 3)` (`_stem_width`); no `num_classes` floor ([WP-023](https://borda.github.io/lucid-YOLO/work_packages/wp-023/) iteration 1). Revised 2026-08-01 from the original full-conv box stem (`channels // 4`, two `3x3` convs) + class stem (`max(num_classes, channels // 2)`): the [WP-023](https://borda.github.io/lucid-YOLO/work_packages/wp-023/) param gate flagged the head as ~7-16x the reference budget (the box stem alone was ~3.7x the class stem despite emitting 4 vs `num_classes` channels, and the `num_classes` floor bloated the `n`/`s` head). The lightweight symmetric form lands all five scales within tolerance | R1 Fig. S2; YOLOv10/R6 lightweight-head lineage | Param/FLOP gate (Phase 2) — `test_det_vs_table7` | active |
| A29 | R1 Table 7 param/FLOP counting convention | GFLOPs = **2x** fvcore MACs (conventional one-multiply-plus-one-add FLOPs; raw MACs land ~48% low). **Params** count the full trained checkpoint (both dual-head branches); **FLOPs** count the deployed NMS-free inference model only (backbone+neck+one-to-one head — the one-to-many branch is training-only and never executed at E2E inference). Determined empirically by the [WP-023](https://borda.github.io/lucid-YOLO/work_packages/wp-023/) gate (trying raw-MAC and both-branch FLOPs is measurement, not iteration) | R6 (dual-assignment inference); R1 Table 7 | Param/FLOP gate (Phase 2) — `test_det_vs_table7` | active |
| A26 | Blueprint prescribes hand-annotated permissive fixture images; none exist | Test fixtures AND [DATA]-WP stand-ins are generated synthetically with fuse-augmentations (R21), seeded. Since WP-155–157 that package is also this project's augmentation engine (ADR-005), so this row no longer names a test-only dependency: one pin moves the fixtures and the training-time behaviour together, and a bump has to be read against both. The goldens keep the two apart by kind rather than by hope — the two generator-derived goldens, `fixture_checksums.json` and `data_checksums.json`, carry `freezable: false` and are never copied into a frozen set, while the pure-code goldens are ([WP-154c](https://borda.github.io/lucid-YOLO/work_packages/wp-154c/)). Fixtures are byte-identical per seed **on a given platform** (revised 2026-08-01: libm last-bit rounding differs across OS/architecture, so cross-platform reproducibility is asserted via structural metrics with tolerance in the golden harness, not byte hashes — observed macOS arm64 vs ubuntu x86_64 CI divergence); det/seg 16 scenes with boxes+polygons, OBB rotated scenes. Real COCO/DOTA remain required for tier runs (Phases 6–8); synthetic stand-ins never substitute for tier acceptance | R21 | `tests/fixtures/test_fixtures_load.py` (same-platform byte determinism); `goldens/fixture_checksums.json` (cross-platform structural) | active |
| A27 | MuSGD hybrid: momentum-state sharing and Nesterov placement unspecified | one momentum buffer per param; both branch updates derived from Nesterov-adjusted g + mu\*m; Muon branch per R7 Eq. 4 scaling | R1 3.3.1; R7 Eq. 4; R8 | [WP-033](https://borda.github.io/lucid-YOLO/work_packages/wp-033/) toy convergence golden; Ablations-tier MuSGD-vs-SGD trend | active |
| A30 | Head initialization unspecified (R1 silent on init) | Classification-output 1x1 conv bias initialized to -log((1-pi)/pi), pi=0.01 (RetinaNet prior-probability init). Without it the summed TAL-normalized BCE opens at ~1.8M nats and the first MuSGD step kills the network (observed on Det-smoke launch 2026-08-02: loss 6.2e5 -> exact-zero dead predictor in 3 steps, reproduced on CPU and MPS) | R24 sec. 5.1 | `test_head.py::test_cls_bias_prior_init`; Det-smoke stable-training criterion | active |
| A31 | From-scratch stabilization beyond init not restated | Global gradient-norm clipping at 10.0 via the trainer for tier/overfit runs; generic deep-learning practice, not lineage-specific. 30-step real-COCO check: descending loss, box predictions bounded within image scale. The warmup half of this gap landed with the A8 schedule (WP-072) | gap; standard practice | Det-smoke stable-training criterion | active |
| A33 | Batch transport precision unspecified (R1 silent; engineering-efficiency secondary goal) | Collated image batches cross the DataLoader worker boundary quantized to **uint8** (`round(x*255)`; restored `code/255` on the destination device at the consumer's precision) — post-augmentation pixels rounded to `1/255` steps, worst-case round-trip error `1/510`. Images originate as 8-bit JPEGs, so only sub-8-bit interpolation residue is lost; 4x smaller IPC keeps the in-flight worker queue inside containerized shm budgets (observed Colab sandbox ceiling). Per-sample pipeline numerics (and the data goldens, measured pre-collate) unchanged. **Opt-in exception, `--data.device_hsv` (CUDA with float32 or mixed precision, [WP-197](https://borda.github.io/lucid-YOLO/work_packages/wp-197/))**: workers defer the HSV colour transform, so deferred batches cross the worker boundary as float32 images with the drawn gains, at four times the default image IPC; the consumer applies the batched HSV, then quantizes onto the same `1/255` grid and restores its dtype. Default route, masks and validation keep uint8 transport | gap; 8-bit transport is standard CV-pipeline practice | `tests/data/test_coco.py` quantization round-trip bound; `tests/ptl/test_device_hsv.py` (deferred transport and consumer quantization); Det tier training criteria | active |
| A34 | Coefficient stem internals unspecified | Mirrors the class stem: two depthwise-separable units then 1x1; no prior-probability bias | R1 Fig. S2 head symmetry + R16 | Phase 7 param/FLOP gate vs Table S9 | active |
| A35 | Proto-fusion projection form and upsample mode unspecified | Bare 1x1 `nn.Conv2d` projections with no normalization or activation (the literal reading of "projection"); nearest-neighbour upsampling to `X_1`'s spatial size, matching the neck's existing top-down convention | R1 Eq. 8 + this repository's neck convention | Phase 7 param/FLOP gate vs Table S9 | active |
| A32 | Train-time resampling filter unspecified (R1 silent on interpolation) | The fused affine+letterbox warp (WP-070) resamples the training geometric base **once** with plain bilinear `grid_sample` — **no antialiasing** on the train-time downscale (`grid_sample` has no antialias mode; the prior two-step path antialiased its letterbox stage). Box/polygon arithmetic is unchanged (identical composed affine); only image pixels differ. Measured 4.4x geometric-path speedup (17.6 -> 4.0 ms/sample, 1280->640, single thread). **Revised 2026-09-03 by [WP-155](https://borda.github.io/lucid-YOLO/work_packages/wp-155/)**: the validation/eval letterbox no longer keeps `antialias=True` either. Delegating the resample to `fuse` replaced the antialiased `F.interpolate` plus `F.pad` with one `grid_sample` from the source canvas to the letterboxed one, whose antialias is off by default and whose opt-in prefilter is a Gaussian mipmap rather than torch's area-weighted kernel — measured not to engage at this producer's downscale, so enabling it recovers nothing. The assumption's *content* is unchanged and its *reach* widened: non-antialiased resampling is now the whole pipeline's filter rather than the train path's deviation from the eval path, and the two paths differ only in warp count. The eval-path cost is one measured number, `letterbox_image_mean` 0.5024 -> 0.4994 against a 0.002 tolerance, geometry bit-unchanged | gap; cv2-lineage resizes are conventionally non-antialiased | `goldens/data_checksums.json` (recomputed 2026-08-02); `goldens/aug_invariants.json` `letterbox_image_mean` (re-frozen 2026-09-03, WP-155b); Det-ablations trend vs Det-smoke | active |
| A36 | Auxiliary-loss numerics the papers leave open (A16 fixes the instance-mask objective's *form*; R1 sec. 3.4.1 names the aux branch's supervision as BCE+Dice without a formulation) | (i) The instance-mask box-area normalizer is clamped to a minimum of **1.0** (one pixel), so a degenerate or sub-pixel box yields a finite contribution instead of dividing by ~0. (ii) The auxiliary **soft Dice** is taken on sigmoid probabilities as `1 - (2*sum(p*t) + s) / (sum(p) + sum(t) + s)` with smoothing `s = 1.0` in **both** numerator and denominator, computed per `(B, C)` pair over the spatial dims only and then averaged over `B` and `C` — per-pair-then-mean stops one large class dominating the batch, and the smoothing makes an empty prediction against an empty target score exactly `0` rather than `0/0`. (iii) Both auxiliary terms carry **unit coefficients**: `total = bce + dice`; any overall gain belongs at the composition site, not inside the term | gap | `tests/losses/test_mask_loss.py`, `tests/losses/test_semantic_aux.py`; Phase 7 overfit micro-set mask-IoU check | active |
| A37 | Mask-decode numerics the papers leave open (Eq. 7 gives the assembly and A16 fixes the cropping principle and the coefficient activation; neither fixes how an assembled mask becomes binary, at which resolution, or how it leaves the letterboxed canvas) | (i) The binarization threshold is **0.5** on the sigmoid probabilities. (ii) Probabilities are **bilinearly upsampled** (`align_corners=False`) from the prototype grid (A15) to the letterboxed input resolution **before** cropping and thresholding — thresholding first would snap every mask boundary onto the prototype grid, and no later step recovers the lost sub-cell position. (iii) Decode crops to the **predicted** box using the same A11 half-open pixel-centre membership rule the training loss applies to the ground-truth box, so the model is scored under the geometry it was trained under. (iv) The inverse letterbox for a binary mask resamples **nearest-neighbour**, implemented by mapping each original-image pixel centre through the same `Letterbox.forward_affine` scale and padding whose inverse maps the boxes back (A10) — not through an independently derived ratio: a crop-then-resize would implicitly scale by the *rounded* content size over the original size rather than by `r`, leaving masks and boxes on subtly different grids with only the segm score moving | gap | `tests/eval/test_segment_decode.py` (boundary between prototype cells, crop window, threshold cut, letterbox round trip, box/mask frame agreement); Phase 7 segm mAP gate | active |
| A38 | Weight of each segmentation term in the total, and which branch's mask coefficients they supervise (R1 gives no gain for either term, and never says whether the training-only one-to-many branch's coefficients are supervised at all) | (i) `mask_gain = 2.5`, `semantic_gain = 0.5` on the two pre-gain terms. Both terms are per-pixel means and open at ~0.72-0.77 (mask: the BCE of an unbiased mask logit) and ~1.01-1.09 (semantic: its Dice sits at ~1 while the A30-biased classifier predicts ~0.01 everywhere) — measured at initialization at both the overfit scale (4 classes / 320 px, detection total 200.0) and the COCO scale (80 classes / 640 px, detection total 7214.4). At init the two terms are therefore 2-3 orders of magnitude below the detection total whatever gain they carry, so the balance point is the *converged* regime, where the detection total falls to single digits: the gains are set there, and are small integers rather than the detection terms' 7.5/6.0, which scale summed, unnormalized quantities and would be a category error to copy onto a per-pixel mean. `2.5` puts the instance-mask term — the objective the segmentation task exists for — clearly above the auxiliary one; `0.5` matches `cls_gain` and marks the training-only branch as a shaper of shared features rather than an objective in its own right. (ii) The mask term supervises **both** branches' coefficients, each against its own assignment, combined with the same `alpha` split the box terms use (R1 Eq. 2-3): the one-to-one coefficients are the ones the deployed segmentation decode reads, so supervising the one-to-many branch alone — the branch whose dense assignment supplies the positives — would ship a never-trained coefficient stem, while summing the two under one weight would pull the mask supervision against the progressive ramp instead of with it | gap | `scripts/overfit_micro.py --task seg` — **measured 2026-08-07: train mask IoU 0.8146 over 592 instances** (floor 0.7), decoded through the deployed one-to-one path, so the both-branch reading of (ii) is what that number rests on; `tests/ptl/test_seg_training.py` (terms live, gradients reach every branch, targets gathered by the assignment); Seg-smoke segm mAP | active |
| A39 | Fate of DOTA's per-object `difficult` flag (R18 defines the field — "1 for difficult, 0 for not difficult" — and neither it nor R1 says whether such instances are trained on or scored) | Not decided at load: `keep_difficult` is a **required** keyword argument of `dota_targets` / `load_dota_targets`, so every call site states its choice and none inherits a silent default. The flag is parsed and carried on every `DotaObject` either way, so both policies stay available downstream, and `check_data.py` counts every object line regardless (the published 188,282 total does not break difficult instances out). The tier-level policy — what the training path feeds and what the val protocol ignores — lands with [WP-088](https://borda.github.io/lucid-YOLO/work_packages/wp-088/) and [WP-063](https://borda.github.io/lucid-YOLO/work_packages/wp-063/) respectively and revises this row then | R18 annotation format | `tests/data/test_dota_parse.py` (flag round-trips; both policies produce their expected instance counts with every modality still aligned); tier policy pending [WP-063](https://borda.github.io/lucid-YOLO/work_packages/wp-063/)/088 | open |
| A40 | Training-time augmentation of rotated boxes (R1 lists the augmentations in Table S3 and R18 gives the *crop-time* rule for a partial object; neither says what a shear, a canvas edge or a mosaic quadrant does to an oriented annotation during training) | Three choices, all in `rotated_aug.py` ([WP-058](https://borda.github.io/lucid-YOLO/work_packages/wp-058/)). (i) **Warp**: a general affine sends a rectangle to a parallelogram, so a rotated box is expanded to its four corners, those corners go through the same matrix the image does, and a canonical long-edge box is re-fitted to them — R18's own prescription for its cropped parts ("described as an oriented bounding box with 4 vertices ... with a fitting method"). The fit is **exact** under a similarity (rotation, uniform scale, translation) and **approximate** under shear, with per-corner residual `h * sin(s/2)` for an edge-perpendicularity deviation `s`; flip and mosaic placement are isometries and stay exact. Every transform re-canonicalizes on the way out, which also closes the [WP-013](https://borda.github.io/lucid-YOLO/work_packages/wp-013/) defect where a mirrored `theta > pi/4` left the long-edge range. (ii) **Clipped instances are dropped, not flagged**: [WP-057](https://borda.github.io/lucid-YOLO/work_packages/wp-057/) follows R18 in flagging a below-threshold part *difficult* and keeping it, but that is dataset preparation and `Targets` carries no `difficult` field at training time, so augmentation drops instead — by exactly the axis-aligned path's existing rule (`min_box_size` on the clipped envelope's sides, `min_visibility` on clipped-over-pre-clip envelope area), so one policy governs both modalities. The clipper and the orientation-preserving re-fit are [WP-057](https://borda.github.io/lucid-YOLO/work_packages/wp-057/)'s, not a second implementation. (iii) **`boxes` is recomputed as the envelope of the rotated geometry** whenever `rboxes` are present, rather than warped independently: after a rotation the envelope of a warped envelope is the envelope of nothing real, and [WP-061](https://borda.github.io/lucid-YOLO/work_packages/wp-061/)'s assigner reads the two modalities as one instance. This narrows [WP-056](https://borda.github.io/lucid-YOLO/work_packages/wp-056/)'s load-time meaning (envelope of the hand-drawn quad) to "envelope of the fitted rectangle" from the first geometric transform onward | R18 sec. 4 (fitting method, and the 0.7 flag rule this diverges from); R1 Table S3 (the augmentation list); the drop/flag split itself: gap | `tests/data/test_rotated_aug.py` — `::test_roundtrip` (the DoD: a transform composed with its inverse returns the rotated boxes to tolerance), canonical output swept over the whole angle range through flip/affine/mosaic/mixup, similarity exact vs the derived shear bound, envelope/rotated-box consistency, and one-mask-drops-every-modality; OBB-tier confirmation pending [WP-088](https://borda.github.io/lucid-YOLO/work_packages/wp-088/) | active |
| A41 | ProbIoU's behaviour on a degenerate box (R17 derives `B_D` for non-singular Gaussians and never says what a zero-area box does; both of its denominators vanish there) | Box sides are floored at **`min_side = 1e-4`** in whatever coordinate frame the caller works in, before `w`, `h` become the variances `w^2/12`, `h^2/12` (`probiou.py`, [WP-059](https://borda.github.io/lucid-YOLO/work_packages/wp-059/)). The value is deliberately not load-bearing: `B_D` depends on the floor only **logarithmically**, one decade moving it by exactly `ln 10` (measured: 5.57, 7.87, 10.18, 12.48 for floors 1e-2 … 1e-5 against a fixed normal box), so the pair saturates at `L1 = 1` for any choice in the usable window and the choice is which order of magnitude, not which number. The window is bounded below at ~1e-10, where the product of four floored sides underflows float32 and a degenerate-against-degenerate pair returns `NaN` (measured: 1e-12 does), and above at ~1e-3, where the floor would start to move genuinely small boxes in a normalized frame; 1e-4 sits six decades above the first bound and one below the second, and is inert in a pixel frame either way. A clamped side takes **zero gradient**, so the loss abandons a collapsed box rather than chasing it. Two related numerical choices are documented in the module rather than here, being properties of the same equations rather than open gaps: the Hellinger loss's radicand is floored at 1e-20 inside its square root (whose derivative is infinite at zero, so a coincident pair would backpropagate `0 * inf = NaN`; the coincident case takes an exact-zero branch and never observes the floor), and the working precision follows the input dtype — float64 internals are unavailable because a regression loss runs on the training device and D12c puts training on MPS, which has no float64 | R17 (silent); the floor value itself: gap | `tests/losses/test_probiou.py` — zero-area, zero-width, zero-height, below-the-floor and negative-sided boxes stay finite forwards and backwards, against both a normal box and another degenerate one; `::test_clamped_sides_take_no_gradient`; the logarithmic floor dependence is what makes the value a non-item. OBB-tier confirmation pending [WP-088](https://borda.github.io/lucid-YOLO/work_packages/wp-088/) | active |
| A42 | The angle loss on a degenerate target (R1 Eq. 15 forms `omega` from `ln(w*/h*)`, which is undefined when either side is zero, and R1 never says what a collapsed target does) | Both target sides are floored at **`min_side = 1e-4`** before the ratio is taken (`angle_loss.py`, [WP-060](https://borda.github.io/lucid-YOLO/work_packages/wp-060/)) — numerically the same floor `probiou.py` uses, but **not** for A41's reason: A41 rests on `B_D` depending on the floor only logarithmically, and no part of that argument transfers to a log-ratio. The consequence is the honest reading of the formula rather than a choice R1 supports: a target collapsed in *one* dimension floors to an extreme ratio and its `omega` decays towards zero, so it is scored as elongated and left to the rotated IoU loss; a target collapsed in *both* dimensions floors to ratio 1 and receives `omega = 1`, i.e. **full** square-object weight, on a box with no orientation to supervise. That is the exposure this row records. It is bounded — such a target contributes at most `q_i / S` to a term whose own gain is A22's 1.0 — and it is unreachable through the shipped data path, where [WP-057](https://borda.github.io/lucid-YOLO/work_packages/wp-057/) drops or flags sub-pixel instances long before assignment. The floor is also what keeps the backward pass finite: unfloored, `ln(w/h)` goes to `inf` and its gradient to `NaN` | R1 (silent on degenerate targets); the floor value itself: gap | `test_angle_loss.py::test_degenerate_targets_stay_finite_forwards_and_backwards` covers one-sided and two-sided collapse, forwards and backwards; `::test_omega_profile` pins the undegenerate profile at 1.000, 0.948, 0.750, 0.555, 0.369 for 1:1 through 20:1. Whether a real OBB run ever presents one is unmeasured until [WP-088](https://borda.github.io/lucid-YOLO/work_packages/wp-088/) | active |
| A43 | Whether the STAL surrogate may leave the long-edge convention (R1 Eq. 4-6 clamps each dimension independently and says nothing about oriented boxes, so nothing in the paper forbids the short edge overtaking the long one) | `surrogate_rboxes` passes its clamped output through `canonicalize`, which swaps the pair and turns theta by pi/2 whenever `h` overtakes `w` -- a 10x4 box clamped to `s_ref = 16` becomes 16x10 turned a quarter, the same rectangle. Without it the surrogate would hand every downstream consumer an rbox violating the `w >= h` invariant `rotated_geom` documents as enforced. Free of consequence for candidacy, which is what the surrogate is for: containment is invariant under the swap, measured identical candidate sets from either representative | R1 Eq. 4-6 (dimension-wise clamp); the oriented re-canonicalization: gap | `test_rotated_assign.py::test_rotated_surrogate_clamps_per_edge`; lead-side check that a 10x4 box at `s_ref = 16` returns 16x10 at theta + pi/2, is its own canonical form, and yields the same candidates either way | active |
| A44 | How the angle composes with the ltrb regression (R1 gives Eq. 13 for the angle and the "separate branch" wording for where it comes from, but never says how that scalar and the four distances become one oriented box) | The minimal composition the "separate branch" wording implies: the ltrb distances are decoded by `decode_ltrb` **unchanged** — the same function, not a rotated variant of it — the resulting `xyxy` box supplies the centre and the two extents, and theta then rotates that box about its own centre (`decode_rboxes`, [WP-062](https://borda.github.io/lucid-YOLO/work_packages/wp-062/)). The angle branch therefore *adds* orientation to the existing box regression rather than reinterpreting it, and a predicted theta of 0 reproduces the axis-aligned decode exactly, which is the cheapest available check that the two paths have not drifted. Extents are passed through **unclamped**, matching the axis-aligned decode: an untrained head emitting `r < -l` yields a negative extent in both paths, and clamping only the oriented one would hide the divergence rather than fix it. The alternative readings this forecloses — distances measured in the rotated frame, or the anchor offset turned by theta before the box is formed — are not excluded by anything in R1 and would change what the box regression means; the oriented tier is what can distinguish them | R1 sec. 3.4.3, R1 Eq. 13 (both silent on the composition); the composition itself: gap | `test_obb_head.py::test_zero_angle_decode_reproduces_axis_aligned_box` against `decode_ltrb` on the same inputs; end-to-end through the deployed view in `::test_deployed_view_decodes_end_to_end_into_oriented_detections`. Whether it is R1's composition is unmeasurable without a trained oriented tier ([WP-088](https://borda.github.io/lucid-YOLO/work_packages/wp-088/)) | open |
| A45 | Oriented output tuple (the A9 analogue: R1 sec. 3.2.1 fixes neither layout, and the oriented one has no published form at all) | Width **7**, `[cx, cy, w, h, theta, score, class]` (`RBOX_DET_WIDTH`): the five-column long-edge rotated box of `rotated_geom` followed by the same score and class columns A9's axis-aligned tuple ends with, so the two layouts differ only in their box columns. Selection is **not** recomputed here — `o2o_rotated_topk` delegates to `o2o_topk_with_indices` and gathers theta by the anchor indices that helper reports, the rule A9's segmentation coefficients already follow. A second ranking would be free to disagree with the first and pair one anchor's orientation with another anchor's box: a detection with a correct centre, a correct score and a silently wrong heading | R1 sec. 3.2.1 (dense one-to-one output, no tuple layout); the layout itself: gap, by analogy with A9 | `test_obb_head.py::TestRotatedTopk::test_emits_oriented_detection_tuple`, `::test_pairs_each_angle_with_own_anchor` (the anti-drift gate), `::test_returns_every_anchor_when_fewer_than_k` (node ids moved under `TestRotatedTopk` by [WP-128](https://borda.github.io/lucid-YOLO/work_packages/wp-128/)) | open |
| A46 | Recall interpolation, on both instruments (R1 names no averaging convention, and the two candidate protocols disagree: R12's COCO AP samples 101 interpolated recall points, R18's devkit inherits VOC's all-point interpolation) | The COCO 101-point grid (`RECALL_POINTS = 101`), sampled so that "has recall reached `i/100`" is decided by the values and not by rounding. `dota_eval` decides it in **exact int64** (`hits*100 >= i*positives`); `coco_eval` supplies torchmetrics the **correctly rounded float64 hundredths** through its documented `rec_thresholds` constructor argument (WP-092), replacing the float32 `torch.linspace` default that overshoots `k/100` at **36** of the 101 indices. Raw pycocotools/faster-coco-eval's own float64 `numpy.linspace` forfeits 10 (indices 35, 41, 47, 57, 69, 70, 82, 83, 94, 95); both lucid-yolo instruments now forfeit **none** and agree exactly at every boundary either can reach. A forfeited boundary cost `1/101` of that class's AP, always downward; exposure was ordinary rather than exotic, since a class with 5, 10, 20, 25, 50 or 100 ground truths lands on a grid point at every attainable recall, 20-36% of them forfeited. The two routes are exact for different reasons — int64 for any denominator, float64 because correctly rounded division puts equal rationals on the same double while distinct ones stay at least `1/(100Q)` apart, exact to `Q ~ 4.5e13` — so the agreement is a measured property, not a shared code path. Only the `map` family was affected; average recall never enters the grid. The oriented number remains non-comparable to published DOTA leaderboard figures, which use the devkit's VOC-style averaging; the tier report must state which protocol it quotes | R12 (the 101-point grid); R18 devkit (the VOC-style alternative this departs from); the exact-sampling choice on both instruments: gap | `test_dota_eval.py::TestRecallGridBoundary::test_boundary_recall_sampled_exactly` — seven exact-boundary cases against hand-derived `(k+1)/101`, three at indices float32 gets wrong; `::test_agrees_with_axis_aligned_instrument_at_float32_boundary` pins both instruments at `66/101` where they used to differ 66-versus-65. `test_coco_eval.py::TestRecallGridBoundary::test_boundary_recall_sampled_exactly` — eight cases, five at forfeited indices, confirmed lead-side to fail against the unwired code with deltas of exactly `1/101` while the three control boundaries float32 gets right pass throughout; `::test_torchmetrics_default_grid_overshoots_at_thirty_six_boundaries` pins the dependency defect so its removal is noticed; `::test_guard_fires_when_metric_stops_honouring_rec_thresholds` pins the fail-loud guard. Lead-side reproduction of the 36/10/0 forfeit counts and the exact ten-index float64 miss set, and of `rec_thresholds` being a documented parameter forwarded verbatim as float64 | validated |
| A47 | Detections scored per image (R1 states no cap; R12's COCO protocol fixes maxDets at 100, which is below what this architecture emits) | `MAX_DETECTIONS = 300`, applied per image across all classes inside the accumulator. 300 is not borrowed — it is what the shipped oriented path produces (`o2o_rotated_topk` default `k`, the A45 tuple, A9's cap), so a 100 cap would discard two thirds of the model's output and report the remainder as its recall. Reported as `mar_300` rather than `mar_100`, the cap differing so the name does too. The exposure this records: a rotated mAP quoted against COCO's 100-detection convention is not this number | R12 (the 100-detection convention this departs from); A9 (the 300-row output); the value itself: gap, inherited from the architecture rather than from a protocol | `test_dota_eval.py::TestProtocolConstants::test_detection_cap_300_not_cocos_100` asserts the constant equals the head's own output width, so the two cannot drift; `TestDetectionCap::test_detections_beyond_cap_dropped` and `::test_detections_within_cap_survive` place the same correct detection at rank 350 and rank 299; lead-side reproduction of both | open |
| A48 | Which ground truth a detection matches when a difficult instance also overlaps it (R18's devkit settles that a difficult match is discarded, but its VOC-derived code takes the argmax over **all** ground truths before inspecting the flag, so it never states what happens when a difficult and a matchable non-difficult instance both qualify) | A non-difficult match takes precedence: the detection claims the best still-unmatched non-difficult ground truth at or above the threshold; only failing that does an overlap with any difficult instance discard it; failing both it is a false positive. Difficult instances are never consumed, so several detections may be discarded against one — the devkit's behaviour. The divergence from literal argmax is narrow but real: under the devkit a difficult instance overlapping a detection more than a matchable non-difficult one shadows it and costs a true positive, and this ordering declines to. Load-bearing rather than decorative, because `tile_windows` flags every instance clipped below 70% visible as difficult (A39), so real DOTA val tiles carry them whether or not the label file did — which also makes `keep_difficult=True` a requirement of the evaluation loader, filtering at load turning every ignorable detection into a false positive | R18 sec. 4 and its devkit (discard rule, 0.7 flag); the precedence ordering itself: gap | `test_dota_eval.py::TestDifficultGroundTruths::test_detection_on_difficult_target_discarded` contrasts the flag being present against the instance being deleted; `::test_difficult_targets_leave_recall_denominator`; `::test_one_difficult_target_may_absorb_several_detections`; `::test_absent_difficult_key_reads_as_no_instance_difficult`; lead-side check that flagging scores 1.0 where deleting scores 0.5 on identical predictions | open |
| A49 | Which of R17's two ProbIoU losses the oriented path uses, and at what gain (R17 proposes both the bounded Hellinger form and the unbounded Bhattacharyya form, and recommends starting on the latter and switching; R1 names neither, and settles no gain for an oriented box term) | The bounded Hellinger form `L1 = H_D = 1 - ProbIoU`, at A13's `box_gain = 7.5` carried over unchanged. It occupies the slot the Complete-IoU loss vacated and shares its `[0, 1]` range, so the existing gain transfers without inventing a magnitude, which the unbounded form's distance-dependent scale would have required. R17's argument for `B_D` is its non-vanishing far field, and that far field is largely unreachable here: an anchor is a candidate only if its centre lies inside the rotated ground truth (A25), and a positive's weight is a normalized `s**alpha * u**beta` already near zero for a badly placed box. R17's start-on-`L2`-then-switch schedule would add both a switch epoch and a second gain, neither of which any source states. The form is a constructor argument rather than a hard-coded call, so the alternative is one config key away | R17 (both forms and the switching schedule); R1 (silent on which, and on the gain); the selection and the gain transfer: gap | `tests/ptl/test_obb_training.py::test_each_oriented_gain_enters_total_at_weight` pins the gain's leverage on the total. Measured lead-side on the [WP-088](https://borda.github.io/lucid-YOLO/work_packages/wp-088/) overfit slice at seed 0, 100 epochs, recipe otherwise untouched: the registered Hellinger form scores 0.8959 rotated mAP50 against 0.3914 for `bhattacharyya`, a deficit of 0.5046. The alternative is not marginally worse but far worse, so this is a measured choice rather than an argued one | validated |
| A50 | Whether the L1 box term survives beside a rotated IoU term, and against which target (R1 sec. 3.4.3 gives the oriented objective's terms but does not say whether the axis-aligned L1 term is kept, dropped, or retargeted) | Kept, with its target moved to the rotated box's own `(cx, cy, w, h)`. Keeping it unchanged is not available: its original target is `assign.target_boxes`, the axis-aligned *envelope* of the ground truth, while A44's composition reads the decoded `xyxy` as the oriented box's own extents before rotating about its centre. For a 40x10 box at 45 degrees the envelope is roughly 35x35, so the two terms would pull the same `w` and `h` towards targets that disagree by the whole of the rotation; they coincide only at `theta = 0`. Dropping it is worse still: it would leave the extents supervised by ProbIoU alone, which floors a collapsed side at `min_side` with exactly zero gradient there (A41), and that is the state an untrained head starts in, since raw ltrb distances routinely decode to `r < -l`. The L1 term is what pulls a collapsed extent back out. `theta` is deliberately not an L1 column: it is not a stride-unit quantity, and R1 Eq. 15 already owns the angular residual | R1 sec. 3.4.3 (the term list); A41 (the ProbIoU gradient floor this depends on); A44 (the decode frame that makes the original target wrong); the retargeting: gap | `tests/ptl/test_obb_training.py::test_each_oriented_gain_enters_total_at_weight` pins the term's leverage. Measured lead-side on the [WP-088](https://borda.github.io/lucid-YOLO/work_packages/wp-088/) overfit slice at seed 0, 100 epochs: retargeting scores 0.8959 rotated mAP50 against 0.1847 with the term dropped, a deficit of 0.7112 — the largest single effect measured on this path, and direct confirmation of the collapsed-extent mechanism argued above | validated |
| A51 | Where R18's per-instance difficult flag lives between the loader and the metric (R18 defines the flag and its scoring rule, but no source states how it should be carried through a training pipeline that also serves non-oriented datasets) | A `difficult` channel on `Targets`, on the shared instance axis, defaulting to "no instance is difficult" so every non-oriented dataset means what it already meant. This is the channel A48's requirement needs rather than a second policy: A48 makes `keep_difficult=True` a requirement of the evaluation loader, and without somewhere to put the flag, keeping the instance and dropping it are identical downstream. The flag survives `filter`, `concat`, `clone`, the letterbox transform (the only geometric transform on the evaluation path) and the loader's packed transport. Train-time multi-image assemblies rebuild their instance axis and are documented at each site; the flag is not consumed in training at all, since A39 leaves that policy open | R18 sec. 4 and its devkit (the flag and its scoring rule); A48 (the loader requirement this serves); the transport channel: gap | `tests/ptl/test_obb_training.py::test_difficult_flags_reach_metric_through_loader` drives the flag through the real loader into the metric; `::test_rotated_targets_survive_loader_transport_into_metric` covers the geometry it rides with | active |
| A52 | Whether an object-free crop is kept as a training image (R18 sec. 4 defines the crop geometry and the difficult rule for parts of objects, but neither it nor R1 says what becomes of a window that contains no annotated object at all — and at 1024 px over a DOTA image, many do) | Kept, and written to the layout like any other tile. The one-to-one branch is trained to fire once per object and not otherwise, so object-free crops are exactly the negative evidence for the second half of that; dropping them would train the suppression-free path only on scenes where something is present. The cost is disk, which is a build artifact's cheapest resource. `build_dota_tiles.py --drop-empty-tiles` selects the other reading without re-deriving anything, and the build's report prints the count either way so the choice is never silent | R18 sec. 4 (the crop protocol that produces them); the retention policy: gap | `tests/data/test_build_dota_tiles.py::test_every_window_of_every_source_image_written` and `::test_dropping_empty_tiles_keeps_only_annotated_windows` pin both readings against the window count | active |
| A53 | How R18's per-instance difficult flag and a tile's window provenance survive a COCO container (COCO's schema has no field for either, and A51 fixes only where the flag lives *after* the loader has it) | Non-schema keys, written by the build and read back by name: `difficult` and `visible_fraction` per annotation, `source_image` and `window` per image. A standard COCO reader ignores all four and sees an ordinary detection set, which is the property that makes the layout usable by anything else; this project's reader forwards `difficult` onto the A51 channel and defaults it to `False` when absent, so an ordinary COCO file is unaffected. `visible_fraction` rides along because the fitted box's area is not the clipped area, so R18's `U_i` is otherwise unrecoverable and a threshold sweep would have to re-tile. The window keys exist because whole-image evaluation needs to know which source image a tile came from and where it sat, and recovering that by parsing file names would make the naming a load-bearing interface | R18 sec. 4 (the flag and `U_i`); R12 (the COCO container being extended); the encoding: gap | `tests/data/test_coco.py::test_difficult_key_reaches_targets_channel` on both readings and `::test_ordinary_coco_file_reads_as_nothing_difficult`; `tests/data/test_build_dota_tiles.py::test_difficult_flag_survives_round_trip` and `::test_image_records_carry_window_provenance` | active |
| A54 | Whether a YOLO image with no label file has no objects or a broken export (the format carries no image manifest — no COCO `images` array — so an absent file and a half-finished download are indistinguishable on the format's own terms) | A missing label file raises, naming the image and the path searched; an **empty** file reads as a background image, because writing zero bytes is a positive statement that the image holds none. `allow_missing_labels=True` opts into the permissive reading for a dataset that genuinely omits them | R32 (every image in the read export has a label file; none is empty) | `tests/data/test_yolo_parse.py` — a missing file raises naming both, an empty one yields a zero-instance `Targets` | active |
| A55 | The legal range of a YOLO label row's coordinate fields, and what to do outside it (no allowlisted source states the range or the failure mode) | Fields must lie in `[0, 1]`, compared **exactly** with no epsilon; outside it the file was never normalized — typically written in pixels — and is rejected rather than clamped, since clamping turns such a file into a wall of degenerate edge boxes it would then train on. The *pixel* box derived from a valid row is still clamped to the image bounds, as the COCO reader clamps `xywh` | R32 (fields lie in `(0, 1]`; the export writes clipped corners as exactly `0` and `1`, so `1.0000001` is out of spec rather than rounding) | `tests/data/test_yolo_parse.py` — an out-of-range field raises; a valid row's pixel box is canvas-clamped | active |
| A56 | The oriented YOLO row's field count (R18's own label lines carry a trailing `difficult` flag; the normalized variant has no published spelling for one) | Exactly nine whitespace-separated fields: class index, then four normalized `(x, y)` corners. A ten-field row is **rejected** naming file and line rather than having the extra column assumed away | R18 (the quadrilateral convention and the flag it appends); no source reachable for the normalized ten-field form | `tests/data/test_yolo_parse.py` — a ten-field row raises | active |
| A57 | The form of `names` in `data.yaml` (a list and a mapping are both plausible and the papers say nothing) | A list in index order, or a mapping whose keys are exactly `0..K-1`; any other mapping is rejected, because it leaves the class order unstated and a silently reordered class list is a wrong dataset that trains. `nc`, when present, must equal `len(names)` | R32 (`names` written as an index-ordered list beside `nc: 3`) | `tests/data/test_yolo_parse.py` — list and contiguous mapping accepted, sparse mapping and `nc` mismatch rejected | active |
| A58 | How a `data.yaml` split entry resolves to a directory (the entry is a relative path with no stated base) | Against the `data.yaml`'s own directory: the literal reading first, then the same path with its leading `..` components dropped. Neither candidate existing raises, listing both. A `path:` key is **not** honoured — it is not present in any export reachable from the allowlist, and inventing its semantics is the guess this package's clean-room clause exists to prevent | R32 (writes `train: ../train/images` for a tree at `<root>/train/images`, and carries no `path:` key) | `tests/data/test_yolo_parse.py` — both spellings resolve, an unresolvable entry raises listing every path tried | active |
| A59 | How detections from overlapping tiles are merged onto one whole source image (R18 sec. 4 defines the crop protocol and says nothing about recombining detections, and R1's one-to-one branch has no suppression stage a seam duplicate could be removed by) | **Core ownership.** Each tile owns the source-coordinate rectangle whose axis boundaries sit at the midpoints of the overlap bands with its neighbours, outer bounds infinite; a detection survives iff its source-coordinate centre lies in its own tile's core, and whole-image ground truth is reconstructed from the tiles' own annotations by the identical filter. The cores partition the plane, so an object contributes at most one detection however many tiles saw it. This is ownership, not suppression: nothing is compared against another detection or ranked by confidence, and the assignment is fixed by the tiling geometry before the model runs — which is why it composes with a suppression-free path where a confidence-ranked IoU dedup would reintroduce NMS at the seam. With overlap `v` a core ends `v/2` inside its own window, so every object of circumradius at most `v/2` is wholly visible to the tile owning its centre, which is what R18's stride was chosen to give. Costs, all stated in the module: whole-image recall is the owner's recall and not the union, so the merge can only lower a per-tile number; objects wider than the overlap are unrecoverable under any tile-local rule; boundary jitter can double a detection. One residual flattering effect — a clipped difficult copy that survives ownership absorbs a detection the true annotation set would score as a false positive, measured at 0.165 of `map_50` on the unit fixture and bounded to the same wider-than-overlap class | R18 sec. 4 (the crop protocol and the 0.7 rule the reconstruction rests on); R1 sec. 3.2.1 (the suppression-free branch); the rule itself: gap | `tests/eval/test_tile_merge.py::TestSeamDuplicate`, `::TestSingleTileEquality`, `::TestOwnershipCost`, `::TestGroundTruthReconstruction` | active |
| A60 | How many detections are scored per evaluation unit when the unit is a merged whole image (A47 fixes 300 because that is what `o2o_rotated_topk` emits per forward pass; a merged source image is many forward passes) | The whole-image path scores with the cap **off**. A47's 300 still applies where it was put — at emission, per tile — so nothing is uncapped that the head did not already cap, while re-capping the merged image at 300 would measure a truncation rather than a model, a dense DOTA image carrying far more than 300 instances. The per-tile path is unchanged and bit-identical at the default. Exposure: the metric key stays `mar_300`, A47's name, whatever cap produced it, so a report quoting a whole-image figure must name the cap it used — the report payload's `whole_image.max_detections` field does. First exercised at [WP-111](https://borda.github.io/lucid-YOLO/work_packages/wp-111/): the OBB-smoke checkpoint's whole-image mAR_300 came out 0.0118 above its per-tile figure on real DOTA-v1.0 val, consistent with the dense-image rationale above, though that run does not isolate the lifted cap from [WP-107](https://borda.github.io/lucid-YOLO/work_packages/wp-107/)'s core-ownership merge running alongside it | A47 (the per-tile cap this departs from); R12 (COCO's `maxDets`); the lifting: gap | `tests/eval/test_tile_merge.py::TestDetectionCap`; `tests/eval/test_eval_obb.py` — the report names both figures rather than quoting one; [WP-111](https://borda.github.io/lucid-YOLO/work_packages/wp-111/)'s real-data run, log-only, not test-pinned | active |
| A61 | The IoU threshold of **rotated** non-maximum suppression (R1 states no suppression parameter at all — the one-to-one branch's freedom from NMS is its claim, not a setting; R18's devkit scores detections it is handed and settles nothing on the decode side; R13 fixes the angle convention and no suppression rule, so the oriented comparison column has a knob no allowlisted source turns) | `ROTATED_NMS_IOU_THRESHOLD = 0.7`, read by `RotatedNMSDecoder` from the one constant rather than restated at the constructor. The carry-over from the axis-aligned default (A62) **is** the argument: the oriented `nms` column exists to be compared against the suppression-free one, and turning the overlap *measure* and the threshold in the same step would leave a difference between the columns attributable to neither. It deliberately does **not** rest on the reading that looks obvious and is false — that the two measures are ordered. Rotated IoU is usually the smaller (mean 0.047 against 0.080 over 3000 random canonical pairs) but *exceeds* envelope IoU whenever both boxes lie along a shared diagonal, measured at 138 of those 3000 pairs by up to +0.146; so 0.7 cannot be defended as "suppresses no more than the axis-aligned path did", only as holding the knob still while the measure changes. Exposure: 0.7 **under-suppresses elongated objects**, which is DOTA's characteristic shape — two detections of one 10:1 object whose headings disagree by 5 degrees score 0.643 and both survive (10:1 at 2/5/10/15 degrees: 0.839/0.643/0.395/0.239; 5:1: 0.914/0.802/0.645/0.516; 2:1: 0.958/0.903/0.825/0.762). That direction is the one this project prefers, a duplicate being inspectable where an over-suppressed object is simply absent, and the value is a constructor argument | R1 (states no suppression parameter); R18 devkit (evaluates, does not decode); R13 (angle convention only); the value itself: gap | `tests/decode/test_rotated_nms.py::TestRegisteredThreshold` pins the decoder's default to the constant so register and code cannot drift; `::TestRotatedSuppression::test_threshold_decides_partial_overlap` places a pair at 0.667 between thresholds 0.7 and 0.5 and asserts each answer, so the parameter is consulted rather than effectively fixed at an extreme. Revised by the first OBB-tier run reporting both columns: the mAP delta between them at 0.7 is the measurement | active |
| A62 | The IoU threshold of **axis-aligned** class-wise NMS (R1's dense branch is a training-time auxiliary and no allowlisted source states a decode-side suppression threshold for it; the value has shipped since WP-042 as `NMSDecoder`'s default with no row of its own, which [WP-091b](https://borda.github.io/lucid-YOLO/work_packages/wp-091b/) found when A61 went looking for a lineage to inherit) | `0.7`, unchanged — registered here as the convention it has always been rather than revised in place, because every axis-aligned "mAP (non-E2E)" number this project has published was measured at it and re-picking the value now would silently move a comparison column that already has committed figures beside it. It is the widely used detection-NMS convention and nothing more; no source on the allowlist is cited for it, and A61 inherits it as a convention rather than as a citation | R1 (no decode-side suppression parameter); the value itself: gap | `tests/decode/test_nms_path.py` — the existing decoder suite exercises the default; a revision is a golden-moving change and would ship as the next 0.MINOR | active |
| A63 | Which reader a dataset root calls for when its own directory names satisfy **both** conventions (no source states a precedence and none can — the two layouts come from different publishers, R12's COCO container and R32's YOLO export, with no shared precedence statement between them) | **Refused, not resolved.** Both candidate tables are evaluated in full and the verdict is a pair of booleans, so the answer cannot depend on which was consulted first; satisfying both raises naming both matches and the two spellings of the explicit `layout` override, and satisfying neither raises naming both conventions in words plus every path tried. Precedence is taken *within* a candidate table, where the loser is another spelling of one reader and at worst the wrong directory of the same dataset, and never *between* the tables, where the loser is a different label space, a different image set and a different parser — a silent pick there trains on annotations nobody named, and reports a plausible number while doing it. The explicit override exists because two states are otherwise unreachable: an ambiguous root on a read-only mount, and a YOLO root whose `data.yaml` points its splits outside the convention table, which `from_root` resolves and no probe can see | gap; R12 and R32 are independent publications with no shared precedence statement | `tests/ptl/test_datamodule.py::TestLayoutDispatch` — a both-root raises naming both overrides, a neither-root raises naming both conventions and every path tried, an explicit `layout` settles either | active |
| A64 | `HorizontalFlip`'s left/right keypoint identity swap under a mirror (Phase 12's keypoint container is deliberately K-generic — no source states which index swaps with which, and none could: that pairing is anatomical, not geometric, and belongs to whichever dataset supplies K points, not to the mirror transform itself) | The swap is a caller-supplied `keypoint_flip_pairs` constructor argument, never a constant inside `HorizontalFlip`; `None` mirrors x-coordinates only, which is correct for any K-point task with no left/right symmetry. For the COCO human-pose demo dataset (R12's 17-point person category, ordered nose, left/right eye, left/right ear, left/right shoulder, left/right elbow, left/right wrist, left/right hip, left/right knee, left/right ankle), the pairing is indices `(1,2),(3,4),(5,6),(7,8),(9,10),(11,12),(13,14),(15,16)`, index `0` (nose) fixed — recorded here as the value to pass, not yet wired anywhere: [WP-121](https://borda.github.io/lucid-YOLO/work_packages/wp-121/) is the reader only (COCO `keypoints` field into the `Targets` channel, no datamodule change), and no roadmap row currently owns constructing `HorizontalFlip(keypoint_flip_pairs=...)` for a pose task. That wiring lands whenever a pose task first reaches `DetectionDataModule` -- [WP-122](https://borda.github.io/lucid-YOLO/work_packages/wp-122/) is the head/decode module only, not datamodule wiring either, so this still names no row -- not stated as settled here. **Resolved by [WP-132](https://borda.github.io/lucid-YOLO/work_packages/wp-132/), and very nearly missed.** The row was first drafted here as still-deferred, on the reading that [WP-132](https://borda.github.io/lucid-YOLO/work_packages/wp-132/)'s overfit gate ran with augmentation off -- which was wrong, and wrong in the direction that hides: `DetectionDataModule.__getitem__` applies `self._flip` unconditionally at `_FLIP_PROB = 0.5`, so the gate mirrors half its samples. `HorizontalFlip` was being constructed with no `keypoint_flip_pairs` at all, which mirrors coordinates without swapping identities: every `flank_left` in a mirrored sample was supervised toward the physical point `flank_right` now occupies. That is not a crash, a shape error, or anything a gate reports -- it trains, and it trains a left/right-confused head. It surfaced only because the affine's own hard error stopped the run first and forced a second reading of the whole train path. The pairing is therefore now **dataset-supplied and threaded through the datamodule** rather than left as a value nobody passes: the reader carries its annotation schema's own permutation, R21's 7-point `SYMBOL_KEYPOINT_FLIP_IDX = (0, 1, 2, 4, 3, 6, 5)` for the symbol family (`flank_left`/`flank_right` and `base_left`/`base_right` swap; `center`, `apex`, `tail` lie on the mirror axis and map to themselves), R12's 17-point list above for COCO human pose. A `None` pairing still means "mirror coordinates only" and is still correct for a K-point task with no left/right symmetry; what is no longer possible is a *symmetric* schema silently receiving it | R12 (COCO keypoint category and point order); R21 (the symbol schema's own flip permutation) | `tests/data/test_photometric.py::TestHorizontalFlip::test_keypoints_mirror_without_identity_swap`, `::test_keypoints_mirror_and_swap_supplied_pairs`, `::test_out_of_range_keypoint_pair_raises` pin the mechanism; `tests/ptl/test_keypoint_training.py` pins that the datamodule actually passes the schema's pairing rather than defaulting it away -- the assertion whose absence let this sit unnoticed | active |
| A65 | What guarantees the RLE per-axis uncertainty (`sigma`) stays positive between the keypoint head's raw output and the flow's density (R14's RealNVP-plus-Laplace-base formulation needs a strictly positive scale; nothing in R1 addresses a keypoint task at all -- R14's own `docs/PROVENANCE.md` role is "future pose milestone only" -- so no allowlisted source hands this project a value or a formula to carry over the way A20's angle range did from R1 Eq. 13 \[**struck 2026-09-17, D22**: R1 sec. 3.4.2 does address it, and states the same bound directly -- "a parallel sigma branch predicts per-axis uncertainty sigma = (sigma_x, sigma_y) in (0, 1)^2 for each joint" -- so the resolution below is R1's as well as R14's; the section was unread when this row was written\]) | **Resolved by [WP-123](https://borda.github.io/lucid-YOLO/work_packages/wp-123/), from R14 directly rather than assumed.** R14 sec. 3.3 states its own answer verbatim: "the deviation sigma_hat_i is predicted with a sigmoid function. Hence we have sigma_hat_i in (0, 1)." `RLELoss.forward` (`losses/rle_loss.py`) applies `torch.sigmoid` to the head's raw sigma output at the point that consumes it, matching A23's precedent of normalizing a raw head output where it is used rather than where it is emitted; `DualDetectionHead`'s keypoint stem ([WP-122](https://borda.github.io/lucid-YOLO/work_packages/wp-122/)) and `decode_keypoints` still emit and pass through the value completely raw, exactly as this row originally deferred | R14 sec. 3.3 (states the sigmoid explicitly); R1 sec. 3.4.2 (states the `(0, 1)^2` bound, read 2026-09-17) | `tests/losses/test_rle_loss.py::TestSigmaRange` -- sigma stays in the sigmoid's open range at both saturation extremes; `tests/models/test_keypoint_head.py` -- the head's own raw output is still unactivated, confirming the split held | active |
| A66 | Which COCO keypoint visibility values (`v`) contribute to the RLE loss (R12 defines the flag's meaning -- `v=0` no annotation, `v=1` labeled but occluded, `v=2` labeled and visible -- but states no training policy for which values a loss should read; R14 is a generic pose-regression method with no COCO-specific visibility handling of its own; R1 sec. 3.4.2, read 2026-09-17, names "visibility scores" as a head output and states no training policy for `v` either) | `v == 0` is excluded unconditionally -- its coordinate is not ground truth, and training against it would optimize toward a meaningless target. `v == 1` and `v == 2` both contribute equally: occlusion is exactly the case a regression model must learn to infer from context, not a reason to withhold supervision, and COCO's own OKS evaluation protocol (R12) already scores against `v >= 1` regardless of visibility -- training against the same set the metric scores against is the consistent reading, not a narrower one this project invented | R12 (visibility semantics); the training-time policy itself: gap | `tests/losses/test_rle_loss.py::TestVisibilityMasking` -- `v=0` is excluded (a corrupted target changes nothing), `v=1` and `v=2` are proven equivalent, and an all-unlabeled batch returns a finite zero rather than `NaN` | active |
| A67 | Which per-point OKS sigmas score a **synthetic symbol** keypoint run (R12's 17-value table is the only sigma vector any allowlisted source states, and it is derived from measured *annotator* standard deviations on human anatomy -- a quantity that does not exist for R21's `SymbolShape` landmarks, which are placed analytically at three-quarters of the way from each outline's area centroid to a named vertex, with no annotator and therefore no variance to measure) | A **uniform** sigma across all K points, at R12's own median value `0.072` (`SYMBOL_KEYPOINT_OKS_SIGMA`). Uniform is not a simplification but the only honest reading: a per-point table encodes which landmarks humans localize less reliably, and for analytically-placed points that structure is absent, so any non-uniform vector this project wrote would fabricate it. The *value* is close to arbitrary by construction -- a uniform sigma is a monotone rescaling of OKS, so it moves every score together and changes no ordering, meaning it sets only where an acceptance floor sits and never which run wins. R12's median is chosen over a round number so the resulting figure stays on a scale comparable to a real COCO pose run, keeping the [WP-132](https://borda.github.io/lucid-YOLO/work_packages/wp-132/) gate floor interpretable rather than free-floating. Exposure: an OKS quoted for a symbol run is **not** comparable to a published COCO pose OKS despite sharing a scale -- different sigma vector, different point count, synthetic data (A26) What the uniform value costs is now measured rather than assumed: with the prediction set to the ground truth the gate's scorer returns exactly `1.0`, and displacing every point of every instance uniformly walks it down `+1 px -> 0.934`, `+3 px -> 0.269`, `+8 px -> 0.000`. At `0.072` on symbols a few dozen pixels across, OKS AP is a cliff, which is why the `kp` gate's frozen figure (0.3357) is an order below its sibling gates' while describing a loop that memorized the slice just as they did. The rescaling is monotone, so this fixes only where the floor sits -- but it fixes it steeply, and a future sigma change moves the frozen value a long way. | R12 (the 17-value table and its annotator-variance derivation); the uniform choice and the value: gap | `scripts/_tests/test_overfit_micro.py` -- the `kp` gate's frozen train OKS; a per-point table is never constructed, so drift here is a code change rather than a silent retune | active |
| A68 | The gain the RLE keypoint term enters the detection total at (R14 states the loss and its flow, never a multi-task weight -- it trains a pose-only network; R1 addresses no keypoint task at all, so neither source hands this project the balance the way R1 Eq. 15 handed A22 its `0.25` angle gain \[**struck 2026-09-17, D22**: R1 sec. 3.4.2 addresses it and R1 Table 9 hands over a balance -- `wOKS = 24, wRLE = 1` is its best split on YOLO26s (63.0 mAP), with RLE alone at `(0, 1)` 61.9 and `(0, 2)` 62.4. The unity below coincides with `wRLE = 1` in value, not in frame: Table 9's weights sit beside R1 Table S5's box/cls gains (9.83 / 0.65 at `s`), this project's beside 7.5 / 0.5 (A13), so the two are comparable only up to that ratio, and no run here has tested either. The OKS term Table 9 weighs at 24 did not exist in this project until [WP-178](https://borda.github.io/lucid-YOLO/work_packages/wp-178/) (A75) and enters at `oks_gain = 0.0`\]) | `keypoint_gain = 1.0`, carried as a `DetectionLitModule` constructor argument rather than a module constant, matching how `angle_gain` (A22) is plumbed for the oriented task. Unity is chosen as the deliberately un-tuned starting point: the [WP-132](https://borda.github.io/lucid-YOLO/work_packages/wp-132/) gate it first ships under is an overfit-100 loop-composition check, where the question is whether the RLE term reaches the optimizer and drives the points at all, not whether it is balanced against the box and class terms -- a tuned value picked against a 100-image memorization run would not transfer to a real pose run anyway, and would look like evidence while being none. Exposure: unity is **untested at tier scale**; the first run entitled to move it is [WP-125](https://borda.github.io/lucid-YOLO/work_packages/wp-125/)'s pose-smoke, where a keypoint term that swamps or is swamped by the detection objective would show as a mechanism failure rather than a tuning one. **[WP-125](https://borda.github.io/lucid-YOLO/work_packages/wp-125/) ran**: unity trained a real pose head in both the RLE and Laplace-NLL arms, each beating its own control on OKS rather than being swamped by or swamping the detection objective -- the mechanism-failure exposure this row named did not happen. The value's own tuned adequacy, as distinct from that narrower bar, was never this row's question and is still unmeasured -- no sweep ran, and unity stays the un-tuned starting point it was chosen as | R14 (the loss, no weight); R1 Table 9 (the published `(24, 1)` split, read 2026-09-17, untested here); R1 Eq. 15 / A22 (the precedent for how a task gain is plumbed, not for the value); the value itself: gap | `tests/ptl/test_keypoint_training.py` -- `keypoint_gain=0` leaves the detection total bit-exact, proving the term is genuinely additive and the gain genuinely consulted; [WP-125](https://borda.github.io/lucid-YOLO/work_packages/wp-125/) (work_packages/wp-125.md#wp-125) is the tier-scale mechanism-failure check, not a tuning sweep | open |
| A69 | Whether the one-to-many and one-to-two branches share a single RLE normalizing flow or train one each (R14 fits one density to one regressor's residuals and has no dual-branch architecture to speak to; R1's dual assignment predates any keypoint task, so neither source has a view on how a learned residual density should be split across two branches that are deliberately trained to disagree early and converge late) | **One shared flow** (`DetectionLitModule.rle_loss`, a single `RLELoss` called once per branch and blended by WP-035's `alpha`, the same arrangement the mask and oriented terms use). Two arguments, neither decisive alone: it is the smaller claim -- one density over one model's residuals is what R14 actually describes, where two would assert that the branches' error distributions are different in kind rather than merely in schedule -- and it is the smaller parameter count, the flow being the only trainable-weight loss in the project. It is called twice rather than once over concatenated positives so each branch's term keeps its own `alpha` share; that much is forced by the blend, not chosen. Exposure: **untested against the alternative.** If the two branches' residual distributions genuinely differ early in training, one flow is fit to a mixture and may serve neither well -- a failure that would show as a keypoint term that falls slowly while the box terms converge normally, not as an error. No measurement here distinguishes the two, and the overfit gate cannot: a 100-image memorization run drives both branches to the same near-zero residual, which is exactly the regime where one flow and two are least distinguishable | R14 (one density, one regressor, no dual branch); R1 (dual assignment, no keypoint task); the split itself: gap | `tests/ptl/test_keypoint_training.py` -- pins that one flow is constructed and that both branches reach it; the shared-vs-split question is a [WP-125](https://borda.github.io/lucid-YOLO/work_packages/wp-125/) measurement, since only a tier-scale run has branches whose residuals differ enough to tell | open |
| A70 | What becomes of a keypoint that a geometric transform warps outside the canvas, on an instance the transform *keeps* (R12 annotates only what a human could see and so never faces the question -- a point outside the photograph was simply never labelled; R14 regresses points in a cropped person box and states no cropping policy of its own. The case is manufactured by this project's own augmentation pipeline: the training warp translates by up to 10% and scales, so a point on a kept instance can land off-canvas while its box survives the keep mask. Named for what they were when this row was written, both mechanisms have since moved without the case moving with them -- `_keep_mask` became upstream's `instance_keep_mask` at [WP-155](https://borda.github.io/lucid-YOLO/work_packages/wp-155/), and `FusedAffineLetterbox` became `RandomAffine(letterbox=...)` over one `fuse` segment at [WP-156](https://borda.github.io/lucid-YOLO/work_packages/wp-156/)) | **Carried through unchanged** -- true coordinate preserved, visibility untouched, no clamping and no demotion. The two alternatives were rejected on what they teach rather than on cost. Clamping to the boundary invents a target: it supervises the model toward a location the anatomy is demonstrably *not* at, which is worse than absent supervision because it is confident and wrong. Demoting to `v=0` discards a coordinate that is genuinely known and overloads a flag A66 defines as *no annotation exists* with a second, incompatible meaning, *existed and left the frame* -- after which no consumer can tell the two apart, including the OKS scorer. Carrying through is also the reading A66's own argument already implies: it admits `v=1` because "occlusion is exactly the case a regression model must learn to infer from context", and a point pushed past the edge by an affine is that same case with the occluder being the crop. Nothing in the head or the loss objects -- `decode_keypoints` adds stride-scaled offsets to an anchor centre and is unbounded by construction, so an off-canvas target is representable, not a special case. Exposure: a run whose augmentation pushes points off-canvas *often* is training partly on evidence the image does not contain, and this project has no measurement of where that stops helping; the overfit gate cannot supply one, since memorization succeeds regardless | A66 (the visibility semantics this refuses to overload, and whose occlusion argument this extends); R12 and R14: gap on this case | `tests/data/test_affine.py`, `tests/data/test_mosaic.py` -- a point warped past the boundary keeps its coordinate and its visibility, and is neither clamped to the edge nor zeroed | open |
| A71 | The coordinate frame the RLE residual is formed in (R14 states `sigma_hat` is "predicted with a sigmoid function. Hence we have `sigma_hat_i` in (0, 1)" and forms the residual as `x_bar = (mu_g - mu_hat) / sigma_hat`, but never names the frame `mu` lives in -- it regresses inside a top-down person crop resized to a fixed input, so the crop *is* the normalization and the paper has no occasion to state one. A dense one-stage detector has no crop: `decode_keypoints` returns absolute input pixels) | **The assigned ground-truth box's frame, per axis** -- `x` by the box width, `y` by its height, via `normalize_keypoints_to_box`, applied to prediction and target alike immediately before `RLELoss` and nowhere else. The paper's own sigmoid bound forces this: a scale bounded above by 1 can only standardize a residual whose errors are `O(1)`, so in pixels the *smallest* residual expressible for a 40 px error is 40, and the flow's Gaussian base is quadratic in it. Measured, not argued: un-normalized, the keypoint term entered at **1300 of a 1342 total loss** and the run went non-finite on the second step; normalized, the median residual is **1.28**. Per-axis rather than OKS's single `sqrt(area)` scalar because per-axis is what resizing a crop to a fixed input actually does -- the two agree on a square object and differ only in how an elongated one distributes tolerance. The box origin is subtracted as well as the extent divided, though the loss reads the two points only through their difference and the translation cancels: the values then mean what the name says. A degenerate box is floored at one pixel per axis, since a sub-pixel box carries no pose to normalize by and the unclamped division answers a finite annotation with an infinity. Exposure: this frame ties point supervision to **box** quality, so an instance whose box is badly wrong normalizes its points by the wrong scale -- invisible in an overfit run, where the boxes are memorized too | R14 sec. 3.2 (the residual), sec. 3.3 (the sigmoid bound that forces a normalized frame); the frame itself: **gap** -- R14 does not state one; A67 (OKS's own object-scale normalization, the nearest stated precedent) | `tests/ptl/test_keypoint_training.py` -- corner-to-corner mapping, per-axis extents, the one-pixel floor, and that the residual the loss sees is `O(1)` on a real batch rather than `O(10^3)` | active |
| A72 | Whether the RealNVP coupling layer's log-scale is emitted raw or bounded (R14 specifies the conditioner -- "Lfc=3 and Nn=64 by default... Each fully-connected layer is followed by a Leaky-RELU" -- and cites RealNVP for the coupling construction itself rather than restating it, so the parameterization of `s` is stated only in Dinh et al.) | **Bounded: a tanh times a per-layer learned scale**, which is RealNVP's own parameterization and is given there as a stability measure -- "To compute the scaling functions s, we use a hyperbolic tangent function multiplied by a learned scale" (Dinh et al. sec. 4). [WP-123](https://borda.github.io/lucid-YOLO/work_packages/wp-123/) emitted the conditioner's raw linear output, which is not a simplification of that but a different layer, and the difference is invisible until the loss is composed into a training run: the stack multiplies `exp(log_scale)` six times, so an unbounded log-scale compounds. Measured: a residual of **27** reached the latent as **4e11** and the loss as **7e22**, and the run went non-finite -- while the loss's own unit tests, which evaluate it at `O(1)` residuals, stayed green throughout. Only the log-scale is bounded; the shift is additive, compounds linearly, and is not what the paper stabilizes. Exposure: the learned scale is itself unbounded, so the runaway is damped rather than provably impossible -- what the tanh removes is the compounding feedback path, since a saturated tanh passes no gradient back to the conditioner. Residual spikes into the thousands still occur on this gate's data and are **not** understood to be harmless; they are the A70 off-canvas tail meeting a quadratic base density | Dinh et al., *Density estimation using Real NVP* (arXiv:1605.08803) sec. 4, the source R14 Appendix A Eq. 12 defers to; R14: gap (does not restate the parameterization) | `tests/losses/test_rle_loss.py` -- the log-scale stays inside the learned bound, and a residual an order of magnitude outside the training range still yields a finite loss and finite gradients | active |
| A73 | Whether det/seg's torchmetrics-backed eval and keypoints' hand-driven `faster_coco_eval` eval should be forced onto one code path (no paper states an evaluation architecture; torchmetrics 1.9's detection module carries zero keypoint/OKS support -- confirmed by grep, no "keypoint"/"kpt"/"oks" symbol anywhere) | Keep keypoints hand-driven behind `evaluate_keypoints`; do not fork torchmetrics' `CocoBackend`/`MeanAveragePrecision` internals to add a keypoints iou_type (that fork is WP B, deferred, not attempted). [WP-137](https://borda.github.io/lucid-YOLO/work_packages/wp-137/): `val/oks_mAP` runs every validation epoch beside `val/mAP`, the same "beside, not instead of" relationship `val/segm_mAP` already has, closing the actual gap -- keypoints previously logged no per-epoch pose signal at all. **Revised 2026-08-25 by [WP-138](https://borda.github.io/lucid-YOLO/work_packages/wp-138/)**: [WP-137](https://borda.github.io/lucid-YOLO/work_packages/wp-137/)'s own text here claimed a hand-driven `hotcoco` path "would cost back the memory-bounded streaming `_StreamingScorer` exists for" -- checked against torchmetrics' actual `_get_safe_item_values` source and found **wrong**: torchmetrics RLE-encodes every mask immediately inside `update()` and discards the dense tensor per batch, and nothing stops a hand-driven adapter doing the identical thing. [WP-138](https://borda.github.io/lucid-YOLO/work_packages/wp-138/) does exactly that: `hotcoco` is adopted as `lucid-eval`'s default bbox/segm engine (`DualPathEvaluator(backend=...)`, `--eval_backend auto\|hotcoco\|faster_coco_eval`), a hand-driven `_HotcocoStreamingScorer` RLE-encoding per batch same as torchmetrics does, verified bit-for-bit (`abs=1e-6`) against `faster_coco_eval` on bbox-only, bbox+segm and the empty-preds case. Five real hotcoco compatibility traps found and fixed, matching rf-detr PR 1402's own documented list: `params`/`dataset` are copy-on-read (get-mutate-reassign, never in-place); RLE `counts` comes back `bytes`, hotcoco's constructor silently reads it as an empty mask unless decoded to `str` first (verified directly: a 9-pixel mask round-tripped through this project's installed hotcoco as 0 pixels, no exception); `mask.encode` accepts `uint8` only, not bool; `iou_type` is spelled snake_case; hotcoco writes stdout/stderr from Rust, which `contextlib.redirect_stdout` does not reach (verified directly) -- fd-level `os.dup2` redirection required. Keypoints' OKS scoring and `module.py`'s training-loop `val/mAP`/`val/segm_mAP` are untouched either way -- the torchmetrics-subclass fork this row still declines stays declined for both | roboflow/rf-detr PR #1183 (`keypoint_oks.py`'s `MetricKeypointOKS`, the identical hand-driven-behind-a-torchmetrics-shaped-interface choice for keypoints, citing upstream torchmetrics PR #3348 as the eventual real fix) and PR #1402 (`_HotCocoBackend(CocoBackend)`, the working torchmetrics-subclass hotcoco integration this project's own WP B would follow if attempted -- 4.2-4.6x measured through the real `compute()` pathway, "not a training-throughput change... under 2% of an epoch" by their own stated verdict, corroborating this project's independent [WP-137](https://borda.github.io/lucid-YOLO/work_packages/wp-137/) benchmark); this project's own [WP-137](https://borda.github.io/lucid-YOLO/work_packages/wp-137/)/WP-138 benchmarks (bbox 13x, segm ~127-180x in isolation at COCO-val scale -- ~1 minute saved over a multi-hour training run either way, the reason `module.py`'s own per-epoch metrics stay on `faster_coco_eval`) | `tests/ptl/test_keypoint_training.py::TestKeypointValidation`; `tests/eval/test_coco_eval.py::TestHotcocoParity`; revisit the torchmetrics-subclass fork (WP B) if `module.py`'s own segm training-loop wall-clock is ever measured and found to matter, or when torchmetrics PR #3348 (native keypoint support) lands | active |
| A74 | Whether R14 sec. 4's "Each fully-connected layer is followed by a Leaky-RELU" covers the conditioner's output projection as well as its hidden layers (the paper's figure and appendix draw no distinction, so the sentence admits both readings) | Read it literally: the output projection carries the trailing LeakyReLU, as it has since [WP-125](https://borda.github.io/lucid-YOLO/work_packages/wp-125/) | R14 sec. 4 | The pose smoke recipe trained twice at one seed, with and without the trailing activation, compared on [WP-125](https://borda.github.io/lucid-YOLO/work_packages/wp-125/)'s acceptance metric. Not run -- needs an accelerator | active |
| A75 | The form of the OKS-based keypoint loss R1 composes with RLE (R1 sec. 3.4.2 says only that "training uses an Object Keypoint Similarity (OKS)-based loss, which normalizes keypoint localization error by person scale and per-keypoint OKS constants", cites the formulation to YOLO-Pose (Maji et al., CVPRW 2022) -- **not on the allowlist** -- and states no equation; R1 Table 9 weighs the term at `wOKS = 24` beside `wRLE = 1` without saying what one unit of it is) | `1 - OKS` per assigned instance, `OKS` being R12's own evaluation quantity: `sum_i exp(-d_i^2 / (2 s^2 k_i^2)) [v_i > 0] / sum_i [v_i > 0]`, `d_i` the pixel distance between predicted and annotated point, `k_i = 2 sigma_i` from R12's 17-value sigma table (A67's uniform sigma for any other schema), and `s^2` the **assigned ground-truth box area** -- COCO's `area` is the segment area, which a keypoints batch does not carry, so the box is the scale this project has; floored at 1.0 (A36's precedent). Instances with no labelled point are excluded from the mean rather than scored zero; `v = 0` points are excluded before any arithmetic (A66). Enters at `oks_gain = 0.0` ([WP-178](https://borda.github.io/lucid-YOLO/work_packages/wp-178/)): the shipped RLE-only objective stays bit-exact and [WP-125](https://borda.github.io/lucid-YOLO/work_packages/wp-125/)'s paired-run evidence keeps its meaning. Table 9's `(24, 1)` is the published split and is **untested here**; a default of 24 with no run behind it would look like evidence while being none (A68's own reasoning) | R1 sec. 3.4.2 (the composition, no formula); R1 Table 9 (the weights); R12 (the OKS definition and sigma table); the loss form and the box-area scale: gap | `tests/losses/test_oks_loss.py` -- exact prediction scores 0, a hand-computed two-point OKS matches, `v = 0` is inert, an all-unlabelled batch is a finite zero; `tests/ptl/test_keypoint_training.py` -- `oks_gain = 0` leaves the total bit-exact and `oks_gain = 1` adds exactly the logged term. The Table 9 sweep at smoke scale is a roadmap item, not run | open |
| A76 | Whether the `log sigma` term enters the RLE objective once or twice. R1 Eq. 11 prints `L_RLE = log sigma - log phi(eps) + log(2 sigma) + abs(eps)`, with the bracketed pair labelled `-log Laplace(x_hat; x*, sigma)` -- the Laplace NLL written in the **un-normalized** frame, which carries its own `log sigma`, beside the reparameterization Jacobian's `log sigma`, so `sigma` is penalized twice per axis. R14 Eq. 8, which `losses/rle_loss.py` transcribes, evaluates the base density on the **normalized** residual (`-log Q(x_bar)`, a unit Laplace, no `sigma` inside) and carries `log sigma_hat` once | Keep R14's form, as landed by [WP-123](https://borda.github.io/lucid-YOLO/work_packages/wp-123/): `L = -log Q(x_bar) - log G_phi(x_bar) + log sigma_hat`, one `log sigma` per axis. The two objectives differ by exactly `log sigma` per axis -- a gradient of `1/sigma` pushing every scale smaller regardless of the residual -- and R1's own text says nothing that resolves whether the doubling is intended or a transcription of an implementation; either way it is a behaviour change to a landed loss whose paired-run comparison ([WP-125](https://borda.github.io/lucid-YOLO/work_packages/wp-125/)) is the thing it would move, so it is recorded, not made. Read 2026-09-17 (D22) | R1 sec. 3.4.2, Eq. 11 (the doubled form); R14 Eq. 8, sec. 3.2 (the single form); which R1 means: gap | The pose smoke recipe trained twice at one seed, once per form, compared on [WP-125](https://borda.github.io/lucid-YOLO/work_packages/wp-125/)'s acceptance metric. Not run -- needs an accelerator | open |
| A77 | The keypoint stem's hidden width, now that a published size table exists to check it against (R1 Table S10 publishes params and FLOPs for every pose scale at 640 px -- n 2.9 M / 7.5 G, s 10.4 / 23.9, m 21.5 / 73.1, l 25.9 / 91.3, x 57.6 / 201.7 -- which `_build_keypoint_stem` and `test_kp_params_flops_golden` both said did not exist) | Unchanged for now: A28's shared `max(16, channels // 3)`, as landed by [WP-122](https://borda.github.io/lucid-YOLO/work_packages/wp-122/). **Measured 2026-09-17** at Table S10's protocol (640 px, 1 class, 17 points), this project against the table: params n **-13.0%**, s -4.5%, m -3.8%, l -3.8%, x -3.0%; FLOPs n **-26.5%**, s -10.8%, m -5.9%, l -0.4%, x +1.0%. Against this project's own detection golden the point stems add 0.11 M / 0.12 G at `n`; Table S10 minus Table 7 implies R1's add 0.5 M / 2.1 G, and crediting back the 80-to-1 class saving the way A20 credited 80-to-15 (0.024 M at `n`, measured on this project's detector) moves that to ~0.52 M — the finding gets slightly stronger, not weaker. The stem is therefore several times narrower than R1's at the small scales, converging by `l`/`x` where the trunk dominates -- the same shape of finding A20 made for the angle stem before its `// 2` revision, and it is left for the same kind of gate-driven revision rather than tuned by hand in a documentation commit. Every other size gate in `test_param_flops.py` passes at ±2–3.5% params and ±5% FLOPs; this one would fail at every scale but `l` and `x` on FLOPs and at every scale on params, so no `_TABLE_S10` gate ships until a width is chosen against it | R1 Table S10 (the sizes, read 2026-09-17); R1 sec. 3.4.2 ("a parallel sigma branch", no width); the width itself: gap, as A20's was | A `test_kp_vs_tableS10` gate mirroring `test_obb_vs_tableS11`, once a width is selected against Table S10 the way A20's was against Table S11 -- roadmap item. Until then `test_kp_params_flops_golden` stays the regression lock it is | open |
| A78 | No paper covers compilation: R1 reports step times and nothing about how the graph was executed, and no allowlisted source states whether an Inductor-compiled objective trains to the published figures | The compiled detection objective (`compile_step: true`, WP-184, D24) trains to the same mAP as eager within run noise. Compilation reorders and fuses floating-point work, so the two are not bit-identical; the assumption is that the difference is below what seed-to-seed variation already produces, which the region's 1-graph trace and the dense loss ([WP-183](https://borda.github.io/lucid-YOLO/work_packages/wp-183/), absdiff 0.0 in eager) make plausible but do not show | Gap; torch's own documentation of Inductor as numerics-preserving up to fusion-order rounding | One `det_nano_smoke` epoch plus validation, `compile_step` true vs false at the same seed and worker count, under `TORCH_LOGS=recompiles`: `val/mAP` within run noise, ≤ 3 recompiles, training step ≥ x1.15 vs eager detect. **Run 2026-09-19/20 on an A100-40GB (torch 2.14.0+cu130), rounds 3–5 of the Colab gate**: a paired 4-epoch `det_nano_smoke` run on val2017 at seed 0, `compile_step` true against false — 1 recompile (the first `no_grad` evaluation graph), training step 180.6 → 144.2 ms (x1.25), and the same weights and batch through the real training path (`setup("fit")`, `deterministic`, `channels_last`, marked dynamic shapes) agree to `3.6e-5` on the total loss with TF32 off; with TF32 on they differ by `1e-2`–`1e-1` per batch because the task-aligned assignment is discrete and TF32 rounding moves its top-k set, which is why `train/loss` is not the comparison to make under the default matmul precision. `val/mAP` after 4 epochs from scratch is `~1e-5` in both arms and carries no signal, so the mAP clause is met by the paired loss parity rather than by a converged run | validated |

## 🔀 Deviation notes

- **A26** is a deliberate deviation from blueprint section 7 (fixtures were specified as "16 permissively-licensed hand-annotated images"). Decision D12 (DECISIONS.md) records the rationale: synthetic scenes are fully redistributable, deterministic, and carry exact ground truth for boxes, polygons, and rotated boxes from one generator, removing annotation noise from unit gates. The dataset-contract rule "missing data blocks the WP, not worked around with synthetic substitutes" still applies to tier acceptance runs — A26 covers fixtures and offline development stand-ins only.
