Links, constraints and summaries#

This guide covers the rules that decide where a task lands: dependency links with lag, date constraints, how much slack the scheduler keeps between tasks, how summary tasks move, and what happens when links form a cycle. It's for developers who already schedule a project and now need to control or explain the dates it produces.

How the scheduler thinks#

Every task has a preferred position: the dates you sent in TaskData. Scheduling starts from those dates and applies three kinds of rules on top:

  • Links push or pull a task relative to another task. A LinkData connects a source to a target with one of four types and an optional lag. Links are the only thing that ties one task's dates to another's.
  • Constraints bound a single task to a date (snet, mfo, ...) or ask it to sit as early or as late as its links allow (asap, alap). They act only when config.apply_constraints is true.
  • Gap behaviour decides what to do with free time. With GapBehavior::Preserve (the default), a task keeps its preferred dates whenever its links allow them. With GapBehavior::Compress, every task is pulled to its earliest possible position.

Summary tasks sit on top of this. A summary's span is always derived from its children, so a summary takes both dates or none, never a duration. When a link points at a summary, the scheduler either moves the whole summary as one block or expands the link onto the summary's leaves, depending on config.move_projects.

Some tasks never move, and they act as fixed points for everything linked to them:

  • tasks with auto_scheduling: false
  • unscheduled tasks (unscheduled: true, which also drops their links)
  • completed tasks, when config.use_progress is on and progress == 1.0

Nothing in this guide raises an error at scheduling time. A constraint the links can't satisfy becomes a Conflict, a link that can't apply becomes a Note::DroppedLink, and a dependency cycle becomes a Cycle entry. All three are data in the ScheduleResult. Errors come only from invalid input, such as a dated constraint without a date or a non-finite lag. See Reading results for the full result shape.

To see which rule placed a task, set SolveOptions.reasons to true. Each update then carries a Reason: Asap (pushed by a predecessor link), Alap (pulled by a successor link), SummaryTranslation (moved with its summary block), Constraint or Preferred.

A link's type names which end of the source drives which end of the target:

LinkType The target's... ...follows the source's...
Fs (default) start finish
Ss start start
Ff finish finish
Sf finish start

lag adds working time between the two ends. It's counted on the target task's calendar, in config.duration_unit unless the link sets its own lag_unit. See Working time and calendars for how calendars define working time.

Chaining tasks with lag#

LinkData has no constructor, so every field is spelled out:

use plen_core::{schedule, DateTime, LinkData, LinkType, ProjectData, SolveOptions, TaskData};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let monday = DateTime::from_ymd_hm(2026, 3, 2, 0, 0).ok_or("invalid date")?;

    let data = ProjectData {
        tasks: vec![
            TaskData { start_date: Some(monday), duration: Some(3.0), ..TaskData::new(1) },
            TaskData { start_date: Some(monday), duration: Some(2.0), ..TaskData::new(2) },
            TaskData { start_date: Some(monday), duration: Some(2.0), ..TaskData::new(3) },
        ],
        links: vec![
            // Task 2 starts 2 working days after task 1 finishes.
            LinkData { id: 1.into(), source: 1.into(), target: 2.into(), link_type: LinkType::Fs, lag: 2.0, lag_unit: None },
            // Task 3 starts 1 working day after task 1 starts.
            LinkData { id: 2.into(), source: 1.into(), target: 3.into(), link_type: LinkType::Ss, lag: 1.0, lag_unit: None },
        ],
        ..ProjectData::default()
    };

    let result = schedule(data, &SolveOptions::default())?;
    for update in &result.updates {
        if let (Some(start), Some(end)) = (update.start_date, update.end_date) {
            println!("task {}: {start} .. {end}", update.id);
        }
    }
    Ok(())
}

Output:

task 2: 2026-03-09 00:00 .. 2026-03-11 00:00
task 3: 2026-03-03 00:00 .. 2026-03-05 00:00

