///|
pub(all) enum ExplanationKind {
AssignmentAccepted
AssignmentRejected
CoverageFailure
SkillCoverageFailure
WorkloadObservation
PreferenceObservation
ImprovementSuggestion
} derive(Eq, Show, ToJson)
///|
pub struct Explanation {
kind : ExplanationKind
title : String
detail : String
worker_id : String?
shift_id : String?
priority : Int
} derive(Eq, Show, ToJson)
///|
pub fn Explanation::new(
kind : ExplanationKind,
title : String,
detail : String,
priority : Int,
) -> Explanation {
{ kind, title, detail, worker_id: None, shift_id: None, priority }
}
///|
pub fn Explanation::for_worker(
self : Explanation,
worker_id : String,
) -> Explanation {
{ ..self, worker_id: Some(worker_id) }
}
///|
pub fn Explanation::for_shift(
self : Explanation,
shift_id : String,
) -> Explanation {
{ ..self, shift_id: Some(shift_id) }
}
///|
fn violation_detail(violation : ConstraintViolation) -> String {
match violation.kind {
UnknownWorker =>
"The worker identifier does not exist in the planning problem."
UnknownShift =>
"The shift identifier does not exist in the planning problem."
DuplicateAssignment => "The same worker and shift pair is already present."
WorkerUnavailable =>
"The shift is outside the worker's declared availability."
ExplicitlyUnavailable =>
"The worker explicitly marked this shift as unavailable."
MissingRequiredPreference => "A required preference was not satisfied."
OverlappingShift => "Another assigned shift overlaps this shift."
InsufficientRest =>
"The assignment would leave less than the required rest time."
WeeklyMinuteLimit =>
"The assignment would exceed the worker's maximum minutes."
ConsecutiveShiftLimit =>
"The assignment would exceed the consecutive-day limit."
MissingSkill => "The worker does not hold the required skill level."
Understaffed =>
"The number of assigned workers is below the hard requirement."
SkillShortage => "Too few assigned workers hold the required skill level."
}
}
///|
pub fn explain_candidate(
problem : PlanningProblem,
schedule : Schedule,
worker_id : String,
shift_id : String,
) -> Array[Explanation] {
let explanations : Array[Explanation] = []
let report = check_candidate(problem, schedule, worker_id, shift_id)
if report.feasible() {
let delta = assignment_delta(problem, schedule, worker_id, shift_id)
let direction = if delta < 0 {
"improves the objective by " + (-delta).to_string()
} else if delta == 0 {
"does not change the objective"
} else {
"increases the objective by " + delta.to_string()
}
explanations.push(
Explanation::new(
AssignmentAccepted,
"Assignment is feasible",
"All hard constraints pass; the assignment " + direction + ".",
1,
)
.for_worker(worker_id)
.for_shift(shift_id),
)
} else {
for violation in report.violations {
let mut explanation = Explanation::new(
AssignmentRejected,
violation.message,
violation_detail(violation),
100,
)
match violation.worker_id {
Some(id) => explanation = explanation.for_worker(id)
None => ()
}
match violation.shift_id {
Some(id) => explanation = explanation.for_shift(id)
None => ()
}
explanations.push(explanation)
}
}
explanations
}
///|
fn coverage_explanations(
problem : PlanningProblem,
schedule : Schedule,
) -> Array[Explanation] {
let explanations : Array[Explanation] = []
for shift in problem.shifts {
let assigned = assignment_count_for_shift(schedule, shift.id)
if assigned < shift.required_workers {
let missing = shift.required_workers - assigned
explanations.push(
Explanation::new(
CoverageFailure,
shift.label + " needs " + missing.to_string() + " more worker(s)",
"No remaining worker could be assigned without violating a hard constraint.",
1000 + missing,
).for_shift(shift.id),
)
}
for requirement in shift.requirements {
let mut qualified = 0
for worker_id in schedule.workers_for(shift.id) {
match problem.worker(worker_id) {
Some(worker) =>
if worker.has_skill(requirement.skill_id, requirement.minimum_level) {
qualified = qualified + 1
}
None => ()
}
}
if qualified < requirement.count {
explanations.push(
Explanation::new(
SkillCoverageFailure,
shift.label + " lacks skill " + requirement.skill_id,
"Add a qualified worker or reduce the skill requirement after review.",
900 + requirement.count - qualified,
).for_shift(shift.id),
)
}
}
}
explanations
}
///|
fn workload_explanations(
problem : PlanningProblem,
schedule : Schedule,
) -> Array[Explanation] {
let explanations : Array[Explanation] = []
for worker in problem.workers {
let minutes = worker_assigned_minutes(problem, schedule, worker.id)
if minutes > worker.target_minutes {
explanations.push(
Explanation::new(
WorkloadObservation,
worker.name + " is above target workload",
"Assigned " +
minutes.to_string() +
" minutes against a target of " +
worker.target_minutes.to_string() +
".",
100 + minutes - worker.target_minutes,
).for_worker(worker.id),
)
} else if minutes < worker.min_minutes {
explanations.push(
Explanation::new(
WorkloadObservation,
worker.name + " is below minimum workload",
"Assigned " +
minutes.to_string() +
" minutes against a minimum of " +
worker.min_minutes.to_string() +
".",
100 + worker.min_minutes - minutes,
).for_worker(worker.id),
)
}
}
explanations
}
///|
fn preference_explanations(
problem : PlanningProblem,
schedule : Schedule,
) -> Array[Explanation] {
let explanations : Array[Explanation] = []
for worker in problem.workers {
for preference in worker.preferences {
let assigned = schedule.contains(worker.id, preference.shift_id)
match preference.kind {
Prefer =>
if !assigned {
explanations.push(
Explanation::new(
PreferenceObservation,
worker.name + " preferred an unassigned shift",
"This is a soft preference and may be traded for coverage or fairness.",
preference.weight,
)
.for_worker(worker.id)
.for_shift(preference.shift_id),
)
}
Avoid =>
if assigned {
explanations.push(
Explanation::new(
PreferenceObservation,
worker.name + " was assigned an avoided shift",
"The solver accepted this soft cost to satisfy stronger constraints.",
preference.weight,
)
.for_worker(worker.id)
.for_shift(preference.shift_id),
)
}
Require =>
if !assigned {
explanations.push(
Explanation::new(
PreferenceObservation,
worker.name + " requested a required shift",
"The request could not be satisfied under the current hard constraints.",
preference.weight * 10,
)
.for_worker(worker.id)
.for_shift(preference.shift_id),
)
}
Unavailable => ()
}
}
}
explanations
}
///|
fn explanation_before(left : Explanation, right : Explanation) -> Bool {
if left.priority != right.priority {
left.priority > right.priority
} else {
left.title < right.title
}
}
///|
fn sort_explanations(values : Array[Explanation]) -> Array[Explanation] {
let result = values.copy()
for i = 1; i < result.length(); i = i + 1 {
let current = result[i]
let mut j = i
while j > 0 && explanation_before(current, result[j - 1]) {
result[j] = result[j - 1]
j = j - 1
}
result[j] = current
}
result
}
///|
pub fn explain_solution(
problem : PlanningProblem,
solution : Solution,
) -> Array[Explanation] {
let explanations : Array[Explanation] = []
explanations.append(coverage_explanations(problem, solution.schedule))
explanations.append(workload_explanations(problem, solution.schedule))
explanations.append(preference_explanations(problem, solution.schedule))
if workload_range(problem, solution.schedule) > 480 {
explanations.push(
Explanation::new(
ImprovementSuggestion,
"Workload spread remains high",
"Review availability or increase local-search iterations to reduce the spread.",
50,
),
)
}
sort_explanations(explanations)
}