Suggesting assignments#

This guide shows how to let Planning Engine pick who works on a task: filling open positions, fixing assignments that no longer fit, and moving work off overloaded people. It also covers how to read the proposal and commit it.

How auto-assignment works#

Project#suggestAssignments(slots, options) takes a list of slots and returns a proposal. It never changes the project. You decide what to commit.

A slot is one position on one task: "task C needs one person, and dev1 or dev2 can do it". It holds the task, the list of eligible resources (allowed) and, when someone already fills it, the assignment that does. Slots know nothing about skills. If you model skills, SkillLayer.slots turns workers and requirements into slots for you (see Skills and requirements).

Every slot is in one of three states, judged from its current resource:

State Meaning AssignTarget What the call does
empty no assignment EMPTY fills it with the cheapest eligible resource
broken held by a resource outside allowed BROKEN moves it to the cheapest eligible resource
valid held by a resource in allowed VALID moves it only if another resource is strictly cheaper

SuggestOptions.targets says which states to act on. The default is [EMPTY]: fill open positions, leave the rest alone.

The call is greedy. It visits slots one at a time and tries every eligible resource on a working copy of the project. Each try is scheduled and scored the same way evaluate scores a candidate: finish delay plus overload plus a heavy penalty for conflicts. The cheapest resource wins, and later slots build on the earlier picks.

The result has two parts. changes is a SchedulingChanges object you can pass straight to apply. steps is a log of every decision, with the cost before and after, so a UI can explain each pick.

suggestAssignments never removes an assignment. It can add one (fill) or change the resource of one (repair, rebalance). A slot it can't fix is reported, and you decide what to do with it.

The call is synchronous and needs a solved project with no unsolved edits. On an unsolved project it throws ProjectException with code NO_BASELINE. A baselineId that doesn't name the current baseline throws STALE_BASELINE.

Slots#

Build a slot with new Slot(id, task, allowed). allowed takes a Vec<Id> or any Collection, so List.of(...) works. The order of allowed is the tie-break: when two resources cost the same, the earlier one wins. A new assignment gets value units, 1.0 by default.

The slot id becomes the id of the assignment that fills it. On the next run, pass that id back in slot.assignment, so the slot is filled and isn't filled twice.

import org.dhtmlx.plen.*;
import java.util.List;

public class FillSlot {
    static TaskData task(String id, Moment start, double duration) {
        return new TaskData(Id.str(id)).withStartDate(start).withDuration(duration);
    }

    static ResourceData resource(String id) {
        return new ResourceData().withId(Id.str(id));
    }

    static AssignmentData assignment(String id, String task, String resource) {
        return new AssignmentData()
            .withId(Id.str(id))
            .withTask(Id.str(task))
            .withResource(Id.str(resource))
            .withValue(1.0);
    }

    public static void main(String[] args) throws Exception {
        Moment start = Moment.fromYmdHm(2026, 8, 10, 0, 0);
        ProjectData data = new ProjectData()
            .withConfig(new SchedulingConfig().withUseCalendars(false))
            .withTasks(List.of(task("A", start, 2.0), task("B", start, 2.0), task("C", start, 2.0)))
            .withResources(List.of(resource("dev1"), resource("dev2"), resource("dev3")))
            // dev1 works on A and B at the same time.
            .withAssignments(List.of(assignment("aA", "A", "dev1"), assignment("aB", "B", "dev1")));

        Project project = new Project();
        project.solve(data, new SolveOptions());

        // Task C needs one person: dev1 or dev2.
        List<Slot> slots = List.of(new Slot(Id.str("sC"), Id.str("C"), List.of(Id.str("dev1"), Id.str("dev2"))));
        SuggestResult result = project.suggestAssignments(slots, new SuggestOptions());

        for (AssignmentData add : result.changes.assignments.add) {
            System.out.println(add.id + " on " + add.task + ": " + add.resource);
        }
    }
}

This prints sC on C: dev2. dev1 is already double-booked on A and B, so putting C on dev1 adds two more days of overload. dev2 is free.