Task 1 works Monday to Wednesday. The FS lag of two working days covers Thursday and Friday, so task 2 starts the next Monday. Task 1 isn't listed because it didn't move: updates holds only changed tasks.

Lag in a different unit#

A project measured in days can still have a link whose lag is in hours. Set lag_unit on that link:

use plen_core::{schedule, DateTime, LinkData, LinkType, ProjectData, SolveOptions, TaskData, TimeUnit};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let monday = DateTime::from_ymd_hm(2026, 3, 2, 0, 0).ok_or("invalid date")?;

    let data = ProjectData {
        tasks: vec![
            TaskData { start_date: Some(monday), duration: Some(1.0), ..TaskData::new("pour") },
            TaskData { start_date: Some(monday), duration: Some(1.0), ..TaskData::new("strip") },
        ],
        links: vec![LinkData {
            id: "cure".into(),
            source: "pour".into(),
            target: "strip".into(),
            link_type: LinkType::Fs,
            lag: 4.0,
            lag_unit: Some(TimeUnit::Hour),
        }],
        ..ProjectData::default()
    };

    let result = schedule(data, &SolveOptions::default())?;
    for update in &result.updates {
        if let (Some(start), Some(end)) = (update.start_date, update.end_date) {
            println!("{}: {start} .. {end}", update.id);
        }
    }
    Ok(())
}

strip starts at 2026-03-03 12:00: four working hours after pour ends on Monday evening, on the default 08:00-12:00, 13:00-17:00 calendar.

Note

Per-link lag_unit is a Planning Engine extension. dhtmlxGantt always measures lag in config.duration_unit. A lag_unit equal to the project unit behaves exactly like gantt.

A link that can't apply is skipped and reported as a Note::DroppedLink with a reason. The rest of the plan still runs. This happens when:

  • the source or target id doesn't exist, or names an unscheduled task
  • the link connects a summary to its own descendant and config.descendant_links is false (the default)
  • an endpoint is a summary with auto_scheduling: false and summaries expand to leaves (see Summary tasks)

A lag that isn't a finite number is different: strict validation rejects the input with an InputError at links[i].lag.

Constraints#

A constraint is set per task with constraint_type and, for the six dated types, constraint_date:

ConstraintType Meaning Needs a date
Asap as soon as the links allow no
Alap as late as the links allow no
Snet / Snlt start no earlier / no later than yes
Fnet / Fnlt finish no earlier / no later than yes
Mso / Mfo must start on / must finish on yes

Constraints are hard bounds, and they exist only when config.apply_constraints is true. With the default false, the scheduler ignores constraint_type and constraint_date completely, so even an Mso pin has no effect. A dated constraint without constraint_date is invalid input in strict mode, whatever apply_constraints says. ConstraintType::is_dated tells you which types need the date.

Holding a task back with a dated constraint#

use plen_core::{
    schedule, ConstraintType, DateTime, ProjectData, SchedulingConfig, SolveOptions, TaskData,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let monday = DateTime::from_ymd_hm(2026, 3, 2, 0, 0).ok_or("invalid date")?;
    let thursday = DateTime::from_ymd_hm(2026, 3, 5, 0, 0).ok_or("invalid date")?;

    let data = ProjectData {
        config: SchedulingConfig { apply_constraints: true, ..SchedulingConfig::default() },
        tasks: vec![TaskData {
            start_date: Some(monday),
            duration: Some(2.0),
            constraint_type: Some(ConstraintType::Snet),
            constraint_date: Some(thursday),
            ..TaskData::new("install")
        }],
        ..ProjectData::default()
    };

    let options = SolveOptions { reasons: true, ..SolveOptions::default() };
    let result = schedule(data, &options)?;
    for update in &result.updates {
        if let (Some(start), Some(end)) = (update.start_date, update.end_date) {
            println!("{}: {start} .. {end} ({:?})", update.id, update.reason);
        }
    }
    Ok(())
}

