Skip to content

Commit 8c80354

Browse files
alanbldclaude
andcommitted
fix: SNET/FNET constraints on non-working days round forward
When a "Start No Earlier Than" or "Finish No Earlier Than" constraint date falls on a non-working day (weekend/holiday), the scheduler was rounding BACKWARD to the previous working day instead of FORWARD to the next working day. Example: SNET = 2018-06-03 (Sunday) - Before: task scheduled on 2018-06-01 (Friday) - WRONG - After: task scheduled on 2018-06-04 (Monday) - CORRECT Root cause: `date_to_working_days()` counts working days up to (but not including) the target date. For floor constraints, if the target is a non-working day, we need to advance to the next working day first. Fix: - Add `advance_to_working_day()` helper function - Apply it to SNET, FNET, MustStartOn, and MustFinishOn constraints - Ceiling constraints (SNLT, FNLT) correctly round backward (unchanged) Fixes the CTL PIII milestone scheduling bug where task_24 "[7.6] TTG Release" was scheduled 2 days early. Tests: 3 new tests for SNET on non-working days (task, milestone, nested) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 66dbe3d commit 8c80354

File tree

3 files changed

+132
-11
lines changed

3 files changed

+132
-11
lines changed

Cargo.toml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ members = [
1414
]
1515

1616
[workspace.package]
17-
version = "0.9.1"
17+
version = "0.9.2"
1818
edition = "2021"
1919
rust-version = "1.75"
2020
license = "MIT OR Apache-2.0"
@@ -24,10 +24,10 @@ categories = ["command-line-utilities", "development-tools"]
2424

2525
[workspace.dependencies]
2626
# Internal crates (version required for crates.io publishing)
27-
utf8proj-core = { version = "0.9.1", path = "crates/utf8proj-core" }
28-
utf8proj-parser = { version = "0.9.1", path = "crates/utf8proj-parser" }
29-
utf8proj-solver = { version = "0.9.1", path = "crates/utf8proj-solver" }
30-
utf8proj-render = { version = "0.9.1", path = "crates/utf8proj-render" }
27+
utf8proj-core = { version = "0.9.2", path = "crates/utf8proj-core" }
28+
utf8proj-parser = { version = "0.9.2", path = "crates/utf8proj-parser" }
29+
utf8proj-solver = { version = "0.9.2", path = "crates/utf8proj-solver" }
30+
utf8proj-render = { version = "0.9.2", path = "crates/utf8proj-render" }
3131

3232
# Date/Time
3333
chrono = { version = "0.4", features = ["serde"] }

crates/utf8proj-solver/src/lib.rs

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -701,6 +701,16 @@ fn date_to_working_days(project_start: NaiveDate, target: NaiveDate, calendar: &
701701
working_days
702702
}
703703

