fix(scheduler): a terminal milestone reached by a lag understates its predecessor's total float and marks it critical — backward pass seeds at the raw finish instant (#4079 regression)
Problem
A terminal milestone reached through a calendar-day lag understates its predecessor's total float and marks the predecessor critical, even though slipping it by a day does not move the finish.
Found by the 2026-09-27 scheduler pre-release audit and reproduced independently against origin/main 49af97b7. Treat it as a well-evidenced hypothesis; re-verify at fix time.
Repro
Mon–Fri calendar, project start Mon 2026-01-05:
A(4d) -FS+1d-> M(milestone) # M instant Sat 00:00, shown Fri (end of day)
# A.total_float == 0, critical_path == ['A', 'M'], project_finish 2026-01-09
# slip A one working day (SNET Tue) -> project_finish still 2026-01-09
# same network + M -FS0-> B(1d):
# A.total_float == 1, critical_path == ['M', 'B']Expected: A has TF = 1 and is not critical. That is consistent with the successor variant, and with pre-#4079 behavior, where the same shape as a 1-day task gave TF 1. The Rust engine agrees with Python (TF 0 → 1), so conformance cannot see it.
Root cause
The late seed for a live milestone is the raw finish_instant: _backward_pass (:1480) → :1560), _apply_milestone_late_date (bounds = [finish_instant]. For a terminal milestone the raw instant is the finish instant (Sat 00:00). A later midnight in the same working position (Sun or Mon 00:00) would leave the finish unchanged, but it is never admitted.
Successor-driven milestones are fine, because _milestone_latest inverts against the successor's working-day late start. Work tasks are fine too: they are seeded with prev_wd(finish_instant - 1).
Introduced by #4079 (closed) (merged 2026-09-27, 0.4), which is not yet in any tag. Adjacent: #4157 (closed) (closed; derive cited the wrong finish for a terminal milestone).
Fix (both engines)
- When a milestone's late bound comes from the project-finish seed rather than a successor link, admit the latest instant with the same working position: the midnight that opens the next working day after
finish_instant. Cap it so the shown day does not passproject_finish(_late_displayalready handles the display cap). - Mirror the change in
derive.py_prefloor_late_window(~:819) and in Rustbackward.rs.
Test plan
- Add the repro as known-answer tests in Python and Rust, plus a conformance fixture.
- Add a definitional property test on FS/SS networks: for every live work task, shifting it by
total_floatworking days via an SNET pin must not move the finish instant, and shifting it bytotal_float + 1must move it. The audit's probe gave 0 failures on 8,822 work-only TF probes and found this class only once milestones were added.
Relates #4079 (closed), #4157 (closed).