install moves from Monday to Thursday, 2026-03-05 00:00 .. 2026-03-07 00:00, with Reason::Constraint. The end is Saturday 00:00, which is the close of Friday in day units.

When a link and a hard constraint disagree, the constraint wins. The task keeps the constrained date, and the result reports the clash as Conflict::ConstraintViolation:

use plen_core::{
    schedule, Conflict, ConstraintType, DateTime, LinkData, LinkType, ProjectData,
    SchedulingConfig, SolveOptions, TaskData,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let monday = DateTime::from_ymd_hm(2026, 3, 2, 0, 0).ok_or("invalid date")?;
    let tuesday = DateTime::from_ymd_hm(2026, 3, 3, 0, 0).ok_or("invalid date")?;

    let data = ProjectData {
        config: SchedulingConfig { apply_constraints: true, ..SchedulingConfig::default() },
        tasks: vec![
            TaskData { start_date: Some(monday), duration: Some(3.0), ..TaskData::new("build") },
            TaskData {
                start_date: Some(tuesday),
                duration: Some(1.0),
                constraint_type: Some(ConstraintType::Mso),
                constraint_date: Some(tuesday),
                ..TaskData::new("demo")
            },
        ],
        links: vec![LinkData {
            id: 1.into(),
            source: "build".into(),
            target: "demo".into(),
            link_type: LinkType::Fs,
            lag: 0.0,
            lag_unit: None,
        }],
        ..ProjectData::default()
    };

    let result = schedule(data, &SolveOptions::default())?;
    for conflict in &result.conflicts {
        if let Conflict::ConstraintViolation { task, constraint, violation, required, actual } = conflict {
            println!("{task}: {constraint:?} {violation:?}, required {required}, actual {actual}");
        }
    }
    Ok(())
}

Output:

demo: Mso LatestStart, required 2026-03-03 00:00, actual 2026-03-05 00:00

build finishes on Wednesday, so the link wants demo to start on Thursday. The Mso pin keeps it on Tuesday, updates is empty, and the Violation says which bound broke: here the latest allowed start.

Placing a task as late as possible#

Alap moves a task as close to its successors as the links allow, even in a forward-scheduled project:

use plen_core::{
    schedule, ConstraintType, DateTime, LinkData, LinkType, ProjectData, SchedulingConfig,
    SolveOptions, TaskData,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let monday = DateTime::from_ymd_hm(2026, 3, 2, 0, 0).ok_or("invalid date")?;
    let friday = DateTime::from_ymd_hm(2026, 3, 6, 0, 0).ok_or("invalid date")?;

    let data = ProjectData {
        config: SchedulingConfig { apply_constraints: true, ..SchedulingConfig::default() },
        tasks: vec![
            TaskData {
                start_date: Some(monday),
                duration: Some(1.0),
                constraint_type: Some(ConstraintType::Alap),
                ..TaskData::new("order-food")
            },
            TaskData {
                start_date: Some(friday),
                duration: Some(1.0),
                constraint_type: Some(ConstraintType::Mso),
                constraint_date: Some(friday),
                ..TaskData::new("party")
            },
        ],
        links: vec![LinkData {
            id: 1.into(),
            source: "order-food".into(),
            target: "party".into(),
            link_type: LinkType::Fs,
            lag: 0.0,
            lag_unit: None,
        }],
        ..ProjectData::default()
    };

    let result = schedule(data, &SolveOptions::default())?;
    for update in &result.updates {
        if let (Some(start), Some(end)) = (update.start_date, update.end_date) {
            println!("{}: {start} .. {end}", update.id);
        }
    }
    Ok(())
}

order-food moves from Monday to Thursday, 2026-03-05 .. 2026-03-06, the last day before party. For a whole project planned backward from a deadline, set config.schedule_from_end and config.project_end instead. See SchedulingConfig.

Inheriting a summary's constraint#

