Evaluating Alternatives#

This guide shows how to compare several possible edits in one raw evaluate call and pick the best one, for example to decide which resource should take a task. It covers building candidates, scoring them with and without leveling, reading the metrics, handling candidates that fail, keeping long evaluations off the main thread and committing the winner.

How evaluation works#

evaluate(candidates_json, options_json) is a method of a solved JsonProject. candidates_json is a JSON array of candidates. Each candidate is an object with an id and a changes field. changes holds a SchedulingChanges payload, the same JSON you'd pass to apply. One candidate is one alternative: "give task B to dev2", "split the review between two people", "add a link". The call scores every candidate against the project's accepted baseline and returns the scores in an envelope. The project isn't modified: evaluate is read-only, like preview.

Every candidate goes through the same run on a disposable copy of the project:

  1. Copy the reference state. That's the accepted baseline, or its cleared copy with clearFirst (see Options).
  2. apply the candidate's changes.
  3. solve without data and measure the scheduled stage.
  4. When the options set level, run level with those options and measure the leveled stage.

You'd run the same sequence to commit the candidate for real. Each candidate starts from the reference state, never from the previous candidate. Both stages are compared with the one baseline.

The result of the envelope has three fields:

  • baseline: the metrics of the reference state itself. All its deltas are 0.
  • candidates: one entry per candidate, in input order. An entry holds the candidate's id, scheduled metrics, leveled metrics (only with level) and a cost. A failed candidate has an error instead.
  • baselineId: the token of the accepted baseline the candidates were scored against.

Each stage is described by a Metrics object: the project finish and the finish of each focus task, resource overload, conflicts, cycles and a single cost number. Every measurement is an absolute value plus a delta against the baseline, so you can show either one.

Errors come in two levels. Problems with the call itself return an error envelope:

  • no solved baseline: kind: "project", code: "no-baseline". This includes a project that had an apply since its last solve.
  • a stale baselineId in the options or in any candidate's changes: kind: "project", code: "stale-baseline".
  • an unknown focus task, leveling options that don't fit the baseline, or level: true: kind: "validation".
  • malformed JSON or an unknown field in a candidate or in the options: kind: "parse".

Everything else fails only its own candidate. The candidate gets an error and no metrics, and the other candidates are scored as usual. See Error handling for the envelope itself.

evaluate reports scores, not dates. The result has no task updates and no overallocation windows. Once you've picked a candidate, commit it with apply and level, or look at its dates with preview.

Candidates#

All examples on this page use the same small project: tasks A and B, two working days each, both starting on Monday, August 10, 2026, and both assigned to dev1. The resource dev2 is idle, so dev1 is overloaded for two days.

Build each alternative as a candidate object and pass the array to evaluate. An empty change set ({}) is a useful candidate too: it scores "leave the plan as it is" next to the other options.

const { JsonProject } = require("./pkg-node/plen_wasm.js");

function unwrap(text) {
  const response = JSON.parse(text);
  if (!response.ok) {
    const { kind, code, message, path } = response.error;
    throw new Error(`${kind} ${code ?? ""}: ${message} (at ${path})`);
  }
  return response.result;
}

// Tasks A and B, two working days each, both on dev1 in the same week.
const data = {
  tasks: [
    { id: "A", start_date: "2026-08-10 08:00", duration: 2 },
    { id: "B", start_date: "2026-08-10 08:00", duration: 2 },
  ],
  resources: [{ id: "dev1" }, { id: "dev2" }],
  assignments: [
    { id: "aA", task: "A", resource: "dev1", value: 1 },
    { id: "aB", task: "B", resource: "dev1", value: 1 },
  ],
};

const candidates = [
  // Keep the plan as it is.
  { id: "stay", changes: {} },
  // Give task B to dev2.
  { id: "dev2", changes: { assignments: { update: [{ id: "aB", resource: "dev2" }] } } },
];

const project = new JsonProject("{}");
try {
  unwrap(project.solve(JSON.stringify(data), "{}"));

  const options = { focus: ["B"] };
  const result = unwrap(project.evaluate(JSON.stringify(candidates), JSON.stringify(options)));

  console.log(`baseline: cost ${result.baseline.cost}`);
  for (const candidate of result.candidates) {
    console.log(`${candidate.id}: cost ${candidate.cost}`);
  }
} finally {
  project.free();
}
baseline: cost 2
stay: cost 2
dev2: cost 0

Without leveling, only the scheduled stage is scored. stay keeps both tasks on dev1 and costs 2, two resource-days of overload. dev2 removes the overload and costs 0.

