///|
/// A concrete change that can make a roster model schedulable.
pub(all) enum RosterRepair {
RenameWorkerId(Int)
RenameShiftId(Int)
AddQualifiedWorker(Int, String, String)
AddSlotCapacity(Int, Int)
RaiseWorkloadLimit(Int, Int)
ReduceMinimumSlotGap(Int, Int)
} derive(Eq, Debug)
///|
/// Render one proposed repair in business-facing terms.
pub fn RosterRepair::message(self : RosterRepair) -> String {
match self {
RenameWorkerId(id) => "assign a unique id to the workers using id \{id}"
RenameShiftId(id) => "assign a unique id to the shifts using id \{id}"
AddQualifiedWorker(id, name, skill) =>
if skill.length() == 0 {
"add an available worker for shift \{id} (\{name})"
} else {
"add a worker with skill \{skill} who is available for shift \{id} (\{name})"
}
AddSlotCapacity(slot, missing) =>
"make \{missing} additional eligible worker(s) available at slot \{slot}"
RaiseWorkloadLimit(current, suggested) =>
"raise the per-worker workload limit from \{current} to \{suggested}"
ReduceMinimumSlotGap(current, suggested) =>
"reduce the minimum slot gap from \{current} to \{suggested}"
}
}
///|
/// Scheduling facts, solver evidence, and actionable repairs in one report.
pub(all) struct RosterRepairPlan {
issues : Array[RosterIssue]
conflict : ConflictReport?
repairs : Array[RosterRepair]
} derive(Eq, Debug)
///|
/// Whether the roster is feasible without applying a repair.
pub fn RosterRepairPlan::is_feasible(self : RosterRepairPlan) -> Bool {
self.issues.length() == 0 && self.conflict is None
}
///|
/// Render the proposed changes as a compact diagnostic line.
pub fn RosterRepairPlan::summary(self : RosterRepairPlan) -> String {
if self.is_feasible() {
return "roster is feasible without repairs"
}
if self.repairs.length() == 0 {
return "no automatic roster repair is available"
}
let mut output = ""
for index = 0; index < self.repairs.length(); index = index + 1 {
if index > 0 {
output = output + "; "
}
output = output + self.repairs[index].message()
}
output
}
///|
fn slot_has_unstaffed_shift(
issues : Array[RosterIssue],
shifts : Array[Shift],
slot : Int,
) -> Bool {
for issue in issues {
match issue {
NoEligibleWorkerFound(id, name) =>
for shift in shifts {
if shift.id == id && shift.name == name && shift.slot == slot {
return true
}
}
_ => ()
}
}
false
}
///|
fn repair_for_issue(
issue : RosterIssue,
issues : Array[RosterIssue],
shifts : Array[Shift],
) -> RosterRepair? {
match issue {
DuplicateWorkerIdFound(id) => Some(RenameWorkerId(id))
DuplicateShiftIdFound(id) => Some(RenameShiftId(id))
NoEligibleWorkerFound(id, name) => {
let mut skill = ""
for shift in shifts {
if shift.id == id && shift.name == name {
skill = shift.required_skill
break
}
}
Some(AddQualifiedWorker(id, name, skill))
}
SlotCoverageShortfall(slot, required, assignable) =>
if slot_has_unstaffed_shift(issues, shifts, slot) {
None
} else {
Some(AddSlotCapacity(slot, required - assignable))
}
}
}
///|
fn policy_model(
workers : Array[Worker],
shifts : Array[Shift],
policy : RosterPolicy,
) -> Result[RosterModel, RosterError] {
match build_roster_model(workers, shifts) {
Err(error) => Err(error)
Ok(model) =>
match apply_roster_policy(model, workers, shifts, policy) {
Err(error) => Err(error)
Ok(_) => Ok(model)
}
}
}
///|
fn minimum_feasible_workload_limit(
workers : Array[Worker],
shifts : Array[Shift],
policy : RosterPolicy,
) -> Int? {
match policy.max_shifts_per_worker {
None => return None
Some(current) =>
for limit = current + 1; limit <= shifts.length(); limit = limit + 1 {
match
policy_model(workers, shifts, {
max_shifts_per_worker: Some(limit),
minimum_slot_gap: policy.minimum_slot_gap,
}) {
Err(_) => ()
Ok(model) =>
match model.problem.solve() {
Satisfied(_, _) => return Some(limit)
Unsatisfied(_) => ()
}
}
}
}
None
}
///|
fn maximum_feasible_rest_gap_below(
workers : Array[Worker],
shifts : Array[Shift],
policy : RosterPolicy,
) -> Int? {
let mut gap = policy.minimum_slot_gap - 1
while gap >= 0 {
match
policy_model(workers, shifts, {
max_shifts_per_worker: policy.max_shifts_per_worker,
minimum_slot_gap: gap,
}) {
Err(_) => ()
Ok(model) =>
match model.problem.solve() {
Satisfied(_, _) => return Some(gap)
Unsatisfied(_) => ()
}
}
gap = gap - 1
}
None
}
///|
/// Diagnose a roster policy and propose independently feasible relaxations.
///
/// Definite input and capacity issues are reported without search. If only the
/// policy makes the model impossible, the returned conflict retains the named
/// rules. Workload suggestions choose the smallest feasible limit; rest
/// suggestions choose the largest smaller gap that restores feasibility.
pub fn suggest_roster_policy_repairs(
workers : Array[Worker],
shifts : Array[Shift],
policy : RosterPolicy,
) -> Result[RosterRepairPlan, RosterError] {
match validate_roster_policy(policy) {
Err(error) => return Err(error)
Ok(_) => ()
}
let analysis = analyze_roster(workers, shifts)
if analysis.issues.length() > 0 {
let repairs : Array[RosterRepair] = []
for issue in analysis.issues {
match repair_for_issue(issue, analysis.issues, shifts) {
Some(repair) => repairs.push(repair)
None => ()
}
}
return Ok({ issues: analysis.issues, conflict: None, repairs, })
}
match policy_model(workers, shifts, policy) {
Err(error) => Err(error)
Ok(model) =>
match model.problem.explain() {
Consistent(_) => Ok({ issues: [], conflict: None, repairs: [], })
Inconsistent(conflict) => {
let repairs : Array[RosterRepair] = []
match policy.max_shifts_per_worker {
Some(current) =>
match minimum_feasible_workload_limit(workers, shifts, policy) {
Some(limit) => repairs.push(RaiseWorkloadLimit(current, limit))
None => ()
}
None => ()
}
if policy.minimum_slot_gap > 0 {
match maximum_feasible_rest_gap_below(workers, shifts, policy) {
Some(gap) =>
repairs.push(ReduceMinimumSlotGap(policy.minimum_slot_gap, gap))
None => ()
}
}
Ok({ issues: [], conflict: Some(conflict), repairs, })
}
}
}
}
///|
/// Diagnose a roster with a uniform workload cap and propose concrete repairs.
///
/// This compatibility helper applies no minimum rest gap. Use
/// `suggest_roster_policy_repairs` when workload and rest rules are combined.
pub fn suggest_capped_roster_repairs(
workers : Array[Worker],
shifts : Array[Shift],
max_shifts_per_worker : Int,
) -> Result[RosterRepairPlan, RosterError] {
suggest_roster_policy_repairs(workers, shifts, {
max_shifts_per_worker: Some(max_shifts_per_worker),
minimum_slot_gap: 0,
})
}