With config.project_constraint on, a task without its own constraint inherits the nearest ancestor's:

use plen_core::{
    schedule, ConstraintType, DateTime, ProjectData, SchedulingConfig, SolveOptions, TaskData,
    TaskType,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let monday = DateTime::from_ymd_hm(2026, 3, 2, 0, 0).ok_or("invalid date")?;
    let wednesday = DateTime::from_ymd_hm(2026, 3, 4, 0, 0).ok_or("invalid date")?;

    let data = ProjectData {
        config: SchedulingConfig {
            apply_constraints: true,
            project_constraint: true,
            ..SchedulingConfig::default()
        },
        tasks: vec![
            TaskData {
                task_type: TaskType::Summary,
                constraint_type: Some(ConstraintType::Snet),
                constraint_date: Some(wednesday),
                ..TaskData::new("phase")
            },
            TaskData {
                parent: Some("phase".into()),
                start_date: Some(monday),
                duration: Some(1.0),
                ..TaskData::new("step")
            },
        ],
        ..ProjectData::default()
    };

    let result = schedule(data, &SolveOptions::default())?;
    for update in &result.updates {
        if let (Some(start), Some(end)) = (update.start_date, update.end_date) {
            println!("{} ({:?}): {start} .. {end}", update.id, update.kind);
        }
    }
    Ok(())
}

step moves to Wednesday, and the rolled-up phase follows it as a MutationKind::Summary update. With project_constraint: false, nothing in this example moves. config.group_mode turns inheritance off again. That's the only thing group_mode does.

Note

dhtmlxGantt's JS engine accepts project_constraint but never applies the inherited constraint to dates. Planning Engine follows the documented intent, so this is one of the three declared date differences from gantt. Leave project_constraint off for gantt's behaviour.

Gap behaviour#

config.gap_behavior decides what happens to the free time between linked tasks:

  • GapBehavior::Preserve (default) keeps each task at its preferred dates as long as its links allow them. A link only pushes a task when the task would otherwise start too early.
  • GapBehavior::Compress pulls every task to the earliest position its links and constraints allow, closing the gaps.

Keeping or closing a gap#

The same data, scheduled with both settings:

use plen_core::{
    schedule, DateTime, GapBehavior, LinkData, LinkType, ProjectData, SchedulingConfig,
    SolveOptions, TaskData,
};

fn plan(gap_behavior: GapBehavior) -> Result<(), Box<dyn std::error::Error>> {
    let monday = DateTime::from_ymd_hm(2026, 3, 2, 0, 0).ok_or("invalid date")?;
    let thursday = DateTime::from_ymd_hm(2026, 3, 5, 0, 0).ok_or("invalid date")?;

    let data = ProjectData {
        config: SchedulingConfig { gap_behavior, ..SchedulingConfig::default() },
        tasks: vec![
            TaskData { start_date: Some(monday), duration: Some(1.0), ..TaskData::new("design") },
            TaskData { start_date: Some(thursday), duration: Some(1.0), ..TaskData::new("review") },
        ],
        links: vec![LinkData {
            id: 1.into(),
            source: "design".into(),
            target: "review".into(),
            link_type: LinkType::Fs,
            lag: 0.0,
            lag_unit: None,
        }],
        ..ProjectData::default()
    };

    let options = SolveOptions { include_unchanged: true, ..SolveOptions::default() };
    let result = schedule(data, &options)?;
    for update in &result.updates {
        if let Some(start) = update.start_date {
            println!("{gap_behavior:?} {}: starts {start}", update.id);
        }
    }
    Ok(())
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    plan(GapBehavior::Preserve)?;
    plan(GapBehavior::Compress)?;
    Ok(())
}

Output:

Preserve design: starts 2026-03-02 00:00
Preserve review: starts 2026-03-05 00:00
Compress design: starts 2026-03-02 00:00
Compress review: starts 2026-03-03 00:00