The candidate id is echoed back so you can match results to inputs. Ids aren't checked for uniqueness, and results keep input order anyway. A candidate object takes only id and changes; any other field fails the whole call with a parse error. See the evaluate reference.

Options#

The options object has four fields, all optional:

  • level: a LevelingOptions object levels each candidate after scheduling it and adds the leveled stage. {} uses the default leveling options. false or null (the default) scores the scheduled stage only. true isn't accepted: it returns a validation error.
  • focus: one task id or an array of ids. These are the tasks to report finish dates and focus overload for. There's no implicit "modified task", so list the tasks your candidates are about.
  • baselineId: a baseline token. The call fails with stale-baseline when it no longer names the accepted baseline. Each candidate's changes.baselineId is checked the same way.
  • clearFirst: score against a copy with the in-scope leveling_delay and pauses cleared.

Leveling changes the picture. Without it, an overloaded plan looks bad on overload. With it, the overload turns into delay, which is what the user gets after committing and leveling:

const { JsonProject } = require("./pkg-node/plen_wasm.js");

function unwrap(text) {
  const response = JSON.parse(text);
  if (!response.ok) {
    const { kind, code, message, path } = response.error;
    throw new Error(`${kind} ${code ?? ""}: ${message} (at ${path})`);
  }
  return response.result;
}

// Tasks A and B, two working days each, both on dev1 in the same week.
const data = {
  tasks: [
    { id: "A", start_date: "2026-08-10 08:00", duration: 2 },
    { id: "B", start_date: "2026-08-10 08:00", duration: 2 },
  ],
  resources: [{ id: "dev1" }, { id: "dev2" }],
  assignments: [
    { id: "aA", task: "A", resource: "dev1", value: 1 },
    { id: "aB", task: "B", resource: "dev1", value: 1 },
  ],
};

const candidates = [
  { id: "stay", changes: {} },
  { id: "dev2", changes: { assignments: { update: [{ id: "aB", resource: "dev2" }] } } },
];

const project = new JsonProject("{}");
try {
  unwrap(project.solve(JSON.stringify(data), "{}"));

  // `level: {}` levels each candidate with the default leveling options.
  const options = { level: {}, focus: ["A", "B"] };
  const result = unwrap(project.evaluate(JSON.stringify(candidates), JSON.stringify(options)));

  for (const candidate of result.candidates) {
    const leveled = candidate.leveled;
    if (!leveled) continue;
    const finish = leveled.finish.project;
    console.log(`${candidate.id}: project ends ${finish.date} (+${finish.delta} days), cost ${candidate.cost}`);
    for (const task of leveled.finish.tasks) {
      if (task.finish) console.log(`  ${task.id} ends ${task.finish.date} (+${task.finish.delta})`);
    }
  }
} finally {
  project.free();
}
stay: project ends 2026-08-14 08:00 (+2 days), cost 2
  A ends 2026-08-12 08:00 (+0)
  B ends 2026-08-14 08:00 (+2)
dev2: project ends 2026-08-12 08:00 (+0 days), cost 0
  A ends 2026-08-12 08:00 (+0)
  B ends 2026-08-12 08:00 (+0)

For stay, leveling resolves the overload by pushing B two days later, so the cost is now the delay. For dev2 there's nothing to level.

level runs with the options exactly as you pass them. Its own clearFirst defaults to true, so each candidate is re-leveled from zero. That creates a trap after an earlier level run. The stored delays are already in the baseline finish, and they hide the overload the current assignments cause. A candidate that removes the overload then shows a negative finish delta, because leveling from zero drops the old delays.

The top-level clearFirst in the evaluate options fixes this. It replaces the reference state with a copy that has the in-scope delays and pauses cleared, solved again. The baseline metrics and every candidate run then use that copy. The scope is level.scope.tasks when you give it, otherwise every task. It's independent of level.clearFirst, so it also works without level.

const { JsonProject } = require("./pkg-node/plen_wasm.js");

function unwrap(text) {
  const response = JSON.parse(text);
  if (!response.ok) {
    const { kind, code, message, path } = response.error;
    throw new Error(`${kind} ${code ?? ""}: ${message} (at ${path})`);
  }
  return response.result;
}

// Tasks A and B, two working days each, both on dev1 in the same week.
const data = {
  tasks: [
    { id: "A", start_date: "2026-08-10 08:00", duration: 2 },
    { id: "B", start_date: "2026-08-10 08:00", duration: 2 },
  ],
  resources: [{ id: "dev1" }, { id: "dev2" }],
  assignments: [
    { id: "aA", task: "A", resource: "dev1", value: 1 },
    { id: "aB", task: "B", resource: "dev1", value: 1 },
  ],
};