To describe a filled slot, set assignment with withAssignment. The assignment must be active and on the slot's task:

import org.dhtmlx.plen.*;
import java.util.List;

public class FilledSlot {
    public static void main(String[] args) {
        Slot slot = new Slot(Id.str("sB"), Id.str("B"), List.of(Id.str("dev1"), Id.str("dev2"), Id.str("dev3")))
            .withAssignment(Id.str("aB"));
        System.out.println(slot);
    }
}

The call checks every slot before it does any work. It throws InputException on a duplicate slot id, an unknown or summary task, an unknown resource in allowed, a value that isn't a finite number above 0, or a bad assignment. The exception's path points at the slot (slots[1].allowed[0]). The full list is on the Slot reference.

If you model skills, don't build slots by hand. SkillLayer.slots builds them from a pool of workers and task requirements and binds existing assignments to their slots:

import org.dhtmlx.plen.*;
import java.util.List;

public class SkillSlots {
    public static void main(String[] args) throws Exception {
        Moment start = Moment.fromYmdHm(2026, 8, 10, 0, 0);
        ProjectData data = new ProjectData()
            .withConfig(new SchedulingConfig().withUseCalendars(false))
            .withTasks(List.of(new TaskData(Id.str("C")).withStartDate(start).withDuration(2.0)))
            .withResources(List.of(
                new ResourceData().withId(Id.str("dev1")),
                new ResourceData().withId(Id.str("dev2"))));

        Project project = new Project();
        project.solve(data, new SolveOptions());

        Pool pool = new Pool().withWorkers(List.of(
            new Worker()
                .withResource(Id.str("dev1"))
                .withSkills(List.of(new Skill().withId(Id.str("qa")).withValue(1.0))),
            new Worker().withResource(Id.str("dev2"))));
        List<Requirement> requirements = List.of(
            new Requirement(Id.str("qa"), Id.str("C"), List.of(SkillMatch.atLeast(Id.str("qa"), 1.0))));

        // One slot "qa#1" on C, with dev1 as the only eligible resource.
        Vec<Slot> slots = SkillLayer.slots(project, pool, requirements);
        SuggestResult result = project.suggestAssignments(slots, new SuggestOptions());
        System.out.println(result.steps.size() + " step(s)");
        System.out.println(slots.get(0).id + " " + slots.get(0).allowed);
    }
}

This prints 1 step(s), then qa#1 [dev1]: dev2 has no qa skill, so it isn't in allowed.

Options#

new SuggestOptions() gives the defaults. Change only the fields you need with the fluent withX setters.

Field Default Use it to
targets [EMPTY] choose which slot states to act on; an empty list only reports broken slots
fixed empty keep listed assignments where they are
newResourcePenalty 0.0 trade balance against headcount (see How picks are made)
passes 1 rebalance valid slots more than once
level null score each candidate after leveling
clearFirst false score against a copy with stored leveling delays cleared
baselineId null throw STALE_BASELINE if the project changed

All fields are on the SuggestOptions reference.

Rebalancing filled slots#

Add AssignTarget.VALID to move work off overloaded people. Rebalancing runs after filling and repairing, so it also sees the slots that were just filled.

import org.dhtmlx.plen.*;
import java.util.List;

public class Rebalance {
    static TaskData task(String id, Moment start, double duration) {
        return new TaskData(Id.str(id)).withStartDate(start).withDuration(duration);
    }

    static ResourceData resource(String id) {
        return new ResourceData().withId(Id.str(id));
    }

    static AssignmentData assignment(String id, String task, String resource) {
        return new AssignmentData()
            .withId(Id.str(id))
            .withTask(Id.str(task))
            .withResource(Id.str(resource))
            .withValue(1.0);
    }

