[Canonical page](<https://docs.dhtmlx.com/planning-engine/rust/scheduling/reading-results/>)
[Full documentation index](<https://docs.dhtmlx.com/planning-engine/llms.txt>)


# Reading results

Every scheduling call hands back a [`ScheduleResult`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/schedule-result.md>). 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.

## How a result is organized

[`ScheduleResult`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/schedule-result.md>) is the output of [`schedule`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/schedule.md>), [`Project::solve`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/project.md#solve>), [`Project::preview`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/project.md#preview>) and [`Project::analyze`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/project.md#analyze>). [`Project::level`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/project.md#level>) wraps one in [`LevelingResult::schedule`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/resources/leveling-result.md>). Its fields fall into four groups:

| Group | Fields | What it tells you |
|---|---|---|
| Dates | `updates` | The tasks that changed, with their new fields |
| Problems | `conflicts`, `cycles`, `notes`, `converged` | 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`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/mutation.md>) carries only the fields that changed. A task that didn't move isn't there. Set [`SolveOptions::include_unchanged`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/solve-options.md#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](<https://docs.dhtmlx.com/planning-engine/rust/error-handling.md>).
- **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`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/task-data.md>) and [`TaskUpdate`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/changes/task-update.md>): `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.

## Updates

Read `updates` after each call and copy the changed fields into your model. Every field is an `Option`, so `None` means "unchanged", not "cleared".

```rust
use std::collections::HashMap;
use plen_core::{schedule, DateTime, Id, LinkData, LinkType, ProjectData, SolveOptions, TaskData};

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

    let mut design = TaskData::new(1);
    design.start_date = Some(monday);
    design.duration = Some(2.0);

    let mut build = TaskData::new(2);
    build.start_date = Some(monday);
    build.duration = Some(1.0);

    // The caller's own copy of the dates: id -> (start, duration).
    let mut model: HashMap<Id, (DateTime, f64)> = HashMap::new();
    model.insert(1.into(), (monday, 2.0));
    model.insert(2.into(), (monday, 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()
    };

    let result = schedule(data, &SolveOptions::default())?;
    for update in &result.updates {
        println!(
            "{} ({:?}): start {:?}, end {:?}, duration {:?}",
            update.id,
            update.kind,
            update.start_date.map(|d| d.to_string()),
            update.end_date.map(|d| d.to_string()),
            update.duration,
        );
        if let Some((start, duration)) = model.get_mut(&update.id) {
            if let Some(new_start) = update.start_date {
                *start = new_start;
            }
            if let Some(new_duration) = update.duration {
                *duration = new_duration;
            }
        }
    }
    Ok(())
}
```

Output:

```
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`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/mutation-kind.md>): `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`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/project.md#level>), and only when leveling changed them. [Leveling resources](<https://docs.dhtmlx.com/planning-engine/rust/resources-and-assignment/leveling-resources.md>) covers how to write them back.

To get every task's dates instead of the changed ones, see [Load all task dates](<https://docs.dhtmlx.com/planning-engine/rust/how-to/load-all-task-dates.md>).

## Reasons

Turn on [`SolveOptions::reasons`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/solve-options.md>) to learn why each task ended up where it did. Each update then carries a [`Reason`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/reason.md>):

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

fn link(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,
    }
}

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

    let mut tasks = Vec::new();
    for id in 1..=3 {
        let mut 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`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/preview-options.md>) 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.

## Conflicts

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`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/conflict.md>) has two variants:

- `ConstraintViolation { task, constraint, violation, required, actual }`: a task's constraint and its links disagree. `violation` is a [`Violation`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/violation.md>) 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:

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

fn main() -> 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")?;

    let mut 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.
    let mut 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);

    let mut 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(())
}
```

Output:

```
task 2: snlt violated (LatestStart), required 2026-08-11 08:00, actual 2026-08-12 08:00
task 2: start Some("2026-08-11 08:00")
```

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](<https://docs.dhtmlx.com/planning-engine/rust/scheduling/links-constraints-summaries.md>) explains how each constraint type resolves.

## Cycles and notes

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`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/cycle.md>) holds the task ids and link ids that form the loop.

`notes` lists records the scheduler left out or changed. A [`Note`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/note.md>) 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.
- `Repaired { path, message }`: a fix applied under [`Validation::Repair`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/validation.md>).

This example has a cycle between tasks 1 and 2, plus a parked task 3:

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

fn link(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,
    }
}

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

    let mut tasks = Vec::new();
    for id in 1..=3 {
        let mut task = TaskData::new(id);
        task.start_date = Some(monday);
        task.duration = Some(1.0);
        tasks.push(task);
    }
    // Task 3 is parked: it isn't scheduled, and neither is the link to it.
    tasks[2].unscheduled = true;

    let data = ProjectData {
        tasks,
        // 1 -> 2 -> 1 is a cycle.
        links: vec![link(10, 1, 2), link(11, 2, 1), link(12, 2, 3)],
        ..Default::default()
    };

    let result = schedule(data, &SolveOptions::default())?;
    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 [{}], links [{}]", tasks.join(", "), links.join(", "));
    }
    println!("updates: {}", result.updates.len());

    for note in &result.notes {
        match note {
            Note::DroppedLink { link, reason } => println!("link {link} dropped: {reason}"),
            Note::DroppedTask { task, reason } => println!("task {task} dropped: {reason}"),
            Note::DroppedAssignment { assignment, reason } => {
                println!("assignment {assignment} dropped: {reason}")
            }
            Note::Repaired { path, message } => println!("repaired {path}: {message}"),
        }
    }
    if !result.converged {
        println!("the planner stopped before a fixed point");
    }
    Ok(())
}
```

Output:

```
cycle: tasks [1, 2], links [10, 11]
updates: 0
task 3 dropped: unscheduled
link 12 dropped: target task "3" is unscheduled
```

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`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/analytics-status.md>).

`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](<https://docs.dhtmlx.com/planning-engine/rust/how-to/accept-messy-input.md>).

## Baseline token

`baseline_id` names the accepted baseline of a [`Project`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/project.md>): the dates the last [`solve`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/project.md#solve>) or [`level`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/project.md#level>) accepted. These calls fill it in:

- `solve` and `level`: the baseline they just accepted.
- [`preview`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/project.md#preview>): the baseline it read.
- [`analyze(None, ..)`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/project.md#analyze>) on a solved project with no pending edits: the current baseline. Otherwise `None`.
- [`schedule`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/schedule.md>): sets it too, but the project behind the call is gone afterwards, so the token has no use.

The token changes whenever the data changes: new data, an [`apply`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/project.md#apply>), a solve that moves dates, or leveling edits. Keep it next to the dates you display. Pass it back in [`PreviewOptions::baseline_id`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/preview-options.md>) or [`SchedulingChanges::baseline_id`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/changes/scheduling-changes.md>), and the call fails with [`ProjectError::StaleBaseline`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/errors/project-error.md>) if the project moved on in the meantime:

```rust
use plen_core::{
    DateTime, Error, LinkData, LinkType, Patch, PreviewOptions, Project, ProjectData, ProjectError,
    ProjectOptions, SchedulingChanges, SolveOptions, TaskData, TaskUpdate,
};

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

    let mut design = TaskData::new(1);
    design.start_date = Some(monday);
    design.duration = Some(2.0);

    let mut 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()
    };

    let mut 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();

    let mut stretch = TaskUpdate::new(1);
    stretch.duration = Patch::Value(3.0);
    let mut 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) => return Err(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](<https://docs.dhtmlx.com/planning-engine/rust/live-projects/editing-a-project.md>) shows the full edit cycle.

## Serializing results

[`ScheduleResult`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/schedule-result.md>) and the other scheduling result types implement `serde::Serialize` and `serde::Deserialize` behind the `serde` feature:

```bash
cargo add plen-core --features serde
cargo add serde_json
```

Then serialize the result with any serde format:

```rust
use plen_core::{
    schedule, ConstraintType, DateTime, LinkData, LinkType, ProjectData, ScheduleResult,
    SolveOptions, TaskData,
};

fn main() -> 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")?;

    let mut design = TaskData::new(1);
    design.start_date = Some(monday);
    design.duration = Some(2.0);

    let mut 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);

    let mut 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 options = SolveOptions {
        reasons: true,
        ..Default::default()
    };
    let result = schedule(data, &options)?;

    let json = serde_json::to_string_pretty(&result)?;
    println!("{json}");

    // The same shape reads back into a ScheduleResult.
    let back: ScheduleResult = serde_json::from_str(&json)?;
    println!("round trip equal: {}", back == result);
    Ok(())
}
```

Output:

```json
{
  "baselineId": "1-ba73503627ba43c0",
  "updates": [
    {
      "id": 2,
      "kind": "task",
      "start_date": "2026-08-11 08:00",
      "end_date": "2026-08-12 08:00",
      "reason": {
        "kind": "constraint"
      }
    }
  ],
  "cycles": [],
  "conflicts": [
    {
      "kind": "constraint-violation",
      "task": 2,
      "constraint": "snlt",
      "violation": "latest-start",
      "required": "2026-08-11 08:00",
      "actual": "2026-08-12 08:00"
    }
  ],
  "notes": [],
  "converged": true
}
```

```
round trip equal: true
```

The JSON follows these naming rules:

- 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`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/reason.md>), [`Conflict`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/conflict.md>) and [`Note`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/note.md>) 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`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/resources/leveling-result.md>) serializes with the `ScheduleResult` fields flattened next to `rounds`.

The feature covers output only. Input records such as [`ProjectData`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/project-data.md>) and [`SchedulingChanges`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/changes/scheduling-changes.md>) don't implement `Deserialize`. To read JSON input, use the `plen-wire` crate. [Serialize results to JSON](<https://docs.dhtmlx.com/planning-engine/rust/how-to/serialize-results-to-json.md>) covers the round trip.