const candidates = [
  { id: "dev2", changes: { assignments: { update: [{ id: "aB", resource: "dev2" }] } } },
];

const project = new JsonProject("{}");
try {
  unwrap(project.solve(JSON.stringify(data), "{}"));
  // The plan was leveled earlier: B carries a two-day leveling delay.
  const leveled = unwrap(project.level("{}"));

  const options = { baselineId: leveled.baselineId, level: {}, clearFirst: true };
  const result = unwrap(project.evaluate(JSON.stringify(candidates), JSON.stringify(options)));

  // The cleared baseline shows the overload that B's delay was hiding.
  console.log(`baseline overload: ${result.baseline.overload.total.value}`);
  for (const candidate of result.candidates) {
    const finish = candidate.leveled?.finish.project;
    if (finish) console.log(`${candidate.id}: finish delta ${finish.delta}`);
  }
} finally {
  project.free();
}
baseline overload: 2
dev2: finish delta 0

With clearFirst, the baseline reports 2 resource-days of overload and the dev2 candidate a finish delta of 0. Without it, the baseline has no overload and the same candidate shows a finish delta of -2. Both views are valid. Use clearFirst when you'll commit with level and its default clearFirst: true, which re-levels from zero.

A few details:

  • baselineId, candidate tokens, level options and focus ids are all checked against the accepted state before anything is cleared. clearFirst adds no new errors.
  • A candidate's own changes.baselineId is dropped before its trial run, because the cleared copy has a different token. The result's baselineId is still the accepted state's.
  • When there's nothing to clear in scope, the output is the same as with clearFirst: false.

The full option list is on the evaluate reference. For the leveling options themselves, see Leveling resources.

Metrics and cost#

Every stage reports the same Metrics fields:

Field What it measures
finish.project Latest end over scheduled tasks. null when the stage has no tasks.
finish.tasks One { id, finish } entry per focus task, in focus order.
overload.total Overload over all resources.
overload.resources Overload per resource, for every resource that's non-zero in the stage or the baseline.
overload.focus Per focus task: the overload per resource in the buckets that intersect the task.
conflicts, cycles How many conflicts and cycles the stage has.
cost One number for ranking, lower is better.

Dates come as a shift: { date, delta }. delta is the working time from the baseline finish in config.duration_unit, positive when later. A task's delta is measured on its own calendar, the project's on the global calendar. date is a YYYY-MM-DD HH:mm string, like every date on the raw surface (see Wire format). Counts and overload come as an amount: { value, delta }.

Overload is measured the same way the leveler sees it. For each time bucket, it's how far a resource's load goes over its capacity, summed over buckets. The unit is capacity × buckets, so on a day basis 2 means "two resource-days over". One bucket size (basis) applies to the baseline and every stage of every candidate: level.basis when you give it, otherwise the one derived from config.duration_unit. loadProfile shows the same per-resource sum as excess at that basis.

Focus overload shows where the trouble is. At the scheduled stage it's the overload around the task. At the leveled stage it's what leveling couldn't resolve there. A milestone has zero length and covers no bucket.

The leveled stage (LeveledMetrics) carries the same fields, flattened into one object, plus converged, rounds and windows. windows is the number of overallocation windows left after leveling.

cost is one fixed heuristic:

cost = max(0, finish.project.delta)
     + overload.total.value
     + 1000 × (conflicts.value + cycles.value)

When the basis follows duration_unit, a day of delay and a resource-day of overload weigh the same. An earlier finish doesn't earn a bonus. The 1000 penalty keeps any broken schedule, with conflicts or cycles, behind every valid one. The candidate's own cost is the cost of the last stage present: leveled when you level, scheduled otherwise. The baseline carries its own cost.

If the heuristic doesn't fit your product, rank on the raw values instead. This function prints the per-resource overload change of each candidate's scheduled stage, given the parsed result of an evaluate envelope:

function printOverload(result) {
  for (const candidate of result.candidates) {
    if (!candidate.scheduled) continue;
    for (const row of candidate.scheduled.overload.resources) {
      const { value, delta } = row.overload;
      console.log(`${candidate.id}: ${row.resource} overload ${value} (${delta} vs baseline)`);
    }
  }
}

For the project above, the dev2 candidate prints dev2: dev1 overload 0 (-2 vs baseline). The row for dev1 is there because dev1 was overloaded in the baseline, even though it's zero in the stage. Resources come in stage order, then resources that exist only in the baseline. See the Metrics reference.

Failed and cyclic candidates#

