scheduler: SF anchor's positive lag is absorbed by an adjacent weekend when the pre-snap anchor sits on the working day before it

Summary

_edge_anchor's SF branch (packages/scheduler/src/trueppm_scheduler/engine.py) pre-snaps its anchor to the previous working day (_prev_working_day(instant - 1, pred_cal)) before the caller adds lag, for both ordinary-work SF and a milestone predecessor's SF link. This is the exact bug class #4273 (closed) already fixed for FF-out-of-a-milestone-with-positive-lag ("Anchoring a positive lag on the last working day as well ... let a weekend absorb it") — but the analogous fix (defer the snap, anchor on the raw instant - 1 instead, when lag > 0) was only applied to the FF branch, not to SF.

Repro (found while building #4327's fixture)

Two tasks, 1-day duration each, project calendar Mon-Fri, 11 -SF(lag=2d)-> 12, project start Monday:

from datetime import date, timedelta
from trueppm_scheduler import Calendar, Dependency, DependencyType, Project, Task, schedule

tasks = [Task(id="11", name="T1", duration=timedelta(days=1)),
         Task(id="12", name="T2", duration=timedelta(days=1))]
deps = [Dependency("11", "12", dep_type=DependencyType.SF, lag=timedelta(days=2))]
project = Project(id="x", name="x", start_date=date(2016, 2, 8),  # Monday
                   calendar=Calendar(), tasks=tasks, dependencies=deps)
result = schedule(project)
  • Engine: task 12 early_start == early_finish == 2016-02-08 (same day as task 11 — the 2-day lag had no effect).
  • MS Project (verified against task-links-project2016-mpp12.mpp and task-links-project2016-mpp14.mpp, two independently-saved files from MPXJ's generated cross-version test matrix, joniles/mpxj): task 12 finishes 2016-02-09 (Tuesday) — one working day later, per the 2-working-day lag.

The mismatch only appears when the zero-lag SF anchor (the working day before the predecessor's start) sits immediately before a weekend — here, predecessor starts Monday, so the zero-lag anchor is the prior Friday, and Friday + 2 calendar days lands on Sunday, snapped forward back to Monday, absorbing the entire lag. When the predecessor starts mid-week (Wed, Thu — also checked against real MS-Project-saved files, task-links-project2000-mpp8/mpp9 and task-links-project2019-mpp12/mpp14) the engine's current behavior already matches MS Project, because no weekend sits between the pre-snapped anchor and the lagged result.

Where to look

  • _edge_anchor (engine.py): the elif dep_type == DependencyType.FF and lag > timedelta(0): return _safe_offset(instant, -_ONE_DAY) special case needs an SF equivalent — and the ordinary-work SF path (instant = start then falling through to the shared pre-snapped tail) needs the same treatment, since it never passes through that milestone-only elif at all.
  • _sf_latest_start and _milestone_latest's finish-anchored branch: the backward pass's SF inverse is stated in terms of the same prev_wd(start - 1) forward anchor ("deliberately the same expression as _milestone_latest's finish-anchored branch"), so fixing the forward anchor without checking the backward pass would desync late-start/float for exactly the cases this bug affects.
  • Rust/WASM conformance (packages/wasm-scheduler, forward.rs) and Monte Carlo padding need the same fix in lockstep per the project's engine-conformance requirement, or wasm:conformance should start failing once a fixture exercises this.

Acceptance

  • Fix (or explicitly scope-limit and document as Differs) the SF anchor's interaction with positive lag when the zero-lag anchor sits adjacent to a weekend, for both ordinary-work and milestone-predecessor SF.
  • Verify the backward pass (_sf_latest_start, _milestone_latest) stays consistent with whatever the forward anchor becomes.
  • Add a regression test reproducing the Monday-start case above (no existing test exercises it — the two task_links_cross_version.py scenarios #4327 added were deliberately chosen to match today's engine so #4327's branch could verify the composition of SF+lag, not this specific weekend-adjacency defect).
  • Rust/WASM conformance check for the same case.

#4327 (discovered while building its fixture), #4273 (closed) (the analogous fix for FF-out-of-a-milestone), #2534 (closed)/#2535 (the broader, separate open question of calendar-day vs. working-day lag counting — this bug is narrower and does not depend on how that question is resolved).