This guide shows how to staff tasks by skill: describe who can do what, state what each task needs, and get slots that Project::suggest_assignments fills. It also covers how earlier assignments stay attached to their requirements from one run to the next.
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 crate builds those slots from the data you already work with: workers and their skills. Add both crates:
The accepted state of a solved Project. The skill layer reads its scheduled leaf tasks, resources and active assignments through Project::roster.
A Pool of Workers. Each worker is a resource id plus a list of Skills. A skill value is 0/1 for yes-or-no skills, or an integer grade (1 = junior, 2 = senior, and so on). Pool order is the tie-break preference.
A list of Requirements. A requirement says "task T needs count people who satisfy all of these SkillMatches, each booked at units".
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 takes &Project and doesn't change it.
Build a pool, describe what each task needs, and turn both into slots:
useplen_assign::{slots, Pool, Requirement, Skill, SkillMatch, Worker};useplen_core::{DateTime, Project, ProjectData, ResourceData, SolveOptions, TaskData};fnworker(resource:&str, skills:&[(&str,f64)])-> Worker{
Worker {
resource: resource.into(),
skills: skills
.iter().map(|&(id,value)|Skill { id: id.into(), value }).collect(),}}fnmain()->Result<(), Box<dyn std::error::Error>>{// Two 2-day tasks starting Monday 2026-08-10.
letmut tasks =Vec::new();for id in["api","ui"]{letmut task =TaskData::new(id);
task.start_date =DateTime::from_ymd_hm(2026,8,10,8,0);
task.duration =Some(2.0);
tasks.push(task);}let data = ProjectData {
tasks,
resources:["ann","bob","cat"].map(|id|ResourceData { id: id.into(), capacity:None, calendar_id:None}).to_vec(),..Default::default()};letmut project =Project::default();
project.solve(Some(data),&SolveOptions::default())?;// The pool: who can do what. Order is the tie-break preference.
let pool = Pool {
workers:vec![worker("ann",&[("backend",1.0),("frontend",1.0)]),worker("bob",&[("backend",1.0)]),worker("cat",&[("frontend",1.0)]),],};// What each task needs.
let requirements =[Requirement::new("be","api",vec![SkillMatch::at_least("backend",1.0)]),Requirement::new("fe","ui",vec![SkillMatch::at_least("frontend",1.0)]),];for slot inslots(&project,&pool,&requirements)?{let allowed:Vec<String>= slot.allowed.iter().map(|id|id.to_string()).collect();println!("{} on {}: allowed {allowed:?}, value {}", slot.id, slot.task, slot.value);}Ok(())}
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. Choosing who does is the job of suggest_assignments.
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.
Requirement::new creates one slot at full rate (count: 1, units: 1.0, no explicit binding). Change the fields to ask for more people or a lower rate. A SkillMatch compares a worker's skill value with a number:
All three are inclusive. A skill the worker doesn't list counts as 0, so no_more("qa", 0.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.
useplen_assign::{slots, Pool, Requirement, Skill, SkillMatch, Worker};useplen_core::{DateTime, Project, ProjectData, ResourceData, SolveOptions, TaskData};fnworker(resource:&str, skills:&[(&str,f64)])-> Worker{
Worker {
resource: resource.into(),
skills: skills
.iter().map(|&(id,value)|Skill { id: id.into(), value }).collect(),}}fnmain()->Result<(), Box<dyn std::error::Error>>{letmut release =TaskData::new("release");
release.start_date =DateTime::from_ymd_hm(2026,8,10,8,0);
release.duration =Some(3.0);let data = ProjectData {
tasks:vec![release],
resources:["ann","bob","cat","dan"].map(|id|ResourceData { id: id.into(), capacity:None, calendar_id:None}).to_vec(),..Default::default()};letmut project =Project::default();
project.solve(Some(data),&SolveOptions::default())?;// Grades: QA level 1 = junior, 2 = senior.
let pool = Pool {
workers:vec![worker("ann",&[("backend",1.0),("qa",1.0)]),worker("bob",&[("backend",1.0),("devops",1.0)]),worker("cat",&[("qa",2.0)]),worker("dan",&[("qa",1.0)]),],};// Two half-time backend developers.
letmut backend =Requirement::new("be","release",vec![SkillMatch::at_least("backend",1.0)]);
backend.count =2;
backend.units =0.5;// A senior tester.
let senior_qa =Requirement::new("qa","release",vec![SkillMatch::at_least("qa",2.0)]);// A junior tester: exactly grade 1.
let junior_qa =Requirement::new("qa-jr","release",vec![SkillMatch::equal("qa",1.0)]);// A backend developer who also does devops: every match must hold.
let ops =Requirement::new("ops","release",vec![SkillMatch::at_least("backend",1.0),SkillMatch::at_least("devops",1.0)],);// Anyone who is not a QA: a missing skill counts as 0.
let reviewer =Requirement::new("review","release",vec![SkillMatch::no_more("qa",0.0)]);let requirements =[backend, senior_qa, junior_qa, ops, reviewer];for slot inslots(&project,&pool,&requirements)?{let allowed:Vec<String>= slot.allowed.iter().map(|id|id.to_string()).collect();println!("{}: value {}, allowed {allowed:?}", slot.id, slot.value);}Ok(())}
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 at_least 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. suggest_assignments never puts one resource on a task twice, so Ann can't end up in both be#1 and be#2.
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 earlier suggest_assignments run find their way back. It binds even when the resource no longer matches. The slot is then broken.
Explicit list.assignments: Some(list) on a requirement replaces automatic binding for it. Listed entries take the remaining slots in order, and leftover slots stay empty. Some(vec![]) 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 engine runs a maximum bipartite matching in input order. It fills as many slots as possible and doesn't just give 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:
useplen_assign::{slots, Pool, Requirement, Skill, SkillMatch, Worker};useplen_core::{
AssignmentData, DateTime, Project, ProjectData, ResourceData, Slot, SolveOptions, TaskData,};fnworker(resource:&str, skills:&[&str])-> Worker{
Worker {
resource: resource.into(),
skills: skills.iter().map(|&id|Skill { id: id.into(), value:1.0}).collect(),}}fnbooking(id:&str, resource:&str)-> AssignmentData{
AssignmentData {
id:Some(id.into()),
task:"api".into(),
resource: resource.into(),
value:1.0,
offset:None,
duration:None,}}fnprint(label:&str, slots:&[Slot]){let bound:Vec<String>= slots
.iter().map(|s|match&s.assignment {Some(a)=>format!("{} <- {a}", s.id),None=>format!("{} empty", s.id),}).collect();println!("{label}: {bound:?}");}fnmain()->Result<(), Box<dyn std::error::Error>>{letmut api =TaskData::new("api");
api.start_date =DateTime::from_ymd_hm(2026,8,10,8,0);
api.duration =Some(2.0);// Ann and Bob already work on `api`, under ids the skill layer didn't create.
let data = ProjectData {
tasks:vec![api],
resources:["ann","bob"].map(|id|ResourceData { id: id.into(), capacity:None, calendar_id:None}).to_vec(),
assignments:vec![booking("a1","ann"),booking("a2","bob")],..Default::default()};letmut project =Project::default();
project.solve(Some(data),&SolveOptions::default())?;let pool = Pool {
workers:vec![worker("ann",&["backend","frontend"]),worker("bob",&["backend"])],};let backend =Requirement::new("be","api",vec![SkillMatch::at_least("backend",1.0)]);let frontend =Requirement::new("fe","api",vec![SkillMatch::at_least("frontend",1.0)]);// Automatic: a maximum matching, so Bob takes backend and Ann frontend.
print("matching",&slots(&project,&pool,&[backend.clone(), frontend.clone()])?);// Explicit list: `be` takes a1, whatever the matching would do.
letmut pinned = backend.clone();
pinned.count =2;
pinned.assignments =Some(vec!["a1".into()]);print("explicit",&slots(&project,&pool,&[pinned, frontend.clone()])?);// Empty list: every slot of `be` starts empty.
letmut fresh = backend;
fresh.assignments =Some(vec![]);print("empty list",&slots(&project,&pool,&[fresh, frontend])?);Ok(())}
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.
An assignment that ends up in no slot stays on the task. It still blocks its resource for the task's other slots, and suggest_assignments never repairs or moves it.
slots checks explicit lists up front:
At most count entries, 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.
Pass the slots straight to Project::suggest_assignments, 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 Broken target repairs it:
useplen_assign::{slots, Pool, Requirement, Skill, SkillMatch, Worker};useplen_core::{
AssignTarget, AssignmentData, DateTime, Project, ProjectData, ResourceData, SolveOptions,
SuggestOptions, TaskData,};fnworker(resource:&str, skills:&[&str])-> Worker{
Worker {
resource: resource.into(),
skills: skills.iter().map(|&id|Skill { id: id.into(), value:1.0}).collect(),}}fnmain()->Result<(), Box<dyn std::error::Error>>{// `api` and `ui` both run Monday-Tuesday. Bob already works on `api`.
letmut tasks =Vec::new();for id in["api","ui"]{letmut task =TaskData::new(id);
task.start_date =DateTime::from_ymd_hm(2026,8,10,8,0);
task.duration =Some(2.0);
tasks.push(task);}let data = ProjectData {
tasks,
resources:["ann","bob"].map(|id|ResourceData { id: id.into(), capacity:None, calendar_id:None}).to_vec(),
assignments:vec![AssignmentData {
id:Some("a2".into()),
task:"api".into(),
resource:"bob".into(),
value:1.0,
offset:None,
duration:None,}],..Default::default()};letmut project =Project::default();
project.solve(Some(data),&SolveOptions::default())?;let pool = Pool {
workers:vec![worker("bob",&["backend"]),worker("ann",&["backend"])],};let requirements =[Requirement::new("be-api","api",vec![SkillMatch::at_least("backend",1.0)]),Requirement::new("be-ui","ui",vec![SkillMatch::at_least("backend",1.0)]),];// Run 1: fill the empty slot.
let slot_list =slots(&project,&pool,&requirements)?;let proposal = project.suggest_assignments(&slot_list,&SuggestOptions::default())?;for step in&proposal.steps {println!("pass {}: {} -> {}", step.pass, step.slot, step.to_resource);}// Commit the proposal and reschedule.
let report = project.apply(&proposal.changes)?;ifletSome(rejected)= report.rejected.first(){returnErr(format!("{}: {}", rejected.path, rejected.message).into());}
project.solve(None,&SolveOptions::default())?;// Run 2: the same requirements bind to what run 1 committed.
let slot_list =slots(&project,&pool,&requirements)?;for slot in&slot_list {let held = slot.assignment.as_ref().map(|id|id.to_string());println!("{} <- {held:?}", slot.id);}let options = SuggestOptions {
targets:vec![AssignTarget::Empty,AssignTarget::Broken,AssignTarget::Valid],..Default::default()};let proposal = project.suggest_assignments(&slot_list,&options)?;println!("run 2: {} steps", proposal.steps.len());// Run 3: Ann moved to frontend, so her slot is now broken.
let pool = Pool {
workers:vec![worker("bob",&["backend"]),worker("ann",&["frontend"])],};let slot_list =slots(&project,&pool,&requirements)?;let check = SuggestOptions { targets:vec![],..Default::default()};let invalid:Vec<String>= project
.suggest_assignments(&slot_list,&check)?.invalid
.iter().map(|id|id.to_string()).collect();println!("invalid: {invalid:?}");let proposal = project.suggest_assignments(&slot_list,&options)?;for step in&proposal.steps {let from = step.from_resource.as_ref().map(|id|id.to_string());println!("pass {}: {}{from:?} -> {}", step.pass, step.slot, step.to_resource);}Ok(())}
Output:
pass 0: be-ui#1 -> ann
be-api#1 <- Some("a2")
be-ui#1 <- Some("be-ui#1")
run 2: 0 steps
invalid: ["be-ui#1"]
pass 0: be-ui#1 Some("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: vec![] only reports the broken slot in invalid. The full run repairs it by moving the assignment to Bob.
Some things to keep in mind:
slots needs a solved, clean project. After apply, call solve(None, ..) before you build slots again.
suggest_assignments checks 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 from slots pass these checks, unless the project changed in between.
Targets, fixed assignments, new_resource_penalty, 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.
slots returns Result<Vec<Slot>, MatchError>. MatchError has two variants:
MatchError::Project(ProjectError::NoBaseline): the project is unsolved, or it has unsolved edits after apply. slots takes no baseline token, so there's no stale-baseline case.
MatchError::Invalid { path, message }: bad pool or requirement input. Display prints "{path}: {message}".
useplen_assign::{slots, MatchError, Pool, Requirement, Skill, SkillMatch, Worker};useplen_core::{DateTime, Project, ProjectData, ResourceData, SolveOptions, TaskData};fnmain()->Result<(), Box<dyn std::error::Error>>{let pool = Pool {
workers:vec![Worker {
resource:"ann".into(),
skills:vec![Skill { id:"backend".into(), value:1.0}],}],};let requirement =Requirement::new("be","api",vec![SkillMatch::at_least("backend",1.0)]);// Not solved yet: no accepted state to read.
letmut project =Project::default();matchslots(&project,&pool,&[requirement.clone()]){Err(MatchError::Project(error))=>println!("project error: {error}"),
other =>println!("unexpected: {other:?}"),}letmut api =TaskData::new("api");
api.start_date =DateTime::from_ymd_hm(2026,8,10,8,0);
api.duration =Some(2.0);let data = ProjectData {
tasks:vec![api],
resources:vec![ResourceData { id:"ann".into(), capacity:None, calendar_id:None}],..Default::default()};
project.solve(Some(data),&SolveOptions::default())?;// `count: 0` is invalid input with a path.
letmut empty = requirement;
empty.count =0;matchslots(&project,&pool,&[empty]){Err(MatchError::Invalid { path, message })=>println!("invalid: {path}: {message}"),
other =>println!("unexpected: {other:?}"),}Ok(())}
Output:
project error: 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 returns the first problem it finds and builds nothing. slots never changes the project, so you can fix the input and call again.
MatchError converts into plen_core::Error: Invalid becomes Error::Input (a validation InputError with the same path), and Project stays Error::Project. One error type then covers both calls:
useplen_assign::{slots, Pool, Requirement};useplen_core::{Error, Project, SuggestOptions, SuggestResult};// One error type for both crates: `?` turns a MatchError into a core Error.
fnstaff(project:&Project,
pool:&Pool,
requirements:&[Requirement],
)->Result<SuggestResult, Error>{let slot_list =slots(project, pool, requirements)?;
project.suggest_assignments(&slot_list,&SuggestOptions::default())}fnmain(){let project =Project::default();matchstaff(&project,&Pool::default(),&[]){Err(Error::Project(error))=>println!("project error: {error}"),Err(Error::Input(error))=>println!("input error at {:?}: {}", error.path, error.message),Ok(result)=>println!("{} steps", result.steps.len()),}}