This guide shows how to compare several possible edits in one call and pick the best one. For example, you can check which resource should take a task. It covers building candidates, scoring them with and without leveling, reading the metrics, handling candidates that fail, scoring on several threads and committing the winner.
Project::evaluate takes a list of candidates. A Candidate is an id plus a SchedulingChanges value, the same change set you'd pass to apply. Each 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 an EvaluateResult. The project isn't modified. evaluate takes &self, like preview.
Every candidate goes through the same run on a disposable copy of the project:
Copy the reference state. That's the accepted baseline, or its cleared copy with clear_first (see Options).
With EvaluateOptions.level set, level with those options and measure the leveled stage.
This is the same sequence you'd run to commit the candidate for real. Each candidate starts from the reference state, never from the previous candidate, and both stages are compared with the one baseline.
The result has three parts:
baseline: the metrics of the reference state itself. All its deltas are 0.
candidates: one CandidateResult per candidate, in input order. It holds scheduled metrics, leveled metrics (only with level) and a cost.
baseline_id: the token of the accepted baseline the candidates were scored against.
Each stage is described by a Metrics value: 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 fail the whole call: no solved baseline (ProjectError::NoBaseline), a stale baseline_id in the options or in any candidate (ProjectError::StaleBaseline), an unknown focus task or leveling options that don't fit the baseline (Error::Input). Everything else fails only its own candidate: the candidate gets an error and no metrics, and the other candidates are scored as usual.
evaluate reports scores, not dates. There are no task updates or overallocation windows in the result. Once you've picked a candidate, commit it yourself with apply and level, or look at its dates with preview.
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. dev1 is overloaded for two days.
Build each alternative as a Candidate and pass them all to evaluate. An empty change set is a useful candidate too: it scores "leave the plan as it is" next to the other options.
useplen_core::{
AssignmentData, AssignmentUpdate, Candidate, DateTime, EvaluateOptions, Patch, Project,
ProjectData, ResourceData, SchedulingChanges, SolveOptions, TaskData,};fntask(id:&str, start: DateTime, duration:f64)-> TaskData{letmut task =TaskData::new(id);
task.start_date =Some(start);
task.duration =Some(duration);
task
}fnresource(id:&str)-> ResourceData{
ResourceData {
id: id.into(),
capacity:None,
calendar_id:None,}}fnassign(id:&str, task:&str, resource:&str)-> AssignmentData{
AssignmentData {
id:Some(id.into()),
task: task.into(),
resource: resource.into(),
value:1.0,
offset:None,
duration:None,}}fnmain()->Result<(), Box<dyn std::error::Error>>{let monday =DateTime::from_ymd_hm(2026,8,10,8,0).ok_or("invalid date")?;let data = ProjectData {
tasks:vec![task("A", monday,2.0),task("B", monday,2.0)],
resources:vec![resource("dev1"),resource("dev2")],
assignments:vec![assign("aA","A","dev1"),assign("aB","B","dev1")],..Default::default()};letmut project =Project::default();
project.solve(Some(data),&SolveOptions::default())?;// Candidate 1: keep the plan as it is.
let stay = Candidate {
id:"stay".into(),
changes:SchedulingChanges::default(),};// Candidate 2: give task B to dev2.
letmut to_dev2 =SchedulingChanges::default();letmut moved =AssignmentUpdate::new("aB");
moved.resource =Patch::Value("dev2".into());
to_dev2.assignments.update.push(moved);let dev2 = Candidate {
id:"dev2".into(),
changes: to_dev2,};let options = EvaluateOptions {
focus:vec!["B".into()],..Default::default()};let result = project.evaluate(&[stay, dev2],&options)?;println!("baseline: cost {}", result.baseline.cost);for candidate in&result.candidates {println!("{}: cost {:?}", candidate.id, candidate.cost);}Ok(())}
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. See the Candidate reference.
level: Some(LevelingOptions) levels each candidate after scheduling it and adds the leveled stage. None (the default) scores the scheduled stage only.
focus: the tasks to report finish dates and focus overload for. There's no implicit "modified task", so list the tasks your candidates are about.
baseline_id: a baseline token. The call fails with ProjectError::StaleBaseline when it no longer names the accepted baseline. Each candidate's changes.baseline_id is checked the same way.
clear_first: 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:
stay: project ends 2026-08-14 08:00 (+2 days), cost Some(2.0)
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 Some(0.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, so LevelingOptions.clear_first (default true) re-levels everything from zero in each candidate. That creates a trap after a previous 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.
EvaluateOptions.clear_first 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 LevelingOptions.clear_first, so it also works without level.
useplen_core::{
AssignmentData, AssignmentUpdate, Candidate, DateTime, EvaluateOptions, LevelingOptions,
Patch, Project, ProjectData, ResourceData, SchedulingChanges, SolveOptions, TaskData,};fntask(id:&str, start: DateTime, duration:f64)-> TaskData{letmut task =TaskData::new(id);
task.start_date =Some(start);
task.duration =Some(duration);
task
}fnresource(id:&str)-> ResourceData{
ResourceData {
id: id.into(),
capacity:None,
calendar_id:None,}}fnassign(id:&str, task:&str, resource:&str)-> AssignmentData{
AssignmentData {
id:Some(id.into()),
task: task.into(),
resource: resource.into(),
value:1.0,
offset:None,
duration:None,}}/// Tasks A and B, two days each, both on dev1 in the same week.
fnsolved_project()->Result<Project, Box<dyn std::error::Error>>{let monday =DateTime::from_ymd_hm(2026,8,10,8,0).ok_or("invalid date")?;let data = ProjectData {
tasks:vec![task("A", monday,2.0),task("B", monday,2.0)],
resources:vec![resource("dev1"),resource("dev2")],
assignments:vec![assign("aA","A","dev1"),assign("aB","B","dev1")],..Default::default()};letmut project =Project::default();
project.solve(Some(data),&SolveOptions::default())?;Ok(project)}fnreassign(assignment:&str, resource:&str)-> SchedulingChanges{letmut changes =SchedulingChanges::default();letmut update =AssignmentUpdate::new(assignment);
update.resource =Patch::Value(resource.into());
changes.assignments.update.push(update);
changes
}fnmain()->Result<(), Box<dyn std::error::Error>>{letmut project =solved_project()?;// The plan was leveled earlier: B carries a two-day leveling delay.
let leveled = project.level(&LevelingOptions::default())?;let candidates =[Candidate {
id:"dev2".into(),
changes:reassign("aB","dev2"),}];let options = EvaluateOptions {
baseline_id: leveled.schedule.baseline_id.clone(),
level:Some(LevelingOptions::default()),
clear_first:true,..Default::default()};let result = project.evaluate(&candidates,&options)?;// The cleared baseline shows the overload that B's delay was hiding.
println!("baseline overload: {}", result.baseline.overload.total.value);for candidate in&result.candidates {ifletSome(finish)= candidate
.leveled
.as_ref().and_then(|leveled|leveled.metrics.finish.project.as_ref()){println!("{}: finish delta {:+}", candidate.id, finish.delta);}}Ok(())}
With clear_first, 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. clear_first is the one that matches a commit with level and clear_first, which re-levels from zero.
A few details:
baseline_id, candidate tokens, level options and focus ids are all checked against the accepted state before anything is cleared. clear_first adds no new errors.
A candidate's own changes.baseline_id is dropped before its trial run, because the cleared copy has a different token. The result's baseline_id is still the accepted state's.
When there's nothing to clear in scope, the output is the same as with clear_first: false.
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 plus delta, 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. Counts and overload come as an Amount: value plus delta.
Overload is measured the same way the leveler sees it: for each time bucket, how far a resource's load goes over its capacity, summed over buckets. The unit is capacity ร buckets, so on a day basis 2.0 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. The same per-resource sum is what load_profile shows 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 is a LeveledMetrics: the same Metrics in its metrics field, plus converged, rounds and windows, the number of overallocation windows left after leveling.
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. CandidateResult.cost is the cost of the last stage present, so 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 loop prints the per-resource overload change of each candidate's scheduled stage:
For the project above, the dev2 candidate prints a row for dev1 with a delta of -2. The row 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.
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 a leveling scope id the candidate removed. The result has error: Some(RejectedOperation) with a path and a message. scheduled, leveled and cost are None.
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 None. The cycle penalty pushes its cost over 1000.
Focus task removed. When the candidate removes a focus task, that task's finish and its focus overload resources are None. The rest of the metrics are normal.
missing rejected at assignments.update[0]: unknown assignment id
cycle is cyclic, leveled: false
cycle cost Some(1002.0)
removed removes focus task B
removed cost Some(0.0)
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.
Each candidate runs on its own copy, so candidates are independent. On a native multi-core host, Project::evaluate_with spreads them over threads:
usestd::num::NonZeroUsize;useplen_core::{
AssignmentData, AssignmentUpdate, Candidate, DateTime, EvaluateOptions, Execution, Patch,
Project, ProjectData, ResourceData, SchedulingChanges, SolveOptions, TaskData,};fntask(id:&str, start: DateTime, duration:f64)-> TaskData{letmut task =TaskData::new(id);
task.start_date =Some(start);
task.duration =Some(duration);
task
}fnresource(id:&str)-> ResourceData{
ResourceData {
id: id.into(),
capacity:None,
calendar_id:None,}}fnassign(id:&str, task:&str, resource:&str)-> AssignmentData{
AssignmentData {
id:Some(id.into()),
task: task.into(),
resource: resource.into(),
value:1.0,
offset:None,
duration:None,}}/// Tasks A and B, two days each, both on dev1 in the same week.
fnsolved_project()->Result<Project, Box<dyn std::error::Error>>{let monday =DateTime::from_ymd_hm(2026,8,10,8,0).ok_or("invalid date")?;let data = ProjectData {
tasks:vec![task("A", monday,2.0),task("B", monday,2.0)],
resources:vec![resource("dev1"),resource("dev2")],
assignments:vec![assign("aA","A","dev1"),assign("aB","B","dev1")],..Default::default()};letmut project =Project::default();
project.solve(Some(data),&SolveOptions::default())?;Ok(project)}fnreassign(assignment:&str, resource:&str)-> SchedulingChanges{letmut changes =SchedulingChanges::default();letmut update =AssignmentUpdate::new(assignment);
update.resource =Patch::Value(resource.into());
changes.assignments.update.push(update);
changes
}fnmain()->Result<(), Box<dyn std::error::Error>>{let project =solved_project()?;// One candidate per resource that could take task B.
let candidates:Vec<Candidate>=["dev1","dev2"].iter().map(|dev|Candidate {
id:(*dev).into(),
changes:reassign("aB", dev),}).collect();let options =EvaluateOptions::default();let threads =NonZeroUsize::new(4);let result = project.evaluate_with(&candidates,&options,Execution::Parallel { threads })?;// Same results, same order as `project.evaluate(&candidates, &options)`.
for candidate in&result.candidates {println!("{}: cost {:?}", candidate.id, candidate.cost);}Ok(())}
Execution::Parallel { threads: None } uses std::thread::available_parallelism. Execution::Sequential is what evaluate does. The workers are std::thread::scope threads that share &self and take the next candidate as they finish, so peak memory is one candidate copy per worker.
The results are bit-identical to the sequential run and keep input order. If the call fails, you get the same error as in a sequential run: the first one in input order. Later candidates may already have run.
evaluate_with and Execution::Parallel are compiled only for native targets (#[cfg(not(target_family = "wasm"))]). On wasm, call evaluate. Like every call in the crate, evaluate is CPU-bound and synchronous. In an async server, run large evaluations on spawn_blocking or a worker thread. See the Execution reference.
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 LevelingOptions, step included. With other options, the committed plan isn't the one that was scored. If you scored without level, follow apply with solve(None) instead.
useplen_core::{
AssignmentData, AssignmentUpdate, Candidate, DateTime, EvaluateOptions, LevelingOptions,
Patch, Project, ProjectData, ResourceData, SchedulingChanges, SolveOptions, TaskData,};fntask(id:&str, start: DateTime, duration:f64)-> TaskData{letmut task =TaskData::new(id);
task.start_date =Some(start);
task.duration =Some(duration);
task
}fnresource(id:&str)-> ResourceData{
ResourceData {
id: id.into(),
capacity:None,
calendar_id:None,}}fnassign(id:&str, task:&str, resource:&str)-> AssignmentData{
AssignmentData {
id:Some(id.into()),
task: task.into(),
resource: resource.into(),
value:1.0,
offset:None,
duration:None,}}/// Tasks A and B, two days each, both on dev1 in the same week.
fnsolved_project()->Result<Project, Box<dyn std::error::Error>>{let monday =DateTime::from_ymd_hm(2026,8,10,8,0).ok_or("invalid date")?;let data = ProjectData {
tasks:vec![task("A", monday,2.0),task("B", monday,2.0)],
resources:vec![resource("dev1"),resource("dev2")],
assignments:vec![assign("aA","A","dev1"),assign("aB","B","dev1")],..Default::default()};letmut project =Project::default();
project.solve(Some(data),&SolveOptions::default())?;Ok(project)}fnreassign(assignment:&str, resource:&str)-> SchedulingChanges{letmut changes =SchedulingChanges::default();letmut update =AssignmentUpdate::new(assignment);
update.resource =Patch::Value(resource.into());
changes.assignments.update.push(update);
changes
}fnmain()->Result<(), Box<dyn std::error::Error>>{letmut project =solved_project()?;let candidates =vec![
Candidate {
id:"stay".into(),
changes:SchedulingChanges::default(),},
Candidate {
id:"dev2".into(),
changes:reassign("aB","dev2"),},];let leveling =LevelingOptions::default();let options = EvaluateOptions {
level:Some(leveling.clone()),..Default::default()};let result = project.evaluate(&candidates,&options)?;// Lowest cost wins; rejected candidates have no cost.
let best = result
.candidates
.iter().zip(&candidates).filter_map(|(scored,candidate)|scored.cost.map(|cost|(cost, candidate))).min_by(|a,b|a.0.total_cmp(&b.0));ifletSome((cost, winner))= best {letmut changes = winner.changes.clone();// Fails with StaleBaseline if the project changed since the evaluation.
changes.baseline_id =Some(result.baseline_id.clone());let report = project.apply(&changes)?;if report.rejected.is_empty(){// The same options that scored the candidate.
project.level(&leveling)?;println!("committed {} (cost {cost})", winner.id);}}Ok(())}
Results come back in input order, so zipping them with the candidates pairs each score with its change set. Setting baseline_id to the token from the result makes apply refuse the commit when another edit landed in between. In that case, evaluate again.