Every scheduling call hands back a ScheduleResult. This guide shows how to write the new dates back into your own model, explain why tasks moved, and handle constraint conflicts, dependency cycles and dropped records. It also covers the baseline token and turning results into JSON.
Rules the plan breaks, loops in the links, records left out, whether the planner settled
Extras
analytics, overallocations, profile
Critical path and slack, resource overload, load profile (each only when requested or relevant)
Identity
baseline_id
Which accepted baseline the result belongs to
A few rules hold for every result:
Updates are sparse.updates lists only the tasks whose dates changed, and each Mutation carries only the fields that changed. A task that didn't move isn't there. Set SolveOptions::include_unchanged when you need every task's dates, for example on the first load.
Problems are data, not errors. A plan with conflicts, cycles or dropped links still returns Ok. Only invalid input and calls that don't fit the project's state return an Err. See Error handling.
Order is stable. The same input and options always produce the same result, in the same order. The one exception is baseline_id, which is an opaque token.
Updates match the input names. A Mutation uses the same field names as TaskData and TaskUpdate: start_date, end_date, duration, leveling_delay, pauses. You can copy a mutation field by field into a TaskUpdate.
Project::solve returns Arc<ScheduleResult>, because the project caches its last result and shares it. Read it like a plain ScheduleResult; the Arc derefs to it.
2 (Task): start Some("2026-08-12 08:00"), end Some("2026-08-13 08:00"), duration None
Task 1 is missing because it didn't move. Task 2 moved, so it reports a new start and end. Its duration is None because the working time didn't change.
kind is a MutationKind: Task, Summary or Milestone. Summaries appear in updates too, because their spans roll up from their children. leveling_delay and pauses only appear in results from Project::level, and only when leveling changed them. Leveling resources covers how to write them back.
To get every task's dates instead of the changed ones, see Load all task dates.
Turn on SolveOptions::reasons to learn why each task ended up where it did. Each update then carries a Reason:
useplen_core::{
schedule, DateTime, LinkData, LinkType, ProjectData, Reason, SolveOptions, TaskData,};fnlink(id:i32, source:i32, target:i32)-> LinkData{
LinkData {
id: id.into(),
source: source.into(),
target: target.into(),
link_type:LinkType::Fs,
lag:0.0,
lag_unit:None,}}fnmain()->Result<(), Box<dyn std::error::Error>>{let monday =DateTime::from_ymd_hm(2026,8,10,8,0).ok_or("invalid date")?;letmut tasks =Vec::new();for id in1..=3{letmut task =TaskData::new(id);
task.start_date =Some(monday);
task.duration =Some(1.0);
tasks.push(task);}let data = ProjectData {
tasks,
links:vec![link(10,1,2),link(11,2,3)],..Default::default()};let options = SolveOptions {
reasons:true,..Default::default()};let result =schedule(data,&options)?;for update in&result.updates {let why =match&update.reason {Some(Reason::Asap { link_id, source_id })=>{format!("pushed by task {source_id} through link {link_id}")}Some(Reason::Alap { link_id, target_id })=>{format!("pulled by task {target_id} through link {link_id}")}Some(Reason::SummaryTranslation { summary_id })=>{format!("moved with summary {summary_id}")}Some(Reason::Constraint)=>"placed by its constraint".to_string(),Some(Reason::Preferred)=>"kept at its preferred date".to_string(),None=>"no reason recorded".to_string(),};println!("task {}: {}", update.id, why);}Ok(())}
Output:
task 2: pushed by task 1 through link 10
task 3: pushed by task 2 through link 11
The variants:
Asap { link_id, source_id }: a predecessor link pushed the task later.
Alap { link_id, target_id }: a successor link pulled the task earlier (backward planning).
SummaryTranslation { summary_id }: the task moved as part of its summary block.
Constraint: the task's own constraint placed it.
Preferred: the task stayed at, or returned to, its own date.
PreviewOptions has the same reasons flag, so a drag preview can explain itself too. A cyclic solve reports no reasons, because the planner doesn't run.
A conflict is a rule the scheduled plan breaks. It doesn't stop scheduling: the dates are still valid output, and conflicts lists what doesn't fit. Conflict has two variants:
ConstraintViolation { task, constraint, violation, required, actual }: a task's constraint and its links disagree. violation is a Violation that names the bound that broke: EarliestStart, LatestStart, EarliestEnd or LatestEnd.
CalendarNonWorking { task, proposed, snapped }: with config.strict_calendar, a task moved with its summary block landed on non-working time.
Constraints only act when config.apply_constraints is true. In this example, task 2 must start no later than Tuesday, but its predecessor runs until Wednesday:
useplen_core::{
schedule, Conflict, ConstraintType, DateTime, LinkData, LinkType, ProjectData, SolveOptions,
TaskData,};fnmain()->Result<(), Box<dyn std::error::Error>>{let monday =DateTime::from_ymd_hm(2026,8,10,8,0).ok_or("invalid date")?;let tuesday =DateTime::from_ymd_hm(2026,8,11,8,0).ok_or("invalid date")?;letmut design =TaskData::new(1);
design.start_date =Some(monday);
design.duration =Some(2.0);// Build must start no later than Tuesday, but design runs until Wednesday.
letmut build =TaskData::new(2);
build.start_date =Some(monday);
build.duration =Some(1.0);
build.constraint_type =Some(ConstraintType::Snlt);
build.constraint_date =Some(tuesday);letmut data = ProjectData {
tasks:vec![design, build],
links:vec![LinkData {
id:1.into(),
source:1.into(),
target:2.into(),
link_type:LinkType::Fs,
lag:0.0,
lag_unit:None,}],..Default::default()};
data.config.apply_constraints =true;let result =schedule(data,&SolveOptions::default())?;for conflict in&result.conflicts {match conflict {Conflict::ConstraintViolation { task, constraint, violation, required, actual }=>{println!("task {task}: {} violated ({violation:?}), required {required}, actual {actual}",
constraint.as_str());}Conflict::CalendarNonWorking { task, proposed, snapped }=>{println!("task {task}: lands on non-working time at {proposed} (snapped {snapped})");}}}for update in&result.updates {println!("task {}: start {:?}", update.id, update.start_date.map(|d|d.to_string()));}Ok(())}
The upper-bound constraint wins: task 2 starts on Tuesday, at required. actual is where the link wanted it to start. Show both dates to the user, so they can relax the constraint or move the predecessor. Links, constraints and summaries explains how each constraint type resolves.
A dependency cycle (task 1 waits for task 2, which waits for task 1) has no valid schedule. Planning Engine doesn't fail on it. It returns the dates unchanged, leaves updates empty and lists every unique simple cycle in cycles. Each Cycle holds the task ids and link ids that form the loop.
notes lists records the scheduler left out or changed. A Note is one of:
DroppedLink { link, reason }: a link that can't apply, for example one that points to an unscheduled task.
DroppedTask { task, reason }: an unscheduled task.
DroppedAssignment { assignment, reason }: an assignment that doesn't take part.
Highlight the links in cycle.links so the user can break the loop. A cyclic solve still installs a baseline. If you requested analytics, they come back with AnalyticsStatus::Invalid.
converged is false when the planner didn't reach a stable state within config.max_iterations (default 8). The dates are still a valid result, so treat it as a warning rather than a failure.
To clean up messy input instead of rejecting it, see Accept messy input.
useplen_core::{
DateTime, Error, LinkData, LinkType, Patch, PreviewOptions, Project, ProjectData, ProjectError,
ProjectOptions, SchedulingChanges, SolveOptions, TaskData, TaskUpdate,};fnmain()->Result<(), Box<dyn std::error::Error>>{let monday =DateTime::from_ymd_hm(2026,8,10,8,0).ok_or("invalid date")?;letmut design =TaskData::new(1);
design.start_date =Some(monday);
design.duration =Some(2.0);letmut build =TaskData::new(2);
build.start_date =Some(monday);
build.duration =Some(1.0);let data = ProjectData {
tasks:vec![design, build],
links:vec![LinkData {
id:1.into(),
source:1.into(),
target:2.into(),
link_type:LinkType::Fs,
lag:0.0,
lag_unit:None,}],..Default::default()};letmut project =Project::new(ProjectOptions::default());let loaded = project.solve(Some(data),&SolveOptions::default())?;// Keep the token next to the dates the UI shows.
let shown = loaded.baseline_id.clone();letmut stretch =TaskUpdate::new(1);
stretch.duration =Patch::Value(3.0);letmut changes =SchedulingChanges::default();
changes.tasks.update.push(stretch);let guarded = PreviewOptions {
baseline_id: shown.clone(),..Default::default()};let preview = project.preview(&changes,&guarded)?;println!("preview read the shown baseline: {}", preview.baseline_id == shown);// Someone else commits an edit; the accepted baseline changes.
project.apply(&changes)?;let committed = project.solve(None,&SolveOptions::default())?;println!("token changed: {}", committed.baseline_id != shown);match project.preview(&changes,&guarded){Err(Error::Project(ProjectError::StaleBaseline))=>{println!("stale: reload the dates before previewing again");}Err(other)=>returnErr(other.into()),Ok(_)=>println!("unexpected: preview accepted a stale token"),}Ok(())}
Output:
preview read the shown baseline: true
token changed: true
stale: reload the dates before previewing again
The token is opaque. Don't parse it, don't store it as a persistent id, and don't compare tokens across runs: the same data solved twice may get two different tokens. Only compare it with the current one, and always take it from the latest result. Data Updates shows the full edit cycle.
Result fields are camelCase: baselineId, conflicts, criticalPath.
Update entries keep the snake_case task-data names: start_date, end_date, leveling_delay. A client can send an entry back as a task update.
Reason, Conflict and Note become objects tagged by kind in kebab-case: "constraint-violation", "summary-translation", "dropped-link". Their fields are camelCase (linkId, sourceId).
Plain enums become kebab-case strings: "task", "latest-start", "snlt".
Dates are "YYYY-MM-DD HH:MM" strings. Ids stay numbers or strings, as in the input.
Empty optional fields are left out, not written as null.
baselineId differs from run to run. LevelingResult serializes with the ScheduleResult fields flattened next to rounds.