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


# Reading results

Every scheduling call returns a [`ScheduleResult`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/schedule-result.md>). This guide shows how to write the new dates back into your own model and explain why tasks moved. It also covers constraint conflicts, dependency cycles, dropped records, the baseline token, and why you should treat a result as read-only.

## How a result is organized

[`ScheduleResult`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/schedule-result.md>) is what [`Engine.Schedule`](<https://docs.dhtmlx.com/planning-engine/net/api/engine-schedule.md>), [`Project.Solve`](<https://docs.dhtmlx.com/planning-engine/net/api/project-solve.md>), [`Project.Preview`](<https://docs.dhtmlx.com/planning-engine/net/api/project-preview.md>) and [`Project.Analyze`](<https://docs.dhtmlx.com/planning-engine/net/api/project-analyze.md>) return. [`Project.Level`](<https://docs.dhtmlx.com/planning-engine/net/api/project-level.md>) wraps one in [`LevelingResult.Schedule`](<https://docs.dhtmlx.com/planning-engine/net/api/core/resources/leveling-result.md>). Its properties fall into four groups:

| Group | Properties | What it tells you |
|---|---|---|
| Dates | [`Updates`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/schedule-result.md#updates>) | The tasks that changed, with their new values |
| Problems | [`Conflicts`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/schedule-result.md#conflicts>), [`Cycles`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/schedule-result.md#cycles>), [`Notes`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/schedule-result.md#notes>), [`Converged`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/schedule-result.md#converged>) | Rules the plan breaks, loops in the links, records left out, whether the planner settled |
| Extras | [`Analytics`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/schedule-result.md#analytics>), [`Overallocations`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/schedule-result.md#overallocations>), [`Profile`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/schedule-result.md#profile>) | Critical path and slack, resource overload, load profile. Each one is `null` unless you requested it or it applies |
| Identity | [`BaselineId`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/schedule-result.md#baselineid>) | Which accepted baseline the result belongs to |

These rules hold for every result:

- **Updates are sparse.** `Updates` lists only the tasks whose dates changed. Each [`Mutation`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/mutation.md>) sets only the properties that changed. A task that didn't move isn't in the list. Set [`SolveOptions.IncludeUnchanged`](<https://docs.dhtmlx.com/planning-engine/net/api/core/project/solve-options.md>) when you need every task's dates, for example on the first load.
- **Problems are data, not exceptions.** A plan with conflicts, cycles or dropped links still returns normally. Calls throw [`InputException`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/input-exception.md>) for invalid input and [`ProjectException`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/project-exception.md>) for a call that doesn't fit the project's state. See [Error handling](<https://docs.dhtmlx.com/planning-engine/net/error-handling.md>).
- **Order is stable.** The same input and options always give the same result, in the same order. `BaselineId` is the one exception, because it's an opaque token.
- **Updates use the input names.** A `Mutation` has the same property names as [`TaskData`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/task-data.md>) and [`TaskUpdate`](<https://docs.dhtmlx.com/planning-engine/net/api/core/changes/task-update.md>): `StartDate`, `EndDate`, `Duration`, `LevelingDelay`, `Pauses`. You can copy a mutation onto a `TaskUpdate` property by property.

## Updates

Read [`Updates`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/schedule-result.md#updates>) after each call and copy the changed values into your model. Every date and number property of a [`Mutation`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/mutation.md>) is nullable, and `null` means "unchanged", not "cleared".

```csharp
using Plen;

var monday = Moment.FromYmdHm(2026, 8, 10, 8, 0) ?? throw new InvalidOperationException("invalid date");

var data = new ProjectData
{
    Tasks =
    [
        new TaskData(1) { StartDate = monday, Duration = 2.0 },
        new TaskData(2) { StartDate = monday, Duration = 1.0 },
    ],
    Links = [new LinkData { Id = 1, Source = 1, Target = 2 }],
};

// The caller's own copy of the dates.
var model = new Dictionary<Id, (Moment Start, double Duration)>
{
    [1] = (monday, 2.0),
    [2] = (monday, 1.0),
};

var result = Engine.Schedule(data, new SolveOptions());
foreach (var update in result.Updates)
{
    Console.WriteLine(
        $"{update.Id} ({update.Kind}): start {update.StartDate}, end {update.EndDate}, " +
        $"duration {update.Duration?.ToString() ?? "unchanged"}");

    if (model.TryGetValue(update.Id, out var row))
    {
        if (update.StartDate is { } start)
            row.Start = start;
        if (update.Duration is { } duration)
            row.Duration = duration;
        model[update.Id] = row;
    }
}
```

Output:

```
2 (Task): start 2026-08-12 08:00, end 2026-08-13 08:00, duration unchanged
```

Task 1 isn't listed because it didn't move. Task 2 moved, so it reports a new start and end. Its `Duration` is `null` because its working time stayed the same.

`Kind` is a [`MutationKind`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/mutation.md#mutationkind>): `Task`, `Summary` or `Milestone`. Summaries show up in `Updates` too, because their spans roll up from their children. `LevelingDelay` and `Pauses` appear only in results from [`Project.Level`](<https://docs.dhtmlx.com/planning-engine/net/api/project-level.md>), and only when leveling changed them. [Leveling resources](<https://docs.dhtmlx.com/planning-engine/net/resources-and-assignment/leveling-resources.md>) shows how to write them back.

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

## Reasons

Set [`SolveOptions.Reasons`](<https://docs.dhtmlx.com/planning-engine/net/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/net/api/core/results/reason.md>). `Reason` is a closed set of nested records, so read it with a `switch` on the nested types:

```csharp
using Plen;

var monday = Moment.FromYmdHm(2026, 8, 10, 8, 0) ?? throw new InvalidOperationException("invalid date");

var data = new ProjectData
{
    Tasks =
    [
        new TaskData(1) { StartDate = monday, Duration = 1.0 },
        new TaskData(2) { StartDate = monday, Duration = 1.0 },
        new TaskData(3) { StartDate = monday, Duration = 1.0 },
    ],
    Links =
    [
        new LinkData { Id = 10, Source = 1, Target = 2 },
        new LinkData { Id = 11, Source = 2, Target = 3 },
    ],
};

var result = Engine.Schedule(data, new SolveOptions { Reasons = true });
foreach (var update in result.Updates)
{
    var why = update.Reason switch
    {
        Reason.Asap r => $"pushed by task {r.SourceId} through link {r.LinkId}",
        Reason.Alap r => $"pulled by task {r.TargetId} through link {r.LinkId}",
        Reason.SummaryTranslation r => $"moved with summary {r.SummaryId}",
        Reason.Constraint => "placed by its constraint",
        Reason.Preferred => "kept at its preferred date",
        _ => "no reason recorded",
    };
    Console.WriteLine($"task {update.Id}: {why}");
}
```

Output:

```
task 2: pushed by task 1 through link 10
task 3: pushed by task 2 through link 11
```

The variants:

- `Reason.Asap(LinkId, SourceId)`: a predecessor link pushed the task later.
- `Reason.Alap(LinkId, TargetId)`: a successor link pulled the task earlier (backward planning).
- `Reason.SummaryTranslation(SummaryId)`: the task moved as part of its summary block.
- `Reason.Constraint`: the task's own constraint placed it.
- `Reason.Preferred`: the task stayed at, or returned to, its own date.

[`PreviewOptions`](<https://docs.dhtmlx.com/planning-engine/net/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`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/schedule-result.md#conflicts>) lists what doesn't fit. [`Conflict`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/conflict.md>) has two variants:

- [`Conflict.ConstraintViolation(Task, Constraint, Violation, Required, Actual)`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/conflict.md#constraintviolation>): a task's constraint and its links disagree. [`Violation`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/conflict.md#violation>) names the bound that broke: `EarliestStart`, `LatestStart`, `EarliestEnd` or `LatestEnd`.
- [`Conflict.CalendarNonWorking(Task, Proposed, Snapped)`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/conflict.md#calendarnonworking>): with [`Config.StrictCalendar`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/scheduling-config.md#strictcalendar>), a task that moved with its summary block landed on non-working time.

Constraints act only when [`Config.ApplyConstraints`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/scheduling-config.md#applyconstraints>) is `true`. In this example, task 2 must start no later than Tuesday, but its predecessor runs until Wednesday:

```csharp
using Plen;

var monday = Moment.FromYmdHm(2026, 8, 10, 8, 0) ?? throw new InvalidOperationException("invalid date");
var tuesday = Moment.FromYmdHm(2026, 8, 11, 8, 0) ?? throw new InvalidOperationException("invalid date");

var data = new ProjectData
{
    Tasks =
    [
        new TaskData(1) { StartDate = monday, Duration = 2.0 },
        // Build must start no later than Tuesday, but design runs until Wednesday.
        new TaskData(2)
        {
            StartDate = monday,
            Duration = 1.0,
            ConstraintType = ConstraintType.Snlt,
            ConstraintDate = tuesday,
        },
    ],
    Links = [new LinkData { Id = 1, Source = 1, Target = 2 }],
};
data.Config.ApplyConstraints = true;

var result = Engine.Schedule(data, new SolveOptions());
foreach (var conflict in result.Conflicts)
{
    switch (conflict)
    {
        case Conflict.ConstraintViolation c:
            Console.WriteLine(
                $"task {c.Task}: {c.Constraint} violated ({c.Violation}), required {c.Required}, actual {c.Actual}");
            break;
        case Conflict.CalendarNonWorking c:
            Console.WriteLine($"task {c.Task}: lands on non-working time at {c.Proposed} (snapped {c.Snapped})");
            break;
    }
}
foreach (var update in result.Updates)
    Console.WriteLine($"task {update.Id}: start {update.StartDate}");
```

Output:

```
task 2: Snlt violated (LatestStart), required 2026-08-11 08:00, actual 2026-08-12 08:00
task 2: start 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/net/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 throw on it. It returns the dates unchanged, leaves [`Updates`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/schedule-result.md#updates>) empty and lists every unique simple cycle in [`Cycles`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/schedule-result.md#cycles>). Each [`Cycle`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/schedule-result.md#cycle>) holds the task ids (`Tasks`) and link ids (`Links`) that form the loop.

[`Notes`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/schedule-result.md#notes>) lists records the scheduler left out or changed. A [`Note`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/note.md>) is one of:

- [`Note.DroppedLink(Link, Reason)`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/note.md#droppedlink>): a link that can't apply, for example one that points to an unscheduled task.
- [`Note.DroppedTask(Task, Reason)`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/note.md#droppedtask>): an unscheduled task.
- [`Note.DroppedAssignment(Assignment, Reason)`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/note.md#droppedassignment>): an assignment that doesn't take part.
- [`Note.Repaired(Path, Message)`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/note.md#repaired>): a fix applied under [`Validation.Repair`](<https://docs.dhtmlx.com/planning-engine/net/api/core/project/validation.md>).

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

```csharp
using Plen;

var monday = Moment.FromYmdHm(2026, 8, 10, 8, 0) ?? throw new InvalidOperationException("invalid date");

var data = new ProjectData
{
    Tasks =
    [
        new TaskData(1) { StartDate = monday, Duration = 1.0 },
        new TaskData(2) { StartDate = monday, Duration = 1.0 },
        // Task 3 is parked: it isn't scheduled, and neither is the link to it.
        new TaskData(3) { StartDate = monday, Duration = 1.0, Unscheduled = true },
    ],
    Links =
    [
        // 1 -> 2 -> 1 is a cycle.
        new LinkData { Id = 10, Source = 1, Target = 2 },
        new LinkData { Id = 11, Source = 2, Target = 1 },
        new LinkData { Id = 12, Source = 2, Target = 3 },
    ],
};

var result = Engine.Schedule(data, new SolveOptions());
foreach (var cycle in result.Cycles)
    Console.WriteLine($"cycle: tasks [{string.Join(", ", cycle.Tasks)}], links [{string.Join(", ", cycle.Links)}]");
Console.WriteLine($"updates: {result.Updates.Count}");

foreach (var note in result.Notes)
{
    var line = note switch
    {
        Note.DroppedLink n => $"link {n.Link} dropped: {n.Reason}",
        Note.DroppedTask n => $"task {n.Task} dropped: {n.Reason}",
        Note.DroppedAssignment n => $"assignment {n.Assignment} dropped: {n.Reason}",
        Note.Repaired n => $"repaired {n.Path}: {n.Message}",
        _ => note.ToString(),
    };
    Console.WriteLine(line);
}
if (!result.Converged)
    Console.WriteLine("the planner stopped before a fixed point");
```

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 [`Analytics.Status == AnalyticsStatus.Invalid`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/analytics.md#analyticsstatus>).

[`Converged`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/schedule-result.md#converged>) is `false` when the planner didn't reach a stable state within [`Config.MaxIterations`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/scheduling-config.md#maxiterations>) passes (default 8). The dates are still a valid result, so treat it as a warning, not a failure.

To clean up messy input instead of rejecting it, see [Accept messy input](<https://docs.dhtmlx.com/planning-engine/net/how-to/accept-messy-input.md>).

## Baseline token

[`BaselineId`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/schedule-result.md#baselineid>) names the accepted baseline of a [`Project`](<https://docs.dhtmlx.com/planning-engine/net/api/core/project/project.md>): the dates the last [`Solve`](<https://docs.dhtmlx.com/planning-engine/net/api/project-solve.md>) or [`Level`](<https://docs.dhtmlx.com/planning-engine/net/api/project-level.md>) accepted. These calls fill it in:

- `Solve` and `Level`: the baseline they just accepted.
- [`Preview`](<https://docs.dhtmlx.com/planning-engine/net/api/project-preview.md>): the baseline it read.
- [`Analyze(null, …)`](<https://docs.dhtmlx.com/planning-engine/net/api/project-analyze.md>) on a solved project with no pending edits: the current baseline. In every other case it's `null`.
- [`Engine.Schedule`](<https://docs.dhtmlx.com/planning-engine/net/api/engine-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/net/api/project-apply.md>), a solve that moves dates, or leveling edits. Keep it next to the dates you display. Pass it back in [`PreviewOptions.BaselineId`](<https://docs.dhtmlx.com/planning-engine/net/api/core/project/preview-options.md>) or [`SchedulingChanges.BaselineId`](<https://docs.dhtmlx.com/planning-engine/net/api/core/changes/scheduling-changes.md>). If the project moved on in the meantime, the call throws [`ProjectException`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/project-exception.md>) with `Code == ProjectError.StaleBaseline`:

```csharp
using Plen;

var monday = Moment.FromYmdHm(2026, 8, 10, 8, 0) ?? throw new InvalidOperationException("invalid date");

var data = new ProjectData
{
    Tasks =
    [
        new TaskData(1) { StartDate = monday, Duration = 2.0 },
        new TaskData(2) { StartDate = monday, Duration = 1.0 },
    ],
    Links = [new LinkData { Id = 1, Source = 1, Target = 2 }],
};

var project = new Project();
var loaded = project.Solve(data, new SolveOptions());

// Keep the token next to the dates you show.
var shown = loaded.BaselineId;

var changes = new SchedulingChanges();
changes.Tasks.Update.Add(new TaskUpdate(1) { Duration = 3.0 });

var guarded = new PreviewOptions { BaselineId = shown };
var preview = project.Preview(changes, guarded);
Console.WriteLine($"preview read the shown baseline: {preview.BaselineId == shown}");

// Someone else commits an edit; the accepted baseline changes.
project.Apply(changes);
var committed = project.Solve(null, new SolveOptions());
Console.WriteLine($"token changed: {committed.BaselineId != shown}");

try
{
    project.Preview(changes, guarded);
    Console.WriteLine("unexpected: preview accepted a stale token");
}
catch (ProjectException e) when (e.Code == ProjectError.StaleBaseline)
{
    Console.WriteLine("stale: reload the dates before previewing again");
}
```

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. Compare it only with the current one, and always take it from the latest result. [Editing a project](<https://docs.dhtmlx.com/planning-engine/net/live-projects/editing-a-project.md>) shows the full edit cycle.

## Results are shared

[`ScheduleResult`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/schedule-result.md>) is a mutable class, but treat every result you get from a [`Project`](<https://docs.dhtmlx.com/planning-engine/net/api/core/project/project.md>) as read-only. The project keeps the result of [`Solve`](<https://docs.dhtmlx.com/planning-engine/net/api/project-solve.md>) and [`Level`](<https://docs.dhtmlx.com/planning-engine/net/api/project-level.md>) as its accepted baseline. It returns that same object from a later `Solve(null, …)` with equal options and nothing changed. Its clones share it, and [`Evaluate`](<https://docs.dhtmlx.com/planning-engine/net/api/project-evaluate.md>) and [`SuggestAssignments`](<https://docs.dhtmlx.com/planning-engine/net/api/project-suggest-assignments.md>) read it as the baseline. If you change it, you change what the project sees.

Call [`DeepClone()`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/schedule-result.md#deepclone>) to get a copy you own:

```csharp
using Plen;

var monday = Moment.FromYmdHm(2026, 8, 10, 8, 0) ?? throw new InvalidOperationException("invalid date");

var data = new ProjectData
{
    Tasks =
    [
        new TaskData(1) { StartDate = monday, Duration = 2.0 },
        new TaskData(2) { StartDate = monday, Duration = 1.0 },
    ],
    Links = [new LinkData { Id = 1, Source = 1, Target = 2 }],
};

var project = new Project();
var options = new SolveOptions();
var first = project.Solve(data, options);
var again = project.Solve(null, options);
var fork = project.Clone();

Console.WriteLine($"same object: {ReferenceEquals(first, again)}");
Console.WriteLine($"clone shares it: {ReferenceEquals(first, fork.Solve(null, options))}");

// Copy before you change anything.
var mine = first.DeepClone();
mine.Updates.Clear();
Console.WriteLine($"project result untouched: {first.Updates.Count}");
```

Output:

```
same object: True
clone shares it: True
project result untouched: 1
```

The copy shares no list or nested record with the original. [`LevelingResult.DeepClone()`](<https://docs.dhtmlx.com/planning-engine/net/api/core/resources/leveling-result.md#deepclone>) is the exception: it doesn't copy [`Schedule`](<https://docs.dhtmlx.com/planning-engine/net/api/core/resources/leveling-result.md#schedule>), so call `Schedule.DeepClone()` before you change the leveled schedule.

`ScheduleResult` compares by value with `Equals`, list by list. Two results of the same data can still differ in [`BaselineId`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/schedule-result.md#baselineid>), so set it to `null` on both copies before comparing them in tests.

A result from [`Engine.Schedule`](<https://docs.dhtmlx.com/planning-engine/net/api/engine-schedule.md>) belongs to you alone, so you can change it freely.

The core package has no JSON support. To send results to a client as JSON, see [Exchange JSON with a client](<https://docs.dhtmlx.com/planning-engine/net/how-to/exchange-json-with-a-client.md>).
