///|
pub(all) enum RiskLevel {
LowRisk
ModerateRisk
HighRisk
CriticalRisk
} derive(Eq, Compare, Show, ToJson)
///|
pub struct ShiftCapacity {
shift_id : String
required_workers : Int
available_workers : Int
eligible_workers : Int
capacity_margin : Int
risk : RiskLevel
} derive(Eq, Show, ToJson)
///|
pub struct SkillCapacity {
skill_id : String
required_slots : Int
qualified_workers : Int
qualification_instances : Int
margin : Int
risk : RiskLevel
} derive(Eq, Show, ToJson)
///|
pub struct CapacityReport {
shifts : Array[ShiftCapacity]
skills : Array[SkillCapacity]
critical_shifts : Int
critical_skills : Int
total_required_slots : Int
total_eligible_instances : Int
resilience_score : Int
} derive(Eq, Show, ToJson)
///|
fn capacity_risk(required : Int, available : Int) -> RiskLevel {
if available < required {
CriticalRisk
} else if available == required {
HighRisk
} else if available <= required * 2 {
ModerateRisk
} else {
LowRisk
}
}
///|
fn worker_satisfies_all_requirements(worker : Worker, shift : Shift) -> Bool {
for requirement in shift.requirements {
if !worker.has_skill(requirement.skill_id, requirement.minimum_level) {
return false
}
}
true
}
///|
pub fn shift_capacity(
problem : PlanningProblem,
shift_id : String,
) -> ShiftCapacity? {
match problem.shift(shift_id) {
None => None
Some(shift) => {
let mut available = 0
let mut eligible = 0
for worker in problem.workers {
if worker.is_available(shift.range) {
available = available + 1
if worker_satisfies_all_requirements(worker, shift) {
eligible = eligible + 1
}
}
}
Some({
shift_id,
required_workers: shift.required_workers,
available_workers: available,
eligible_workers: eligible,
capacity_margin: available - shift.required_workers,
risk: capacity_risk(shift.required_workers, available),
})
}
}
}
///|
fn skill_required_slots(problem : PlanningProblem, skill_id : String) -> Int {
let mut total = 0
for shift in problem.shifts {
for requirement in shift.requirements {
if requirement.skill_id == skill_id {
total = total + requirement.count
}
}
}
total
}
///|
fn qualified_worker_count(problem : PlanningProblem, skill_id : String) -> Int {
let mut total = 0
for worker in problem.workers {
match worker.skill_level(skill_id) {
Some(_) => total = total + 1
None => ()
}
}
total
}
///|
fn qualification_instances(problem : PlanningProblem, skill_id : String) -> Int {
let mut total = 0
for shift in problem.shifts {
for requirement in shift.requirements {
if requirement.skill_id == skill_id {
for worker in problem.workers {
if worker.is_available(shift.range) &&
worker.has_skill(skill_id, requirement.minimum_level) {
total = total + 1
}
}
}
}
}
total
}
///|
pub fn skill_capacity(
problem : PlanningProblem,
skill_id : String,
) -> SkillCapacity {
let required = skill_required_slots(problem, skill_id)
let qualified = qualified_worker_count(problem, skill_id)
let instances = qualification_instances(problem, skill_id)
{
skill_id,
required_slots: required,
qualified_workers: qualified,
qualification_instances: instances,
margin: instances - required,
risk: capacity_risk(required, instances),
}
}
///|
fn resilience_score(
total_required : Int,
total_eligible : Int,
critical_shifts : Int,
critical_skills : Int,
) -> Int {
if total_required == 0 {
return 1000
}
let base = (total_eligible * 1000 / total_required).clamp(min=0, max=1000)
let penalty = critical_shifts * 80 + critical_skills * 120
(base - penalty).clamp(min=0, max=1000)
}
///|
pub fn analyze_capacity(problem : PlanningProblem) -> CapacityReport {
let shifts : Array[ShiftCapacity] = []
let skills : Array[SkillCapacity] = []
let mut critical_shifts = 0
let mut critical_skills = 0
let mut total_required = 0
let mut total_eligible = 0
for shift in problem.shifts {
match shift_capacity(problem, shift.id) {
Some(capacity) => {
shifts.push(capacity)
total_required = total_required + capacity.required_workers
total_eligible = total_eligible + capacity.eligible_workers
if capacity.risk == CriticalRisk {
critical_shifts = critical_shifts + 1
}
}
None => ()
}
}
for skill in problem.skills {
let capacity = skill_capacity(problem, skill.id)
skills.push(capacity)
if capacity.risk == CriticalRisk {
critical_skills = critical_skills + 1
}
}
{
shifts,
skills,
critical_shifts,
critical_skills,
total_required_slots: total_required,
total_eligible_instances: total_eligible,
resilience_score: resilience_score(
total_required, total_eligible, critical_shifts, critical_skills,
),
}
}
///|
pub struct AbsenceImpact {
worker_id : String
baseline_feasible : Bool
absence_feasible : Bool
baseline_score : Int
absence_score : Int
score_increase : Int
baseline_coverage : Int
absence_coverage : Int
coverage_loss : Int
newly_critical_shifts : Int
severity : RiskLevel
} derive(Eq, Show, ToJson)
///|
fn without_worker(
problem : PlanningProblem,
worker_id : String,
) -> PlanningProblem {
let workers : Array[Worker] = []
for worker in problem.workers {
if worker.id != worker_id {
workers.push(worker)
}
}
let locked : Array[Assignment] = []
for assignment in problem.locked_assignments {
if assignment.worker_id != worker_id {
locked.push(assignment)
}
}
{ ..problem, workers, locked_assignments: locked }
}
///|
fn critical_shift_count(report : CapacityReport) -> Int {
let mut count = 0
for shift in report.shifts {
if shift.risk == CriticalRisk || shift.risk == HighRisk {
count = count + 1
}
}
count
}
///|
fn impact_risk(
absence_feasible : Bool,
coverage_loss : Int,
score_increase : Int,
new_critical : Int,
) -> RiskLevel {
if !absence_feasible || coverage_loss >= 200 {
CriticalRisk
} else if coverage_loss > 0 || new_critical >= 3 {
HighRisk
} else if score_increase > 1000 || new_critical > 0 {
ModerateRisk
} else {
LowRisk
}
}
///|
pub fn analyze_absence(
problem : PlanningProblem,
worker_id : String,
config : SolverConfig,
) -> AbsenceImpact? {
if problem.worker(worker_id) is None {
return None
}
let baseline = solve_with_config(problem, config)
let baseline_capacity = analyze_capacity(problem)
let reduced_problem = without_worker(problem, worker_id)
let absence = solve_with_config(reduced_problem, config)
let absence_capacity = analyze_capacity(reduced_problem)
let baseline_coverage = coverage_ratio_per_mille(problem, baseline.schedule)
let absence_coverage = coverage_ratio_per_mille(
reduced_problem,
absence.schedule,
)
let coverage_loss = baseline_coverage - absence_coverage
let score_increase = absence.score.total - baseline.score.total
let new_critical = (critical_shift_count(absence_capacity) -
critical_shift_count(baseline_capacity)).max(0)
Some({
worker_id,
baseline_feasible: baseline.feasible,
absence_feasible: absence.feasible,
baseline_score: baseline.score.total,
absence_score: absence.score.total,
score_increase,
baseline_coverage,
absence_coverage,
coverage_loss,
newly_critical_shifts: new_critical,
severity: impact_risk(
absence.feasible,
coverage_loss,
score_increase,
new_critical,
),
})
}
///|
pub struct SensitivityReport {
impacts : Array[AbsenceImpact]
critical_workers : Array[String]
high_risk_workers : Array[String]
most_critical_worker : String?
worst_coverage_loss : Int
worst_score_increase : Int
} derive(Eq, Show, ToJson)
///|
fn impact_before(left : AbsenceImpact, right : AbsenceImpact) -> Bool {
if left.severity != right.severity {
left.severity > right.severity
} else if left.coverage_loss != right.coverage_loss {
left.coverage_loss > right.coverage_loss
} else if left.score_increase != right.score_increase {
left.score_increase > right.score_increase
} else {
left.worker_id < right.worker_id
}
}
///|
fn sort_impacts(values : Array[AbsenceImpact]) -> Array[AbsenceImpact] {
let result = values.copy()
for i = 1; i < result.length(); i = i + 1 {
let current = result[i]
let mut j = i
while j > 0 && impact_before(current, result[j - 1]) {
result[j] = result[j - 1]
j = j - 1
}
result[j] = current
}
result
}
///|
pub fn analyze_sensitivity(
problem : PlanningProblem,
config : SolverConfig,
) -> SensitivityReport {
let impacts : Array[AbsenceImpact] = []
for worker in problem.workers {
match analyze_absence(problem, worker.id, config) {
Some(impact) => impacts.push(impact)
None => ()
}
}
let impacts = sort_impacts(impacts)
let critical_workers : Array[String] = []
let high_risk_workers : Array[String] = []
let mut worst_coverage_loss = 0
let mut worst_score_increase = 0
for impact in impacts {
if impact.severity == CriticalRisk {
critical_workers.push(impact.worker_id)
}
if impact.severity == CriticalRisk || impact.severity == HighRisk {
high_risk_workers.push(impact.worker_id)
}
if impact.coverage_loss > worst_coverage_loss {
worst_coverage_loss = impact.coverage_loss
}
if impact.score_increase > worst_score_increase {
worst_score_increase = impact.score_increase
}
}
{
impacts,
critical_workers,
high_risk_workers,
most_critical_worker: if impacts.length() == 0 {
None
} else {
Some(impacts[0].worker_id)
},
worst_coverage_loss,
worst_score_increase,
}
}
///|
pub struct CapacityRecommendation {
code : String
title : String
detail : String
priority : Int
worker_id : String?
shift_id : String?
skill_id : String?
} derive(Eq, Show, ToJson)
///|
fn recommendation(
code : String,
title : String,
detail : String,
priority : Int,
) -> CapacityRecommendation {
{
code,
title,
detail,
priority,
worker_id: None,
shift_id: None,
skill_id: None,
}
}
///|
fn CapacityRecommendation::for_shift(
self : CapacityRecommendation,
shift_id : String,
) -> CapacityRecommendation {
{ ..self, shift_id: Some(shift_id) }
}
///|
fn CapacityRecommendation::for_skill(
self : CapacityRecommendation,
skill_id : String,
) -> CapacityRecommendation {
{ ..self, skill_id: Some(skill_id) }
}
///|
fn CapacityRecommendation::for_worker(
self : CapacityRecommendation,
worker_id : String,
) -> CapacityRecommendation {
{ ..self, worker_id: Some(worker_id) }
}
///|
fn recommendation_before(
left : CapacityRecommendation,
right : CapacityRecommendation,
) -> Bool {
if left.priority != right.priority {
left.priority > right.priority
} else {
left.code < right.code
}
}
///|
fn sort_recommendations(
values : Array[CapacityRecommendation],
) -> Array[CapacityRecommendation] {
let result = values.copy()
for i = 1; i < result.length(); i = i + 1 {
let current = result[i]
let mut j = i
while j > 0 && recommendation_before(current, result[j - 1]) {
result[j] = result[j - 1]
j = j - 1
}
result[j] = current
}
result
}
///|
pub fn capacity_recommendations(
problem : PlanningProblem,
capacity : CapacityReport,
sensitivity : SensitivityReport,
) -> Array[CapacityRecommendation] {
let recommendations : Array[CapacityRecommendation] = []
for shift in capacity.shifts {
if shift.risk == CriticalRisk {
recommendations.push(
recommendation(
"increase_shift_availability",
"Increase availability for a critical shift",
"Only " +
shift.available_workers.to_string() +
" worker(s) are available for " +
shift.required_workers.to_string() +
" required slot(s).",
1000,
).for_shift(shift.shift_id),
)
} else if shift.risk == HighRisk {
recommendations.push(
recommendation(
"add_shift_backup", "Add a backup worker", "The shift has no capacity margin if one worker becomes unavailable.",
700,
).for_shift(shift.shift_id),
)
}
}
for skill in capacity.skills {
if skill.risk == CriticalRisk || skill.risk == HighRisk {
recommendations.push(
recommendation(
"cross_train_skill",
"Cross-train an additional worker",
"Skill " +
skill.skill_id +
" has a capacity margin of " +
skill.margin.to_string() +
".",
if skill.risk == CriticalRisk {
950
} else {
650
},
).for_skill(skill.skill_id),
)
}
}
for worker_id in sensitivity.critical_workers {
let worker_name = match problem.worker(worker_id) {
Some(worker) => worker.name
None => worker_id
}
recommendations.push(
recommendation(
"reduce_single_point_of_failure",
"Reduce dependency on " + worker_name,
"The schedule becomes critically degraded when this worker is absent.",
900,
).for_worker(worker_id),
)
}
if capacity.resilience_score < 500 {
recommendations.push(
recommendation(
"review_staffing_plan", "Review the overall staffing plan", "The resilience score is below 500/1000; consider more workers, broader availability, or lower demand.",
850,
),
)
}
sort_recommendations(recommendations)
}
///|
pub fn render_capacity_report(
problem : PlanningProblem,
capacity : CapacityReport,
) -> String {
let output = StringBuilder::new()
output.write_string("RosterWeave capacity report\n")
output.write_string("=========================\n")
output.write_string("Problem: " + problem.name + "\n")
output.write_string(
"Resilience: " + capacity.resilience_score.to_string() + "/1000\n",
)
output.write_string(
"Critical shifts: " + capacity.critical_shifts.to_string() + "\n",
)
output.write_string(
"Critical skills: " + capacity.critical_skills.to_string() + "\n",
)
output.write_string("\nShift capacity\n")
output.write_string("--------------\n")
for shift in capacity.shifts {
let label = match problem.shift(shift.shift_id) {
Some(item) => item.label
None => shift.shift_id
}
output.write_string(
label +
": required=" +
shift.required_workers.to_string() +
", available=" +
shift.available_workers.to_string() +
", eligible=" +
shift.eligible_workers.to_string() +
", risk=" +
shift.risk.to_string() +
"\n",
)
}
output.write_string("\nSkill capacity\n")
output.write_string("--------------\n")
for skill in capacity.skills {
output.write_string(
skill.skill_id +
": required=" +
skill.required_slots.to_string() +
", instances=" +
skill.qualification_instances.to_string() +
", margin=" +
skill.margin.to_string() +
", risk=" +
skill.risk.to_string() +
"\n",
)
}
output.to_string()
}