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) → _apply_milestone_late_date (:1560), 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 pass project_finish (_late_display already handles the display cap).
  • Mirror the change in derive.py _prefloor_late_window (~:819) and in Rust backward.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_float working days via an SNET pin must not move the finish instant, and shifting it by total_float + 1 must 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).