include_unchanged lists every task, including the ones that didn't move. Under Preserve, review stays on Thursday because the link only requires it to start after Monday. Under Compress, it moves to Tuesday.

Compress also changes how summaries behave: it always expands summary links onto leaves, as described in the next section.

Summary tasks#

A summary (TaskType::Summary, or "project" on the gantt wire format) groups child tasks through their parent field. Its span is always rolled up from its children, and changed summaries appear in updates with kind: MutationKind::Summary. Write them back to your model like any other update.

config.move_projects picks one of two modes for links that touch a summary:

  • Block mode (move_projects: true, the default, together with GapBehavior::Preserve): the summary moves as one block. A link into the summary shifts the whole block, and every child keeps its offset inside it.
  • Leaf expansion (move_projects: false, or any project with GapBehavior::Compress): the link is expanded onto the summary's leaf tasks, and each leaf is scheduled on its own.

Inside a moving block, some children stay put: a child pinned by a dated constraint, a child with auto_scheduling: false, and, with config.use_progress, a completed child. A summary with auto_scheduling: false is a fixed boundary that keeps its explicit dates. In leaf-expansion mode its links are dropped with a Note::DroppedLink.

Moving a summary as a block#

use plen_core::{
    schedule, DateTime, LinkData, LinkType, ProjectData, SchedulingConfig, SolveOptions, TaskData,
    TaskType,
};

fn plan(move_projects: bool) -> Result<(), Box<dyn std::error::Error>> {
    let monday = DateTime::from_ymd_hm(2026, 3, 2, 0, 0).ok_or("invalid date")?;
    let tuesday = DateTime::from_ymd_hm(2026, 3, 3, 0, 0).ok_or("invalid date")?;

    let data = ProjectData {
        config: SchedulingConfig { move_projects, ..SchedulingConfig::default() },
        tasks: vec![
            TaskData { start_date: Some(monday), duration: Some(3.0), ..TaskData::new("prep") },
            TaskData { task_type: TaskType::Summary, ..TaskData::new("phase") },
            TaskData {
                parent: Some("phase".into()),
                start_date: Some(monday),
                duration: Some(1.0),
                ..TaskData::new("a")
            },
            TaskData {
                parent: Some("phase".into()),
                start_date: Some(tuesday),
                duration: Some(1.0),
                ..TaskData::new("b")
            },
        ],
        links: vec![LinkData {
            id: 1.into(),
            source: "prep".into(),
            target: "phase".into(),
            link_type: LinkType::Fs,
            lag: 0.0,
            lag_unit: None,
        }],
        ..ProjectData::default()
    };

    let options = SolveOptions { reasons: true, ..SolveOptions::default() };
    let result = schedule(data, &options)?;
    for update in &result.updates {
        if let (Some(start), Some(end)) = (update.start_date, update.end_date) {
            println!("{} {:?}: {start} .. {end} {:?}", update.id, update.kind, update.reason);
        }
    }
    Ok(())
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    plan(true)?;
    plan(false)?;
    Ok(())
}

The phase summary has no dates of its own, so its span comes from a and b. With move_projects: true the output is:

a Task: 2026-03-05 00:00 .. 2026-03-06 00:00 Some(SummaryTranslation { summary_id: Str("phase") })
b Task: 2026-03-06 00:00 .. 2026-03-07 00:00 Some(SummaryTranslation { summary_id: Str("phase") })
phase Summary: 2026-03-05 00:00 .. 2026-03-07 00:00 Some(Asap { link_id: Num(1.0), source_id: Str("prep") })

The link pushes phase to Thursday, and a and b move with it, one day apart as before. With move_projects: false the link lands on each leaf. Both a and b start on Thursday with Reason::Asap, and phase shrinks to one day.

Keeping completed work in place#

With config.use_progress, a child with progress: 1.0 is completed and doesn't move with its block:

use plen_core::{
    schedule, DateTime, LinkData, LinkType, ProjectData, SchedulingConfig, SolveOptions, TaskData,
    TaskType,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let monday = DateTime::from_ymd_hm(2026, 3, 2, 0, 0).ok_or("invalid date")?;
    let tuesday = DateTime::from_ymd_hm(2026, 3, 3, 0, 0).ok_or("invalid date")?;

    let data = ProjectData {
        config: SchedulingConfig { use_progress: true, ..SchedulingConfig::default() },
        tasks: vec![
            TaskData { start_date: Some(monday), duration: Some(3.0), ..TaskData::new("prep") },
            TaskData { task_type: TaskType::Summary, ..TaskData::new("phase") },
            TaskData {
                parent: Some("phase".into()),
                start_date: Some(monday),
                duration: Some(1.0),
                progress: 1.0,
                ..TaskData::new("done")
            },
            TaskData {
                parent: Some("phase".into()),
                start_date: Some(tuesday),
                duration: Some(1.0),
                ..TaskData::new("open")
            },
        ],
        links: vec![LinkData {
            id: 1.into(),
            source: "prep".into(),
            target: "phase".into(),
            link_type: LinkType::Fs,
            lag: 0.0,
            lag_unit: None,
        }],
        ..ProjectData::default()
    };

    let result = schedule(data, &SolveOptions::default())?;
    for update in &result.updates {
        if let (Some(start), Some(end)) = (update.start_date, update.end_date) {
            println!("{}: {start} .. {end}", update.id);
        }
    }
    Ok(())
}

Only open moves, to 2026-03-06 .. 2026-03-07. done stays on Monday, and the summary's span stretches to cover both.

Note

dhtmlxGantt's JS engine drags a completed child along with its moving summary. Planning Engine keeps it fixed, because "completed work doesn't move" is what use_progress promises. This is one of the three declared date differences from gantt.

Linking a summary to its own children#

By default, a link between a summary and one of its descendants is dropped:

use plen_core::{
    schedule, DateTime, LinkData, LinkType, Note, ProjectData, SolveOptions, TaskData, TaskType,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let monday = DateTime::from_ymd_hm(2026, 3, 2, 0, 0).ok_or("invalid date")?;

    let data = ProjectData {
        tasks: vec![
            TaskData { task_type: TaskType::Summary, ..TaskData::new("phase") },
            TaskData {
                parent: Some("phase".into()),
                start_date: Some(monday),
                duration: Some(1.0),
                ..TaskData::new("kickoff")
            },
        ],
        links: vec![LinkData {
            id: "l1".into(),
            source: "phase".into(),
            target: "kickoff".into(),
            link_type: LinkType::Ss,
            lag: 0.0,
            lag_unit: None,
        }],
        ..ProjectData::default()
    };

    let result = schedule(data, &SolveOptions::default())?;
    for note in &result.notes {
        if let Note::DroppedLink { link, reason } = note {
            println!("link {link} dropped: {reason}");
        }
    }
    Ok(())
}

Output:

link l1 dropped: link between a summary and its own descendant (descendant_links disabled)

Set config.descendant_links: true to keep such links. In block mode the JS engine fails on this setup with a cycle error, while Planning Engine schedules it. That's the third declared difference from gantt.

To reschedule only one summary and the tasks that depend on it, see Reschedule a branch.

Cycles#

A dependency cycle (A waits for B, B waits for A) has no valid schedule. Planning Engine doesn't treat it as an error. A cyclic run:

  • leaves every date unchanged and returns empty updates
  • lists each unique simple cycle in result.cycles as a Cycle with its tasks and links
  • returns analytics with AnalyticsStatus::Invalid if you asked for any
  • still installs a baseline on a Project, so you can edit the data to remove the cycle

Cycle detection covers fixed and completed tasks and summary containment too. A self-link is a one-task cycle. A cycle in the parent hierarchy is different. It's invalid input and fails validation.