    public static void main(String[] args) throws Exception {
        Moment start = Moment.fromYmdHm(2026, 8, 10, 0, 0);
        ProjectData data = new ProjectData()
            .withConfig(new SchedulingConfig().withUseCalendars(false))
            .withTasks(List.of(task("A", start, 2.0), task("B", start, 2.0), task("C", start, 2.0)))
            .withResources(List.of(resource("dev1"), resource("dev2"), resource("dev3")))
            .withAssignments(List.of(assignment("aA", "A", "dev1"), assignment("aB", "B", "dev1")));

        Project project = new Project();
        project.solve(data, new SolveOptions());

        Slot b = new Slot(Id.str("sB"), Id.str("B"), List.of(Id.str("dev1"), Id.str("dev2"), Id.str("dev3")))
            .withAssignment(Id.str("aB"));
        List<Slot> slots = List.of(new Slot(Id.str("sC"), Id.str("C"), List.of(Id.str("dev1"), Id.str("dev2"))), b);

        SuggestOptions options = new SuggestOptions()
            .withTargets(List.of(AssignTarget.EMPTY, AssignTarget.VALID));
        SuggestResult result = project.suggestAssignments(slots, options);

        for (AssignmentUpdate update : result.changes.assignments.update) {
            System.out.println(update.id + ": " + update.resource.value());
        }
    }
}

The call fills sC with dev2, then moves aB from dev1 to dev3. dev2 isn't a good pick for B anymore, because it now works on C. The proposal holds one add (sC) and one update (aB: dev3).

A valid slot moves only on strict improvement: on a tie, the current resource stays. With newResourcePenalty at 0.0, a valid slot is skipped when its current resource isn't overloaded during the task. One pass is often enough. When a later move makes an earlier pick worse, raise passes. The loop stops early after a pass that moves nothing.

Keeping people in place#

List assignment ids in fixed to keep them out of every pass, whatever the load:

import org.dhtmlx.plen.*;
import java.util.List;

public class Fixed {
    static TaskData task(String id, Moment start, double duration) {
        return new TaskData(Id.str(id)).withStartDate(start).withDuration(duration);
    }

    static ResourceData resource(String id) {
        return new ResourceData().withId(Id.str(id));
    }

    static AssignmentData assignment(String id, String task, String resource) {
        return new AssignmentData()
            .withId(Id.str(id))
            .withTask(Id.str(task))
            .withResource(Id.str(resource))
            .withValue(1.0);
    }

    public static void main(String[] args) throws Exception {
        Moment start = Moment.fromYmdHm(2026, 8, 10, 0, 0);
        ProjectData data = new ProjectData()
            .withConfig(new SchedulingConfig().withUseCalendars(false))
            .withTasks(List.of(task("A", start, 2.0), task("B", start, 2.0), task("C", start, 2.0)))
            .withResources(List.of(resource("dev1"), resource("dev2"), resource("dev3")))
            .withAssignments(List.of(assignment("aA", "A", "dev1"), assignment("aB", "B", "dev1")));

        Project project = new Project();
        project.solve(data, new SolveOptions());

        Slot b = new Slot(Id.str("sB"), Id.str("B"), List.of(Id.str("dev1"), Id.str("dev2"), Id.str("dev3")))
            .withAssignment(Id.str("aB"));
        List<Slot> slots = List.of(new Slot(Id.str("sC"), Id.str("C"), List.of(Id.str("dev1"), Id.str("dev2"))), b);

        SuggestOptions options = new SuggestOptions()
            .withTargets(List.of(AssignTarget.EMPTY, AssignTarget.VALID))
            .withFixed(List.of(Id.str("aB"))); // dev1 stays on B
        SuggestResult result = project.suggestAssignments(slots, options);

        System.out.println(result.changes.assignments.add.size() + " add(s), "
            + result.changes.assignments.update.size() + " update(s)");
    }
}

This prints 1 add(s), 0 update(s). sC is still filled with dev2, but aB stays on dev1. A fixed resource still counts as being on its task. A broken fixed slot still shows up in invalid. An id in fixed must name an active assignment; it doesn't have to be bound to a slot.

Repairing broken slots#

