📦 WP-173 — fix(models): named branch outputs and a schedule that reaches its floor¶
Engineering log · Phase 15 — Audit remediation, v0.8.0
WP-173 — a tuple that defended its width but not its order, and a floor never reached¶
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. 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.