Reporting cycles#

use plen_core::{
    AnalyticsOptions, DateTime, LinkData, LinkType, Project, ProjectData, ProjectOptions,
    SolveOptions, TaskData,
};

fn link(id: &str, source: &str, target: &str) -> LinkData {
    LinkData {
        id: id.into(),
        source: source.into(),
        target: target.into(),
        link_type: LinkType::Fs,
        lag: 0.0,
        lag_unit: None,
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let monday = DateTime::from_ymd_hm(2026, 3, 2, 0, 0).ok_or("invalid date")?;

    let data = ProjectData {
        tasks: vec![
            TaskData { start_date: Some(monday), duration: Some(1.0), ..TaskData::new("a") },
            TaskData { start_date: Some(monday), duration: Some(1.0), ..TaskData::new("b") },
            TaskData { start_date: Some(monday), duration: Some(1.0), ..TaskData::new("c") },
        ],
        links: vec![link("ab", "a", "b"), link("ba", "b", "a"), link("bc", "b", "c")],
        ..ProjectData::default()
    };

    let mut project = Project::new(ProjectOptions::default());
    let options = SolveOptions {
        analytics: AnalyticsOptions { critical_path: true, ..AnalyticsOptions::default() },
        ..SolveOptions::default()
    };
    let result = project.solve(Some(data), &options)?;

    println!("updates: {}", result.updates.len());
    for cycle in &result.cycles {
        let tasks: Vec<String> = cycle.tasks.iter().map(|id| id.to_string()).collect();
        let links: Vec<String> = cycle.links.iter().map(|id| id.to_string()).collect();
        println!("cycle: tasks {tasks:?}, links {links:?}");
    }
    if let Some(analytics) = &result.analytics {
        println!("analytics: {:?}", analytics.status);
    }
    Ok(())
}

Output:

updates: 0
cycle: tasks ["a", "b"], links ["ab", "ba"]
analytics: Invalid

Link bc isn't part of the cycle, but c doesn't move either: a cyclic run schedules nothing.

Breaking a cycle#

Remove one of the cycle's links with apply, then solve the current state again:

use plen_core::{
    Changes, DateTime, LinkData, LinkType, Project, ProjectData, ProjectOptions,
    SchedulingChanges, SolveOptions, TaskData,
};

fn link(id: &str, source: &str, target: &str) -> LinkData {
    LinkData {
        id: id.into(),
        source: source.into(),
        target: target.into(),
        link_type: LinkType::Fs,
        lag: 0.0,
        lag_unit: None,
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let monday = DateTime::from_ymd_hm(2026, 3, 2, 0, 0).ok_or("invalid date")?;

    let data = ProjectData {
        tasks: vec![
            TaskData { start_date: Some(monday), duration: Some(1.0), ..TaskData::new("a") },
            TaskData { start_date: Some(monday), duration: Some(1.0), ..TaskData::new("b") },
        ],
        links: vec![link("ab", "a", "b"), link("ba", "b", "a")],
        ..ProjectData::default()
    };

    let mut project = Project::new(ProjectOptions::default());
    let result = project.solve(Some(data), &SolveOptions::default())?;

    if let Some(cycle) = result.cycles.first() {
        let culprit = cycle.links.last().cloned().ok_or("cycle without links")?;
        let changes = SchedulingChanges {
            links: Changes { remove: vec![culprit], ..Changes::default() },
            ..SchedulingChanges::default()
        };
        project.apply(&changes)?;

        let result = project.solve(None, &SolveOptions::default())?;
        println!("cycles: {}", result.cycles.len());
        for update in &result.updates {
            if let Some(start) = update.start_date {
                println!("{} starts {start}", update.id);
            }
        }
    }
    Ok(())
}

After ba is removed, the project is acyclic again and b moves to Tuesday, after a. You decide which link to remove. The engine only reports the cycle. For more on apply and partial edits, see Data Updates.

See also#