Skills and Requirements#
This guide shows how to staff tasks by skill: describe who can do what, state what each task needs, and get slots that Project.SuggestAssignments fills. It also covers how earlier assignments stay attached to their requirements from one run to the next.
How the skill layer works#
The scheduling engine doesn't know about skills. Auto-assignment in Plen.Core works on slots: one place for one person on one task, with a list of resources allowed to take it. The separate Plen.Assign package builds those slots from workers and their skills. Add both packages:
The skill types live in the Plen.Assign namespace, so examples import both namespaces. Three inputs go into SkillLayer.Slots:
- A solved
Project. The skill layer reads its scheduled leaf tasks, resources and active assignments throughProject.Roster. - A
PoolofWorkers. Each worker is a resource id plus a list ofSkills. A skill value is0/1for yes-or-no skills, or an integer grade (1 = junior, 2 = senior, and so on).Skill.Valuedefaults to1. Pool order is the tie-break preference. - A list of
Requirements. A requirement says "task T needsCountpeople who satisfy all of theseSkillMatches, each booked atUnits".
Slots returns a List<Slot>, ready to pass to SuggestAssignments:
- Each requirement becomes
Countslots with ids"<requirement id>#1","#2"and so on, in requirement order. Allowedlists the pool workers that pass every match of the requirement, in pool order.Valueis the requirement'sUnits: the rate a new assignment books.Assignmentnames the existing assignment that already fills the slot, if any. See Binding existing assignments.
A slot's id is also the id of the assignment that fills it. After you commit a proposal, the task carries assignments named be#1, be#2, and the next Slots call finds them by id. That's what keeps staffing stable across runs. It also means a requirement id belongs to its task: reuse be on another task after a commit and Slots refuses it.
Skills, pools and requirements aren't stored in ProjectData. You pass them on every call, so they can come from wherever your app keeps them. Slots reads the project and doesn't change it.
Pools and skills#
Build a pool, describe what each task needs, and turn both into slots:
using Plen;
using Plen.Assign;
Worker MakeWorker(string resource, params string[] skills) => new()
{
Resource = resource,
Skills = skills.Select(id => new Skill { Id = id, Value = 1 }).ToList(),
};
var monday = Moment.FromYmdHm(2026, 8, 10, 8, 0) ?? throw new InvalidOperationException("invalid date");
// Two 2-day tasks starting Monday 2026-08-10.
var data = new ProjectData
{
Tasks =
[
new TaskData("api") { StartDate = monday, Duration = 2 },
new TaskData("ui") { StartDate = monday, Duration = 2 },
],
Resources = [new ResourceData { Id = "ann" }, new ResourceData { Id = "bob" }, new ResourceData { Id = "cat" }],
};
var project = new Project();
project.Solve(data, new SolveOptions());
// The pool: who can do what. Order is the tie-break preference.
var pool = new Pool
{
Workers =
[
MakeWorker("ann", "backend", "frontend"),
MakeWorker("bob", "backend"),
MakeWorker("cat", "frontend"),
],
};
// What each task needs.
List<Requirement> requirements =
[
new("be", "api", [SkillMatch.AtLeast("backend", 1)]),
new("fe", "ui", [SkillMatch.AtLeast("frontend", 1)]),
];
foreach (var slot in SkillLayer.Slots(project, pool, requirements))
{
Console.WriteLine($"{slot.Id} on {slot.Task}: allowed [{string.Join(", ", slot.Allowed)}], value {slot.Value}");
}
Output:
be#1 on api: allowed [ann, bob], value 1
fe#1 on ui: allowed [ann, cat], value 1
Ann has both skills, so she's eligible for both slots. Nothing is assigned yet: Slots only says who may take each place. SuggestAssignments chooses who does.
A worker's Resource must name a resource in the project, and each worker appears once. A worker can be in the pool without being eligible for anything, and a project resource can stay out of the pool. A resource outside the pool is never offered for a slot.
Requirements and matches#
The constructor new Requirement(id, task, matches) creates one slot at full rate (Count = 1, Units = 1.0, Assignments = null). Set the properties to ask for more people or a lower rate. A SkillMatch compares a worker's skill value with a number:
| Factory | Holds when |
|---|---|
SkillMatch.AtLeast(skill, v) |
worker value >= v |
SkillMatch.NoMore(skill, v) |
worker value <= v |
SkillMatch.Equal(skill, v) |
worker value == v |
All three are inclusive. You can also set Skill, Op (SkillOp) and Value in an object initializer. A skill the worker doesn't list counts as 0, so SkillMatch.NoMore("qa", 0) means "not a tester". A requirement with several matches needs all of them. A requirement with no matches allows every worker in the pool.
using Plen;
using Plen.Assign;
Worker MakeWorker(string resource, params (string Id, double Value)[] skills) => new()
{
Resource = resource,
Skills = skills.Select(s => new Skill { Id = s.Id, Value = s.Value }).ToList(),
};
var monday = Moment.FromYmdHm(2026, 8, 10, 8, 0) ?? throw new InvalidOperationException("invalid date");
var data = new ProjectData
{
Tasks = [new TaskData("release") { StartDate = monday, Duration = 3 }],
Resources = new[] { "ann", "bob", "cat", "dan" }.Select(id => new ResourceData { Id = id }).ToList(),
};
var project = new Project();
project.Solve(data, new SolveOptions());
// Grades: QA level 1 = junior, 2 = senior.
var pool = new Pool
{
Workers =
[
MakeWorker("ann", ("backend", 1), ("qa", 1)),
MakeWorker("bob", ("backend", 1), ("devops", 1)),
MakeWorker("cat", ("qa", 2)),
MakeWorker("dan", ("qa", 1)),
],
};
List<Requirement> requirements =
[
// Two half-time backend developers.
new("be", "release", [SkillMatch.AtLeast("backend", 1)]) { Count = 2, Units = 0.5 },
// A senior tester.
new("qa", "release", [SkillMatch.AtLeast("qa", 2)]),
// A junior tester: exactly grade 1.
new("qa-jr", "release", [SkillMatch.Equal("qa", 1)]),
// A backend developer who also does devops: every match must hold.
new("ops", "release", [SkillMatch.AtLeast("backend", 1), SkillMatch.AtLeast("devops", 1)]),
// Anyone who isn't a tester: a missing skill counts as 0.
new("review", "release", [SkillMatch.NoMore("qa", 0)]),
];
foreach (var slot in SkillLayer.Slots(project, pool, requirements))
{
Console.WriteLine($"{slot.Id}: value {slot.Value}, allowed [{string.Join(", ", slot.Allowed)}]");
}
Output:
be#1: value 0.5, allowed [ann, bob]
be#2: value 0.5, allowed [ann, bob]
qa#1: value 1, allowed [cat]
qa-jr#1: value 1, allowed [ann, dan]
ops#1: value 1, allowed [bob]
review#1: value 1, allowed [bob]
Count = 2 gives two slots, be#1 and be#2, each booking half-time. Grades turn "senior tester" and "junior tester" into AtLeast and Equal matches on the same skill. review excludes Ann, Cat and Dan because each of them has a QA grade above 0.
A slot list can allow the same person on several slots of one task. SuggestAssignments never puts one resource on a task twice, so Ann can't end up in both be#1 and be#2.
Binding existing assignments#
When the task already has active assignments, Slots attaches them to slots, so a filled place isn't offered again. Binding runs per task, over all requirements on that task, in three steps:
- Slot id. An assignment whose id is one of the requirement's slot ids (
be#2) binds to that slot. This is how assignments created by an earlierSuggestAssignmentsrun return to their slots. It binds even when the resource no longer matches. The slot is then broken. - Explicit list. A non-null
Requirement.Assignmentsreplaces automatic binding for that requirement. Listed entries take the remaining slots in order, and leftover slots stay empty. An empty list ([]) leaves every slot empty. - Matching. The task's remaining assignments go to the empty slots of requirements without a list. An assignment can go to a slot only when its resource is in the slot's
Allowed. The skill layer runs a maximum bipartite matching in input order. It fills as many slots as possible instead of giving each slot the first fit.
Here Ann (backend and frontend) and Bob (backend only) already work on api under ids the skill layer didn't create:
using Plen;
using Plen.Assign;
Worker MakeWorker(string resource, params string[] skills) => new()
{
Resource = resource,
Skills = skills.Select(id => new Skill { Id = id, Value = 1 }).ToList(),
};
void Print(string label, List<Slot> slots) =>
Console.WriteLine($"{label}: " + string.Join(", ",
slots.Select(s => s.Assignment is { } a ? $"{s.Id} <- {a}" : $"{s.Id} empty")));
var monday = Moment.FromYmdHm(2026, 8, 10, 8, 0) ?? throw new InvalidOperationException("invalid date");
// Ann and Bob already work on `api`, under ids the skill layer didn't create.
var data = new ProjectData
{
Tasks = [new TaskData("api") { StartDate = monday, Duration = 2 }],
Resources = [new ResourceData { Id = "ann" }, new ResourceData { Id = "bob" }],
Assignments =
[
new AssignmentData { Id = "a1", Task = "api", Resource = "ann", Value = 1 },
new AssignmentData { Id = "a2", Task = "api", Resource = "bob", Value = 1 },
],
};
var project = new Project();
project.Solve(data, new SolveOptions());
var pool = new Pool { Workers = [MakeWorker("ann", "backend", "frontend"), MakeWorker("bob", "backend")] };
var backend = new Requirement("be", "api", [SkillMatch.AtLeast("backend", 1)]);
var frontend = new Requirement("fe", "api", [SkillMatch.AtLeast("frontend", 1)]);
// Automatic: a maximum matching, so Bob takes backend and Ann frontend.
Print("matching", SkillLayer.Slots(project, pool, [backend, frontend]));
// Explicit list: `be` takes a1, whatever the matching would do.
var pinned = backend.DeepClone();
pinned.Count = 2;
pinned.Assignments = ["a1"];
Print("explicit", SkillLayer.Slots(project, pool, [pinned, frontend]));
// Empty list: every slot of `be` starts empty.
var fresh = backend.DeepClone();
fresh.Assignments = [];
Print("empty list", SkillLayer.Slots(project, pool, [fresh, frontend]));
Output:
matching: be#1 <- a2, fe#1 <- a1
explicit: be#1 <- a1, be#2 empty, fe#1 empty
empty list: be#1 empty, fe#1 <- a1
A greedy pass would give be to Ann, the first backend assignment, and leave fe empty. The matching gives backend to Bob so Ann can cover frontend. The explicit list pins a1 to be#1. Bob's a2 can't fill fe#1, so that slot stays empty. With an empty list, be starts empty and Ann's assignment goes to fe.
The example uses DeepClone() so each variant gets its own requirement and the original stays as it was. See Records, ids and dates.
An assignment that ends up in no slot stays on the task. It still blocks its resource for the task's other slots, and SuggestAssignments never repairs or moves it.
Slots checks explicit lists up front:
- At most
Countentries, each an active assignment on the requirement's task, and each bound once. - If an assignment carries one of the requirement's slot ids, the list must name it. It keeps its own slot.
From slots to suggestions#
Pass the slots straight to Project.SuggestAssignments, commit the proposal with Apply, and reschedule. The next run with the same requirements binds the committed assignments by slot id, so the proposal stays stable. When a worker's skills change, the bound slot becomes broken, and the AssignTarget.Broken target repairs it:
using Plen;
using Plen.Assign;
Worker MakeWorker(string resource, params string[] skills) => new()
{
Resource = resource,
Skills = skills.Select(id => new Skill { Id = id, Value = 1 }).ToList(),
};
var monday = Moment.FromYmdHm(2026, 8, 10, 8, 0) ?? throw new InvalidOperationException("invalid date");
// `api` and `ui` both run Monday-Tuesday. Bob already works on `api`.
var data = new ProjectData
{
Tasks =
[
new TaskData("api") { StartDate = monday, Duration = 2 },
new TaskData("ui") { StartDate = monday, Duration = 2 },
],
Resources = [new ResourceData { Id = "ann" }, new ResourceData { Id = "bob" }],
Assignments = [new AssignmentData { Id = "a2", Task = "api", Resource = "bob", Value = 1 }],
};
var project = new Project();
project.Solve(data, new SolveOptions());
var pool = new Pool { Workers = [MakeWorker("bob", "backend"), MakeWorker("ann", "backend")] };
List<Requirement> requirements =
[
new("be-api", "api", [SkillMatch.AtLeast("backend", 1)]),
new("be-ui", "ui", [SkillMatch.AtLeast("backend", 1)]),
];
// Run 1: fill the empty slot.
var slots = SkillLayer.Slots(project, pool, requirements);
var proposal = project.SuggestAssignments(slots, new SuggestOptions());
foreach (var step in proposal.Steps)
Console.WriteLine($"pass {step.Pass}: {step.Slot} -> {step.ToResource}");
// Commit the proposal and reschedule.
var report = project.Apply(proposal.Changes);
if (report.Rejected.Count > 0)
throw new InvalidOperationException($"{report.Rejected[0].Path}: {report.Rejected[0].Message}");
project.Solve(null, new SolveOptions());
// Run 2: the same requirements bind to what run 1 committed.
slots = SkillLayer.Slots(project, pool, requirements);
foreach (var slot in slots)
Console.WriteLine($"{slot.Id} <- {slot.Assignment?.ToString() ?? "empty"}");
var options = new SuggestOptions { Targets = [AssignTarget.Empty, AssignTarget.Broken, AssignTarget.Valid] };
proposal = project.SuggestAssignments(slots, options);
Console.WriteLine($"run 2: {proposal.Steps.Count} steps");
// Run 3: Ann moved to frontend, so her slot is now broken.
pool = new Pool { Workers = [MakeWorker("bob", "backend"), MakeWorker("ann", "frontend")] };
slots = SkillLayer.Slots(project, pool, requirements);
var check = project.SuggestAssignments(slots, new SuggestOptions { Targets = [] });
Console.WriteLine($"invalid: [{string.Join(", ", check.Invalid)}]");
proposal = project.SuggestAssignments(slots, options);
foreach (var step in proposal.Steps)
Console.WriteLine($"pass {step.Pass}: {step.Slot} {step.FromResource} -> {step.ToResource}");
Output:
pass 0: be-ui#1 -> ann
be-api#1 <- a2
be-ui#1 <- be-ui#1
run 2: 0 steps
invalid: [be-ui#1]
pass 0: be-ui#1 ann -> bob
Run 1 fills be-ui#1 with Ann. Bob comes first in the pool, but he's already busy on api. The committed assignment gets the slot's id. In run 2 it binds back to its slot, and with every target on there's nothing to improve. In run 3 Ann no longer has the backend skill. Targets = [] only reports the broken slot in Invalid. The full run repairs it by moving the assignment to Bob.
Some things to keep in mind:
Slotsneeds a solved project with no unsolved edits. AfterApply, callSolve(null, …)before you build slots again.SuggestAssignmentschecks the slots again: unknown tasks or resources, duplicate slot ids, and a slot id that is already an assignment on another task are errors. Slots fromSlotspass these checks, unless the project changed in between.- Targets,
Fixedassignments,NewResourcePenalty, leveled scoring and the step log are covered in Suggesting assignments. - To keep a person on a slot whatever the load, add the slot's assignment id to
SuggestOptions.Fixed.
Errors#
SkillLayer.Slots throws the same exceptions as the rest of the API:
ProjectExceptionwithCode = ProjectError.NoBaseline: the project is unsolved, or it has unsolved edits afterApply.Slotstakes no baseline token, so it never throwsStaleBaseline.InputExceptionwithKind = InputErrorKind.Validation: bad pool or requirement input.Pathnames the offending item andMessagesays what's wrong.ArgumentNullExceptionfor anullproject, pool or requirement list.
using Plen;
using Plen.Assign;
var pool = new Pool
{
Workers = [new Worker { Resource = "ann", Skills = [new Skill { Id = "backend", Value = 1 }] }],
};
var requirement = new Requirement("be", "api", [SkillMatch.AtLeast("backend", 1)]);
// Not solved yet: no accepted state to read.
var project = new Project();
try
{
SkillLayer.Slots(project, pool, [requirement]);
}
catch (ProjectException e)
{
Console.WriteLine($"project error: {e.Code} ({e.Message})");
}
var monday = Moment.FromYmdHm(2026, 8, 10, 8, 0) ?? throw new InvalidOperationException("invalid date");
project.Solve(new ProjectData
{
Tasks = [new TaskData("api") { StartDate = monday, Duration = 2 }],
Resources = [new ResourceData { Id = "ann" }],
}, new SolveOptions());
// `Count = 0` is invalid input with a path.
var empty = requirement.DeepClone();
empty.Count = 0;
try
{
SkillLayer.Slots(project, pool, [empty]);
}
catch (InputException e)
{
Console.WriteLine($"invalid: {e.Path}: {e.Message}");
}
Output:
project error: NoBaseline (no-baseline)
invalid: requirements[0].count: must be at least 1
The Path points at the offending item:
| Path | Reason |
|---|---|
pool.workers[i].resource |
Unknown resource, or a worker listed twice |
pool.workers[i].skills[j].id |
A skill listed twice for one worker |
pool.workers[i].skills[j].value |
Not a finite number |
requirements[i].id |
Duplicate requirement id, or one of its slot ids is an assignment on another task |
requirements[i].task |
Unknown or unscheduled task |
requirements[i].count |
0 |
requirements[i].units |
Not finite, or 0 or less |
requirements[i].matches[j].value |
Not a finite number |
requirements[i].assignments |
More entries than Count, or an assignment with this requirement's slot id is left out |
requirements[i].assignments[k] |
Unknown or inactive assignment, on another task, or bound twice (the message names the other requirement when there is one) |
Slots throws on the first problem it finds and builds nothing. It never changes the project, so you can fix the input and call again.
Since Slots and SuggestAssignments throw the same types, one try block covers both calls:
using Plen;
using Plen.Assign;
// One catch covers both calls: they throw the same exception types.
SuggestResult Staff(Project project, Pool pool, IReadOnlyList<Requirement> requirements)
{
var slots = SkillLayer.Slots(project, pool, requirements);
return project.SuggestAssignments(slots, new SuggestOptions());
}
try
{
var result = Staff(new Project(), new Pool(), []);
Console.WriteLine($"{result.Steps.Count} steps");
}
catch (ProjectException e)
{
Console.WriteLine($"project error: {e.Message}");
}
catch (InputException e)
{
Console.WriteLine($"input error at {e.Path}: {e.Message}");
}
Output:
project error: no-baseline
See Error handling for the full error model.