A candidate can fail without failing the call. Three cases need handling:

  • Rejected. apply rejects an operation in the change set (the result reports only the first rejection), or solve or level fails on the candidate's input, for example on a leveling scope id the candidate removed. The entry has error: { path, message } and no scheduled, leveled or cost.
  • Cyclic. The candidate creates a dependency cycle. scheduled is measured on the unchanged dates the cyclic solve returns, and cycles.value is above 0. Leveling stops at round 0 on a cyclic plan, so leveled is absent. The cycle penalty pushes the cost over 1000.
  • Focus task removed. When the candidate removes a focus task, that task's finish and its focus overload resources are null. The rest of the metrics are normal.
const { JsonProject } = require("./pkg-node/plen_wasm.js");

function unwrap(text) {
  const response = JSON.parse(text);
  if (!response.ok) {
    const { kind, code, message, path } = response.error;
    throw new Error(`${kind} ${code ?? ""}: ${message} (at ${path})`);
  }
  return response.result;
}

// Tasks A and B, two working days each, both on dev1 in the same week.
const data = {
  tasks: [
    { id: "A", start_date: "2026-08-10 08:00", duration: 2 },
    { id: "B", start_date: "2026-08-10 08:00", duration: 2 },
  ],
  resources: [{ id: "dev1" }, { id: "dev2" }],
  assignments: [
    { id: "aA", task: "A", resource: "dev1", value: 1 },
    { id: "aB", task: "B", resource: "dev1", value: 1 },
  ],
};

const candidates = [
  // Refers to an assignment that doesn't exist.
  { id: "missing", changes: { assignments: { update: [{ id: "nope", resource: "dev2" }] } } },
  // Links A and B both ways.
  {
    id: "cycle",
    changes: {
      links: {
        add: [
          { id: "ab", source: "A", target: "B", type: "fs" },
          { id: "ba", source: "B", target: "A", type: "fs" },
        ],
      },
    },
  },
  // Removes the focus task.
  { id: "removed", changes: { tasks: { remove: ["B"] } } },
];

const project = new JsonProject("{}");
try {
  unwrap(project.solve(JSON.stringify(data), "{}"));

  const options = { level: {}, focus: ["B"] };
  const result = unwrap(project.evaluate(JSON.stringify(candidates), JSON.stringify(options)));

  for (const candidate of result.candidates) {
    if (candidate.error) {
      console.log(`${candidate.id} rejected at ${candidate.error.path}: ${candidate.error.message}`);
      continue;
    }
    const scheduled = candidate.scheduled;
    if (scheduled.cycles.value > 0) {
      console.log(`${candidate.id} is cyclic, leveled: ${candidate.leveled !== undefined}`);
    }
    for (const task of scheduled.finish.tasks) {
      if (task.finish === null) console.log(`${candidate.id} removes focus task ${task.id}`);
    }
    console.log(`${candidate.id} cost ${candidate.cost}`);
  }
} finally {
  project.free();
}
missing rejected at assignments.update[0]: unknown assignment id
cycle is cyclic, leveled: false
cycle cost 1002
removed removes focus task B
removed cost 0

The rejected entry in the envelope looks like this:

{
  "id": "missing",
  "error": { "path": "assignments.update[0]", "message": "unknown assignment id" }
}

Rank only candidates that have a cost. The cyclic candidate has one, but the penalty keeps it at the bottom. Its 1002 is the cycle penalty plus the two resource-days of overload dev1 still has.

Call-level errors still come back as an error envelope with ok: false, and no candidate is scored. For example, focusing on a task the project doesn't have:

const response = JSON.parse(project.evaluate("[]", JSON.stringify({ focus: ["X"] })));
{
  "ok": false,
  "error": { "kind": "validation", "code": null, "message": "unknown task \"X\"", "path": "focus[0]" }
}

A stale token returns kind: "project" with code: "stale-baseline". See Error handling.

Sequential only#

Each candidate runs on its own copy, so candidates are independent of each other. The WebAssembly build still scores them one at a time, in input order, on the thread that calls evaluate.

Note

Candidates are scored one after another.

evaluate is synchronous and CPU-bound, like every raw export. A large evaluation blocks the calling thread until it returns. Keep it off the UI thread or the request loop by running it in a Web Worker or a Node.js worker thread. Each worker loads its own module instance and holds its own JsonProject:

// evaluate-worker.js
const { parentPort } = require("node:worker_threads");
const { JsonProject } = require("./pkg-node/plen_wasm.js");

