# 📦 WP-173 — `fix(models): named branch outputs and a schedule that reaches its floor`

> Engineering log · [Phase 15 — Audit remediation, v0.8.0](https://borda.github.io/lucid-YOLO/engineering/phase-15-audit-remediation/)
>
> 🔗 Depends on [WP-165b](https://borda.github.io/lucid-YOLO/work_packages/wp-165b/) · Needed by [WP-167](https://borda.github.io/lucid-YOLO/work_packages/wp-167/), [WP-175](https://borda.github.io/lucid-YOLO/work_packages/wp-175/)
>
> ⬅️ [WP-172](https://borda.github.io/lucid-YOLO/work_packages/wp-172/) · [Roadmap](https://borda.github.io/lucid-YOLO/ROADMAP/) · [WP-174](https://borda.github.io/lucid-YOLO/work_packages/wp-174/) ➡️


## WP-173 — a tuple that defended its width but not its order, and a floor never reached

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

**The branch output argued against itself.** `_DetectionBranch.forward` returned six tensors as a bare positional tuple, destructured in five places, four of them with underscore placeholders — while its own docstring argues that a fixed shape must be defended, and the outer `DualHeadOutput` is a dataclass precisely because a tuple was judged unsafe one layer up. The *width* was defended and the *order* was not: inserting or reordering a future optional output would silently rebind every consumer, with no error anywhere. A frozen `BranchOutput` with the same six fields now carries them, read by attribute at all five sites. Stem execution order inside `forward` was preserved exactly, so no numeric drift was possible — and the `params_flops_*` goldens passing unchanged is the proof rather than the hope.

**`count_flops` flipped the caller's model into train mode.** `deploy()` returns a freshly constructed view whose `training` is always `True`, so the saved flag was always `True` and the closing `module.train()` propagated through *shared* submodule references back into the parent. Measured on `build_detector("n", 4).eval()` then `count_flops(m.deploy(), 64)`: the model reported `training == False` while `backbone`, `neck` and `head.o2o` were all `True` — the parent reporting eval while three quarters of it would use batch statistics and mutate BN running stats on the next forward, with nothing raising. A per-submodule snapshot is restored in a `finally`, by direct assignment rather than `train(mode)` so restoration does not depend on `named_modules()` being parent-first to undo the recursion. GFLOPs identical at 0.052695.

**The decay never reached its floor.** `LambdaLR`'s last invocation for a run of `total_steps` is `step = total_steps - 1`, so the factor landed at `0.021` against an intended `lrf` of `0.01` — twice the floor — while the doctest that displayed `lrf` evaluated `step = 100`, an index the run never reaches. A doctest can only certify what it evaluates, and this one evaluated a point outside the run. `decay_span = max(1, total - warmup_steps - 1)` fixes it and the doctest moved to `99`. One existing parametrized expectation changed `0.75` → `1.0 - 0.5 * 50 / 99`, written as an expression with a comment recording that this is a semantic correction and not a loosened tolerance: `0.75` was the off-by-one's answer.

**`warmup_steps >= total_steps` was unguarded**, and that shape is not hypothetical — it is a 1–3 epoch smoke run with a 3-epoch warmup default, and it fires inside `test_fast_dev_run_smoke` today. Every step took the ramp, the LR climbed for the whole run, peaked below `lr0`, and nothing warned. Clamped at the call site with a warning, rather than raised, because a raise would fail a launchable run over a schedule detail on the tier where the schedule matters least.

**The clamp's honest limit, recorded because a test caught the overclaim.** Clamping to `total_steps - 1` recovers the *peak* but never the *decay*: any over-request lands exactly on `warmup_steps == total_steps - 1`, whose single post-warmup step sits at progress 0, so the factor is 1.0 and the floor is unreachable by construction. The agent's first warning text promised decay; its own two-step test failed, and the message was corrected to say what the clamp actually buys and what the operator should change instead. That is the right direction of repair — the assertion stood and the claim moved.

**One latency corrected downward.** `_TupleOutputAdapter`'s `SegmentOutput` branch enumerated ten fields where its sibling enumerated twelve, dropping all four keypoint tensors. Both enumerations are now derived from `dataclasses.fields()`, so a new optional output cannot be forgotten in one branch. But `SegmentOutput` is produced only by `Segmenter.forward`, and `Segmenter.__init__` never passes `num_keypoints`, so no public builder reaches the drop today: this removes a possibility rather than repairing an active miscount, which is also why no FLOP golden moved.

**What this row hands to [WP-167](https://borda.github.io/lucid-YOLO/work_packages/wp-167/).** M-32 changes the schedule every trained run follows, so `goldens/gpu/shapes_regression_det.json`'s three metrics are expected to move. The cause is quantified rather than guessed: `det_nano_smoke.yaml` over 6 epochs is 678 optimizer steps with 339 warmup, the final factor moves `0.01292 → 0.01000`, and the largest per-step gap anywhere in the run is `0.00292`, 0.29% of `lr0`, against a 0.05 tolerance. The four `overfit_micro_*` goldens are structurally immune and this was verified by reading the producer rather than assumed: `run_overfit` hardcodes `lrf=1.0, warmup_epochs=0.0`, which trips `schedule_off` so no scheduler is constructed at all. `optim_toy` runs at constant LR and never imports `warmup_decay_factor`.