704+
/// Advance a date to the next working day if it falls on a non-working day.
705+
/// Used for "no earlier than" constraints where we need to round forward.
706+
fn advance_to_working_day(date: NaiveDate, calendar: &Calendar) -> NaiveDate {
707+
let mut current = date;
708+
while !calendar.is_working_day(current) {
709+
current = current + TimeDelta::days(1);
710+
}
711+
current
712+
}
713+
704714
/// Result of topological sort including precomputed successor map
705715
struct TopoSortResult {
706716
/// Tasks in topological order
@@ -2830,16 +2840,36 @@ impl Scheduler for CpmSolver {
28302840
let mut min_finish: Option<i64> = None;
28312841
for constraint in &task.constraints {
28322842
match constraint {
2833-
TaskConstraint::MustStartOn(date)
2834-
| TaskConstraint::StartNoEarlierThan(date) => {
2843+
TaskConstraint::MustStartOn(date) => {
2844+
// MustStartOn pins to exact date (or next working day if non-working)
2845+
let effective_date = advance_to_working_day(*date, &calendar);
2846+
let constraint_days =
2847+
date_to_working_days(project.start, effective_date, &calendar);
2848+
es = es.max(constraint_days);
2849+
}
2850+
TaskConstraint::StartNoEarlierThan(date) => {
2851+
// SNET: if constraint date is non-working, round FORWARD to next working day
2852+
// Example: SNET=Sunday → task starts Monday (not Friday)
2853+
let effective_date = advance_to_working_day(*date, &calendar);
28352854
let constraint_days =
2836-
date_to_working_days(project.start, *date, &calendar);
2855+
date_to_working_days(project.start, effective_date, &calendar);
28372856
es = es.max(constraint_days);
28382857
}
2839-
TaskConstraint::MustFinishOn(date)
2840-
| TaskConstraint::FinishNoEarlierThan(date) => {
2858+
TaskConstraint::MustFinishOn(date) => {
2859+
// MustFinishOn pins to exact date (or next working day if non-working)
2860+
let effective_date = advance_to_working_day(*date, &calendar);
2861+
let constraint_days =
2862+
date_to_working_days(project.start, effective_date, &calendar);
2863+
let exclusive_ef = constraint_days + 1;
2864+
min_finish = Some(
2865+
min_finish.map_or(exclusive_ef, |mf| mf.max(exclusive_ef)),
2866+
);
2867+
}
2868+
TaskConstraint::FinishNoEarlierThan(date) => {
2869+
// FNET: if constraint date is non-working, round FORWARD to next working day
2870+
let effective_date = advance_to_working_day(*date, &calendar);
28412871
let constraint_days =
2842-
date_to_working_days(project.start, *date, &calendar);
2872+
date_to_working_days(project.start, effective_date, &calendar);
28432873
let exclusive_ef = constraint_days + 1;
28442874
min_finish = Some(
28452875
min_finish.map_or(exclusive_ef, |mf| mf.max(exclusive_ef)),

crates/utf8proj-solver/tests/constraint_wiring.rs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,3 +364,94 @@ fn feasible_window_fits() {
364364
assert_eq!(schedule.tasks["bounded"].finish, date(2025, 1, 17));
365365
assert_eq!(schedule.tasks["bounded"].slack, Duration::zero());
366366
}
367+
368+
// =============================================================================
369+
// Non-Working Day Constraint Tests
370+
// =============================================================================
371+
372+
#[test]
373+
fn snet_on_sunday_rounds_forward_to_monday() {
374+
// Bug reproduction: SNET constraint on a non-working day should round FORWARD
375+
// to the next working day, not backward to the previous working day.
376+
//
377+
// Example: SNET = 2025-01-12 (Sunday)
378+
// Expected: task starts on 2025-01-13 (Monday)
379+
// Bug: task was starting on 2025-01-10 (Friday)
380+
let mut project = Project::new("SNET Sunday Test");
381+
project.start = date(2025, 1, 6); // Monday
382+
383+
let mut task = Task::new("constrained").effort(Duration::days(5));
384+
task.constraints
385+
.push(TaskConstraint::StartNoEarlierThan(date(2025, 1, 12))); // Sunday!
386+
project.tasks.push(task);
387+
388+
let solver = CpmSolver::new();
389+
let schedule = solver.schedule(&project).unwrap();
390+
391+
// Task should start on Monday 2025-01-13 (next working day after Sunday)
392+
// NOT Friday 2025-01-10 (previous working day)
393+
assert_eq!(
394+
schedule.tasks["constrained"].start,
395+
date(2025, 1, 13),
396+
"SNET on Sunday should round forward to Monday, not backward to Friday"
397+
);
398+
}
399+
400+
#[test]
401+
fn snet_milestone_on_weekend_rounds_forward() {
402+
// Same bug for milestones (which have zero duration)
403+
let mut project = Project::new("SNET Milestone Weekend");
404+
project.start = date(2025, 1, 6); // Monday
405+
406+
let mut milestone = Task::new("milestone");
407+
milestone.milestone = true;
408+
milestone
409+
.constraints
410+
.push(TaskConstraint::StartNoEarlierThan(date(2025, 1, 11))); // Saturday!
411+
project.tasks.push(milestone);
412+
413+
let solver = CpmSolver::new();
414+
let schedule = solver.schedule(&project).unwrap();
415+
416+
// Milestone should be on Monday 2025-01-13 (next working day after weekend)
417+
assert_eq!(
418+
schedule.tasks["milestone"].start,
419+
date(2025, 1, 13),
420+
"SNET milestone on Saturday should round forward to Monday"
421+
);
422+
}
423+
424+
#[test]
425+
fn nested_milestone_snet_on_weekend() {
426+
// Reproduce the exact bug from CTL PIII: nested milestone with SNET on weekend
427+
let mut project = Project::new("Nested SNET");
428+
project.start = date(2025, 1, 6); // Monday
429+
430+
// Container with a nested milestone
431+
let mut container = Task::new("container");
432+
container.name = "Container".to_string();
433+
434+
let mut child = Task::new("child").effort(Duration::days(3));
435+
child.name = "Child".to_string();
436+
437+
let mut milestone = Task::new("release");
438+
milestone.name = "Release".to_string();
439+
milestone.milestone = true;
440+
milestone
441+
.constraints
442+
.push(TaskConstraint::StartNoEarlierThan(date(2025, 1, 19))); // Sunday!
443+
444+
container.children.push(child);
445+
container.children.push(milestone);
446+
project.tasks.push(container);
447+
448+
let solver = CpmSolver::new();
449+
let schedule = solver.schedule(&project).unwrap();
450+
451+
// Nested milestone should be on Monday 2025-01-20 (next working day)
452+
assert_eq!(
453+
schedule.tasks["container.release"].start,
454+
date(2025, 1, 20),
455+
"Nested milestone SNET on Sunday should round forward to Monday"
456+
);
457+
}

0 commit comments

Comments
 (0)