A slot breaks when its resource drops out of allowed, for example after a requirement or a skill grade changed. Run with an empty targets list first to only check, then with AssignTarget.BROKEN to fix:

import org.dhtmlx.plen.*;
import java.util.List;

public class Repair {
    static TaskData task(String id, Moment start, double duration) {
        return new TaskData(Id.str(id)).withStartDate(start).withDuration(duration);
    }

    static ResourceData resource(String id) {
        return new ResourceData().withId(Id.str(id));
    }

    static AssignmentData assignment(String id, String task, String resource) {
        return new AssignmentData()
            .withId(Id.str(id))
            .withTask(Id.str(task))
            .withResource(Id.str(resource))
            .withValue(1.0);
    }

    public static void main(String[] args) throws Exception {
        Moment start = Moment.fromYmdHm(2026, 8, 10, 0, 0);
        ProjectData data = new ProjectData()
            .withConfig(new SchedulingConfig().withUseCalendars(false))
            .withTasks(List.of(task("A", start, 2.0), task("B", start, 2.0)))
            .withResources(List.of(resource("dev1"), resource("dev2"), resource("dev3")))
            .withAssignments(List.of(assignment("aA", "A", "dev1"), assignment("aB", "B", "dev1")));

        Project project = new Project();
        project.solve(data, new SolveOptions());

        // B now needs dev2 or dev3, but dev1 holds it.
        Slot b = new Slot(Id.str("sB"), Id.str("B"), List.of(Id.str("dev2"), Id.str("dev3")))
            .withAssignment(Id.str("aB"));
        List<Slot> slots = List.of(b);

        SuggestOptions check = new SuggestOptions().withTargets(List.of());
        SuggestResult report = project.suggestAssignments(slots, check);
        System.out.println("broken: " + report.invalid); // broken: [sB]

        SuggestOptions repair = new SuggestOptions().withTargets(List.of(AssignTarget.BROKEN));
        SuggestResult result = project.suggestAssignments(slots, repair);
        for (SuggestStep step : result.steps) {
            System.out.println(step.slot + ": " + step.fromResource + " -> " + step.toResource); // sB: dev1 -> dev2
        }
    }
}

A repair always moves the slot. It takes the cheapest eligible resource, even if keeping the current one would cost less. A broken slot is visited once, with no retry. If no eligible resource can take it, it keeps its assignment and stays in invalid.

Scoring after leveling#

By default each candidate is only scheduled, so overload shows up as cost. With level, each candidate is also leveled with those options and scored on the leveled plan, where overload turns into delay. The score then tells you how much later the project would finish with each pick.

import org.dhtmlx.plen.*;
import java.util.List;

public class Leveled {
    static TaskData task(String id, Moment start, double duration) {
        return new TaskData(Id.str(id)).withStartDate(start).withDuration(duration);
    }

    static ResourceData resource(String id) {
        return new ResourceData().withId(Id.str(id));
    }

    static AssignmentData assignment(String id, String task, String resource) {
        return new AssignmentData()
            .withId(Id.str(id))
            .withTask(Id.str(task))
            .withResource(Id.str(resource))
            .withValue(1.0);
    }

    public static void main(String[] args) throws Exception {
        Moment start = Moment.fromYmdHm(2026, 8, 10, 0, 0);
        ProjectData data = new ProjectData()
            .withConfig(new SchedulingConfig().withUseCalendars(false))
            .withTasks(List.of(task("A", start, 2.0), task("B", start, 2.0), task("C", start, 2.0)))
            .withResources(List.of(resource("dev1"), resource("dev2"), resource("dev3")))
            .withAssignments(List.of(assignment("aA", "A", "dev1"), assignment("aB", "B", "dev1")));

        Project project = new Project();
        project.solve(data, new SolveOptions());

        List<Slot> slots = List.of(new Slot(Id.str("sC"), Id.str("C"), List.of(Id.str("dev1"), Id.str("dev2"))));
        SuggestOptions options = new SuggestOptions().withLevel(new LevelingOptions());
        SuggestResult result = project.suggestAssignments(slots, options);

        LeveledMetrics leveled = result.proposed.leveled;
        if (leveled != null && leveled.metrics.finish.project != null) {
            Shift finish = leveled.metrics.finish.project;
            System.out.println("leveled finish: " + finish.date + " (" + finish.delta + " days later)");
        }
    }
}

