Accept messy input#

Schedule project data that strict validation rejects, such as data exported from a UI with duplicate ids or missing dates, and get a list of every fix the engine made.

By default a Project uses Validation.Strict and throws InputException for the first invalid field. Validation.Repair coerces the data instead (deduplicates ids, derives missing dates and durations, fixes inconsistent date triples), then schedules the result.

Steps#

  1. Create the project with repair validation:

    var project = new Project(new ProjectOptions { Validation = Validation.Repair });
    
  2. Pass your data to Solve(data, …) as usual. Project.Analyze with non-null data repairs supplied data the same way. The one-shot Engine.Schedule always uses strict validation, so use a Project here.

  3. Read the Note.Repaired entries in result.Notes and show them to the user, or log them. Each one has a Path and a Message.

The full example below has four kinds of problems: a duplicate id, a task with no dates, a dated constraint with no date, and a task whose start, end and duration disagree. Strict validation rejects this data. Repair schedules it:

using Plen;

var jan1 = Moment.FromYmdHm(2026, 1, 1, 0, 0) ?? throw new InvalidOperationException("invalid date");
var jan4 = Moment.FromYmdHm(2026, 1, 4, 0, 0) ?? throw new InvalidOperationException("invalid date");

var data = new ProjectData
{
    Tasks =
    [
        // Start and end without a duration: valid, nothing to repair.
        new TaskData("design") { StartDate = jan1, EndDate = jan4 },
        // "build" appears twice. The later record wins.
        new TaskData("build") { StartDate = jan1, Duration = 1.0 },
        // No dates, no duration, and a dated constraint without ConstraintDate.
        new TaskData("review") { ConstraintType = ConstraintType.Snet },
        new TaskData("build") { StartDate = jan1, Duration = 2.0 },
        // Three days between start and end, but Duration says five.
        new TaskData("deploy") { StartDate = jan1, EndDate = jan4, Duration = 5.0 },
    ],
};
// 24/7 time keeps the example dates easy to check.
data.Config.UseCalendars = false;

var project = new Project(new ProjectOptions { Validation = Validation.Repair });
var result = project.Solve(data, new SolveOptions { IncludeUnchanged = true });

foreach (var note in result.Notes)
{
    if (note is Note.Repaired repaired)
    {
        Console.WriteLine($"{repaired.Path}: {repaired.Message}");
    }
}
foreach (var m in result.Updates)
{
    Console.WriteLine($"{m.Id} {m.StartDate}..{m.EndDate} {m.Duration}");
}

What repair changes#

Repair reports each change below as one Note.Repaired, with the wire-spelled field path in Path:

  • Duplicate ids in Tasks, Links, Resources, Assignments and Calendars: the last record replaces the earlier one and takes its position. The note is at tasks[i].id, where i is the index of the duplicate in your input.
  • A task with no start and no end gets StartDate set to Config.ProjectStart, or to the earliest date of any task if ProjectStart isn't set.
  • A leaf task (not a milestone) with no Duration and only one date gets Duration = 1.
  • Start, end and duration that disagree: the dates win, and the duration becomes the working time between them. If the end is in non-working time, or the dates were computed on a different calendar, the duration is dropped and the task is resolved from its dates. An end that falls inside a pause moves to the pause start, with a second note at tasks[i].end_date.
  • A summary with only one date gets the other date computed from that date and its duration.
  • A dated constraint (Snet, Snlt, Fnet, Fnlt, Mso, Mfo) without ConstraintDate is cleared.
  • Invalid Pauses on a task: the whole list is cleared, with a note at tasks[i].pauses.
  • An invalid assignment Offset or Duration is cleared, so the assignment books the whole task.

A task that already has two of start, end and duration is valid input. Repair doesn't touch it and doesn't report it.

Only the duplicate-id notes use input indexes. Every other note indexes the deduplicated list. In the example, the two build records become one, so deploy is tasks[4] in your input but tasks[3] in its note.

What repair doesn't fix#

  • An entirely undated project, with no Config.ProjectStart and no task that has a date, still throws InputException with Path tasks[i].start_date. The engine never falls back to today's date.
  • Every other invalid input, such as a cycle in the Parent hierarchy or a calendar with no working time, still throws InputException. See Error handling.
  • If repair or validation fails, Solve throws and the project keeps its previous state and cached result.

Result#

The example prints the fixes, then the scheduled dates (the date lines may come in a different order):

tasks[3].id: duplicate id replaced the earlier record, retaining its position
tasks[2].constraint_type: dated constraint without date cleared
tasks[2].start_date: missing date defaulted to project anchor
tasks[2].duration: missing duration defaulted to one
tasks[3].duration: duration recomputed from supplied start and end
design 2026-01-01 00:00..2026-01-04 00:00 3
build 2026-01-01 00:00..2026-01-03 00:00 2
review 2026-01-01 00:00..2026-01-02 00:00 1
deploy 2026-01-01 00:00..2026-01-04 00:00 3

build uses the second record's duration of 2, and deploy has a duration of 3, taken from its dates. With new Project(), the same Solve call throws InputException at tasks[2].constraint_date.