// Each message carries the project data, the candidates and the options as JSON text.
parentPort.on("message", ({ dataJson, candidatesJson, optionsJson }) => {
  const project = new JsonProject("{}");
  try {
    const solved = project.solve(dataJson, "{}");
    // Reply with the solve error envelope, or with the evaluate envelope.
    parentPort.postMessage(JSON.parse(solved).ok ? project.evaluate(candidatesJson, optionsJson) : solved);
  } finally {
    project.free();
  }
});
// main.js
const path = require("node:path");
const { Worker } = require("node:worker_threads");

const data = {
  tasks: [
    { id: "A", start_date: "2026-08-10 08:00", duration: 2 },
    { id: "B", start_date: "2026-08-10 08:00", duration: 2 },
  ],
  resources: [{ id: "dev1" }, { id: "dev2" }],
  assignments: [
    { id: "aA", task: "A", resource: "dev1", value: 1 },
    { id: "aB", task: "B", resource: "dev1", value: 1 },
  ],
};
// One candidate per resource that could take task B.
const candidates = ["dev1", "dev2"].map((dev) => ({
  id: dev,
  changes: { assignments: { update: [{ id: "aB", resource: dev }] } },
}));

const worker = new Worker(path.join(__dirname, "evaluate-worker.js"));
worker.once("message", (text) => {
  const response = JSON.parse(text);
  if (response.ok) {
    for (const candidate of response.result.candidates) {
      console.log(`${candidate.id}: cost ${candidate.cost}`);
    }
  } else {
    console.error(response.error);
  }
  worker.terminate();
});
worker.postMessage({
  dataJson: JSON.stringify(data),
  candidatesJson: JSON.stringify(candidates),
  optionsJson: "{}",
});

The worker prints dev1: cost 2 and dev2: cost 0. Only JSON text crosses between the threads, so the envelope can go back unparsed. For the worker setup in the browser build, see Run in a worker.

Committing the winner#

evaluate never changes the project. To commit the chosen candidate, run its changes for real: apply the same change set, then level with the same leveling options, step included. With other options, the committed plan isn't the one that was scored. If you scored without level, follow apply with solve(undefined, …) instead.

const { JsonProject } = require("./pkg-node/plen_wasm.js");

function unwrap(text) {
  const response = JSON.parse(text);
  if (!response.ok) {
    const { kind, code, message, path } = response.error;
    throw new Error(`${kind} ${code ?? ""}: ${message} (at ${path})`);
  }
  return response.result;
}

// Tasks A and B, two working days each, both on dev1 in the same week.
const data = {
  tasks: [
    { id: "A", start_date: "2026-08-10 08:00", duration: 2 },
    { id: "B", start_date: "2026-08-10 08:00", duration: 2 },
  ],
  resources: [{ id: "dev1" }, { id: "dev2" }],
  assignments: [
    { id: "aA", task: "A", resource: "dev1", value: 1 },
    { id: "aB", task: "B", resource: "dev1", value: 1 },
  ],
};

const candidates = [
  { id: "stay", changes: {} },
  { id: "dev2", changes: { assignments: { update: [{ id: "aB", resource: "dev2" }] } } },
];

const project = new JsonProject("{}");
try {
  unwrap(project.solve(JSON.stringify(data), "{}"));

  const leveling = {};
  const options = { level: leveling };
  const result = unwrap(project.evaluate(JSON.stringify(candidates), JSON.stringify(options)));

  // Lowest cost wins; rejected candidates have no cost.
  let best = null;
  result.candidates.forEach((scored, i) => {
    if (scored.cost === undefined) return;
    if (best === null || scored.cost < best.cost) best = { cost: scored.cost, candidate: candidates[i] };
  });

  if (best) {
    // apply fails with stale-baseline if the project changed since the evaluation.
    const changes = { ...best.candidate.changes, baselineId: result.baselineId };
    const report = unwrap(project.apply(JSON.stringify(changes)));
    if (report.rejected.length === 0) {
      // The same options that scored the candidate.
      unwrap(project.level(JSON.stringify(leveling)));
      console.log(`committed ${best.candidate.id} (cost ${best.cost})`);
    }
  }
} finally {
  project.free();
}
committed dev2 (cost 0)

Results come back in input order, so the index of a scored entry is the index of its candidate. Setting baselineId to the token from the result makes apply refuse the commit when another edit landed in between. In that case, evaluate again.

If you scored with clearFirst: true in the evaluate options, commit with level and its own clearFirst left at the default true. The commit then re-levels from zero, like the scoring did.

To handle a rejected apply, see Recover from rejected changes. To generate candidates automatically instead of writing them by hand, use suggestAssignments, which scores its steps the same way. See Suggesting assignments.