sC goes to dev2. The leveled finish is two days later than the accepted plan, because leveling still has to separate A and B on dev1.

Each pick builds on the scheduled copy, not the leveled one, so leveling delays don't add up across picks. Every candidate runs a full level, so keep leveled scoring for small sets of slots. Like level, the call doesn't return when an assignment asks for more units than its resource can ever supply.

If the project holds delays from an earlier level run, those delays can hide overload: the rebalance skip rule then sees nothing to fix. Call withClearFirst(true) to score against a copy with the delays and pauses cleared. changes then starts with a tasks.update for each cleared task, so committing it reproduces the cleared state.

How picks are made#

Slots are visited in a fixed order, set once from the accepted plan: task start, then task order in the model, then the order of the slots you passed. Picks that move dates don't change the order. The order matters, because the result is greedy: an early slot takes the best resource, and later slots work around it.

The call runs in passes:

  1. Pass 0 fills empty slots and repairs broken ones, if those states are targeted.
  2. Passes 1 to passes rebalance every valid slot, if VALID is targeted.

For each slot it tries every resource in allowed, except resources already on the task (a task never gets the same person twice) and duplicates. It schedules each try on a copy of the working state and computes its cost:

cost = evaluate cost against the accepted plan
     + newResourcePenalty ร— (people in use now โˆ’ people in use in the accepted plan)

The evaluate cost is finish delay plus overload plus 1000 per conflict or cycle; see Evaluating alternatives. It's always measured against the accepted plan, never against the previous pick.

"People in use" counts the resources in any slot's allowed, plus the current holders of filled slots, that have at least one active assignment. Once someone is brought in, later slots can use them for free. Moving someone's last assignment away lowers the cost.

newResourcePenalty is the one lever between balance and headcount. At 0.0 the call spreads work to idle people. A large value keeps work on the people already busy, as long as their extra overload costs less than the penalty:

import org.dhtmlx.plen.*;
import java.util.List;

public class Penalty {
    static TaskData task(String id, Moment start, double duration) {
        return new TaskData(Id.str(id)).withStartDate(start).withDuration(duration);
    }

    static ResourceData resource(String id) {
        return new ResourceData().withId(Id.str(id));
    }

    static AssignmentData assignment(String id, String task, String resource) {
        return new AssignmentData()
            .withId(Id.str(id))
            .withTask(Id.str(task))
            .withResource(Id.str(resource))
            .withValue(1.0);
    }

    public static void main(String[] args) throws Exception {
        Moment start = Moment.fromYmdHm(2026, 8, 10, 0, 0);
        ProjectData data = new ProjectData()
            .withConfig(new SchedulingConfig().withUseCalendars(false))
            .withTasks(List.of(task("A", start, 2.0), task("B", start, 2.0), task("C", start, 2.0)))
            .withResources(List.of(resource("dev1"), resource("dev2"), resource("dev3")))
            .withAssignments(List.of(assignment("aA", "A", "dev1"), assignment("aB", "B", "dev1")));

        Project project = new Project();
        project.solve(data, new SolveOptions());

        Slot b = new Slot(Id.str("sB"), Id.str("B"), List.of(Id.str("dev1"), Id.str("dev2"), Id.str("dev3")))
            .withAssignment(Id.str("aB"));
        List<Slot> slots = List.of(new Slot(Id.str("sC"), Id.str("C"), List.of(Id.str("dev1"), Id.str("dev2"))), b);

        for (double penalty : new double[] {0.0, 0.5, 5.0}) {
            SuggestOptions options = new SuggestOptions()
                .withTargets(List.of(AssignTarget.EMPTY, AssignTarget.VALID))
                .withNewResourcePenalty(penalty);
            SuggestResult result = project.suggestAssignments(slots, options);
            System.out.println("penalty " + penalty + ": " + result.proposed.resources
                + " in use, cost " + result.proposed.cost);
        }
    }
}
penalty 0.0: 3 in use, cost 0.0
penalty 0.5: 3 in use, cost 1.0
penalty 5.0: 1 in use, cost 4.0

At 0.5, two new people cost 1, and removing four days of overload is worth more. At 5.0, one new person would cost more than the two extra days of overload on dev1, so C goes to dev1 and nobody else is brought in. With a penalty above 0.0, valid slots aren't skipped when their holder isn't overloaded: freeing a person is a gain on its own.

A candidate that can't be applied or scheduled is dropped. An empty slot with no candidate left goes to unfilled.

Reading the proposal#

SuggestResult has everything a UI needs to show and explain the proposal:

  • changes - the net edit: one add per filled slot, one update per moved assignment (even after several passes), and the clearFirst clears. Its baselineId is set.
  • steps - every decision that changed the working state, in order.
  • unfilled - targeted empty slots left empty.
  • invalid - slots whose resource is outside allowed at the end, whatever targets was.
  • baseline and proposed - cost and people in use before and after. proposed also carries the scheduled Metrics and, with level, the leveled ones.

A SuggestStep has no kind field. Read the kind from fromResource (null for a fill) and pass:

import org.dhtmlx.plen.*;
import java.util.List;

public class Steps {
    static TaskData task(String id, Moment start, double duration) {
        return new TaskData(Id.str(id)).withStartDate(start).withDuration(duration);
    }

    static ResourceData resource(String id) {
        return new ResourceData().withId(Id.str(id));
    }

    static AssignmentData assignment(String id, String task, String resource) {
        return new AssignmentData()
            .withId(Id.str(id))
            .withTask(Id.str(task))
            .withResource(Id.str(resource))
            .withValue(1.0);
    }

    public static void main(String[] args) throws Exception {
        Moment start = Moment.fromYmdHm(2026, 8, 10, 0, 0);
        ProjectData data = new ProjectData()
            .withConfig(new SchedulingConfig().withUseCalendars(false))
            .withTasks(List.of(task("A", start, 2.0), task("B", start, 2.0), task("C", start, 2.0)))
            .withResources(List.of(resource("dev1"), resource("dev2"), resource("dev3")))
            .withAssignments(List.of(assignment("aA", "A", "dev1"), assignment("aB", "B", "dev1")));

        Project project = new Project();
        project.solve(data, new SolveOptions());

        Slot b = new Slot(Id.str("sB"), Id.str("B"), List.of(Id.str("dev1"), Id.str("dev2"), Id.str("dev3")))
            .withAssignment(Id.str("aB"));
        List<Slot> slots = List.of(new Slot(Id.str("sC"), Id.str("C"), List.of(Id.str("dev1"), Id.str("dev2"))), b);

        SuggestOptions options = new SuggestOptions()
            .withTargets(List.of(AssignTarget.EMPTY, AssignTarget.VALID));
        SuggestResult result = project.suggestAssignments(slots, options);

        System.out.println("cost " + result.baseline.cost + " -> " + result.proposed.cost);
        for (SuggestStep step : result.steps) {
            String action;
            if (step.fromResource == null) {
                action = "fill";
            } else if (step.pass == 0) {
                action = "repair from " + step.fromResource;
            } else {
                action = "rebalance from " + step.fromResource + " (pass " + step.pass + ")";
            }
            System.out.println(step.slot + " on " + step.task + ": " + action + " to " + step.toResource
                + ", cost " + step.cost.before + " -> " + step.cost.after);
        }
    }
}
cost 2.0 -> 0.0
sC on C: fill to dev2, cost 2.0 -> 2.0
sB on B: rebalance from dev1 (pass 1) to dev3, cost 2.0 -> 0.0

The step costs form a chain: the first before is baseline.cost, and each before is the previous step's after. The baseline costs 2 because dev1 is already overloaded for two days. Filling C doesn't make that worse, and moving B to dev3 removes it.

proposed.scheduled is the full Metrics of the final state, with finish, overload per resource, conflicts and cycles. proposed.leveled is null unless you set level. See SuggestResult and Metrics.

Committing#

Pass result.changes to apply, then reschedule. If you scored with level, run level with the same options instead of solve(null, ...).

import org.dhtmlx.plen.*;
import java.util.List;

public class Commit {
    static TaskData task(String id, Moment start, double duration) {
        return new TaskData(Id.str(id)).withStartDate(start).withDuration(duration);
    }

    static ResourceData resource(String id) {
        return new ResourceData().withId(Id.str(id));
    }

    static AssignmentData assignment(String id, String task, String resource) {
        return new AssignmentData()
            .withId(Id.str(id))
            .withTask(Id.str(task))
            .withResource(Id.str(resource))
            .withValue(1.0);
    }

    public static void main(String[] args) throws Exception {
        Moment start = Moment.fromYmdHm(2026, 8, 10, 0, 0);
        ProjectData data = new ProjectData()
            .withConfig(new SchedulingConfig().withUseCalendars(false))
            .withTasks(List.of(task("A", start, 2.0), task("B", start, 2.0), task("C", start, 2.0)))
            .withResources(List.of(resource("dev1"), resource("dev2"), resource("dev3")))
            .withAssignments(List.of(assignment("aA", "A", "dev1"), assignment("aB", "B", "dev1")));

        Project project = new Project();
        project.solve(data, new SolveOptions());

        Slot b = new Slot(Id.str("sB"), Id.str("B"), List.of(Id.str("dev1"), Id.str("dev2"), Id.str("dev3")))
            .withAssignment(Id.str("aB"));
        List<Slot> slots = List.of(new Slot(Id.str("sC"), Id.str("C"), List.of(Id.str("dev1"), Id.str("dev2"))), b);

        SuggestOptions options = new SuggestOptions()
            .withTargets(List.of(AssignTarget.EMPTY, AssignTarget.VALID));
        SuggestResult result = project.suggestAssignments(slots, options);

        ApplyReport report = project.apply(result.changes);
        if (!report.rejected.isEmpty()) {
            throw new IllegalStateException("proposal rejected, resync the project");
        }
        project.solve(null, new SolveOptions());

        // Next run: sC is now the id of the assignment on C.
        Slot c = new Slot(Id.str("sC"), Id.str("C"), List.of(Id.str("dev1"), Id.str("dev2")))
            .withAssignment(Id.str("sC"));
        SuggestResult again = project.suggestAssignments(List.of(c, b), options);
        System.out.println(again.steps.size() + " step(s)"); // 0 step(s): nothing left to improve
    }
}

changes.baselineId names the plan the proposal was built on. If the project changed in between, apply throws ProjectException with code STALE_BASELINE instead of committing an outdated proposal. A rejected operation sets needsResync on the ApplyReport; see Recovering from rejected changes.

The new assignment takes the slot id, so the slot keeps its binding after the commit. On the next run, pass that id in slot.assignment, as above. SkillLayer.slots binds slots this way for you. A slot id that is already the id of another assignment makes the call throw InputException at slots[i].id. If the slot isn't bound, every add for it would be rejected as a duplicate id, and the slot would stay unfilled without a reason.

You can also commit part of a proposal. changes is a plain SchedulingChanges object, so remove the adds or updates the user declines from changes.assignments.add and changes.assignments.update before you call apply.

The committed plan matches proposed when config.gapBehavior is GapBehavior.COMPRESS. With the default PRESERVE, it can differ slightly. The call reschedules after each pick, while the commit reschedules once, and preserved dates depend on the path taken. Treat proposed as an estimate in that mode.

suggestAssignments runs one schedule (and one level, with level) per slot and candidate, all on the calling thread. On large projects, run it on a worker executor rather than on a request or UI thread. A Project isn't synchronized, so don't edit it from another thread while the call runs.