///|
/// Stable codes emitted when a worker cannot take one candidate visit.
pub(all) enum ConstraintCode {
WorkerUnavailable
WorkerForbidden
MissingSkill
OutsideAvailability
TimeConflict
InsufficientTravelBefore
InsufficientTravelAfter
BreakViolation
MaxMinutesExceeded
MaxVisitsExceeded
VisitWindowViolation
} derive(Debug, Eq)
///|
/// One failed hard constraint with a human explanation.
pub(all) struct ConstraintFailure {
code : ConstraintCode
message : String
worker_id : String
visit_id : String
} derive(Debug, Eq)
///|
/// Complete feasibility result for one candidate placement.
pub(all) struct FeasibilityResult {
feasible : Bool
failures : Array[ConstraintFailure]
travel_before_minutes : Int
travel_after_minutes : Int
} derive(Debug, Eq)
///|
/// Stable machine name for constraint codes.
pub fn constraint_code_name(code : ConstraintCode) -> String {
match code {
WorkerUnavailable => "worker_unavailable"
WorkerForbidden => "worker_forbidden"
MissingSkill => "missing_skill"
OutsideAvailability => "outside_availability"
TimeConflict => "time_conflict"
InsufficientTravelBefore => "insufficient_travel_before"
InsufficientTravelAfter => "insufficient_travel_after"
BreakViolation => "break_violation"
MaxMinutesExceeded => "max_minutes_exceeded"
MaxVisitsExceeded => "max_visits_exceeded"
VisitWindowViolation => "visit_window_violation"
}
}
///|
/// Build a contextual hard-constraint failure.
fn constraint_failure(
code : ConstraintCode,
message : String,
worker_id : String,
visit_id : String,
) -> ConstraintFailure {
{ code, message, worker_id, visit_id }
}
///|
/// Check every required skill and retain all missing requirements.
pub fn missing_skills(worker : Worker, visit : Visit) -> Array[Skill] {
let missing : Array[Skill] = []
for required in visit.required_skills {
if !worker.has_skill(required) {
missing.push(required)
}
}
missing
}
///|
/// Whether an interval overlaps any existing worker assignment.
pub fn interval_conflicts(
assignments : Array[Assignment],
worker_id : String,
interval : TimeWindow,
) -> Bool {
for assignment in assignments {
if assignment.worker_id == worker_id &&
interval.overlaps(
time_window(assignment.start_minute, assignment.end_minute),
) {
return true
}
}
false
}
///|
/// Evaluate a candidate against all hard constraints.
pub fn evaluate_feasibility(
worker : Worker,
visit : Visit,
start_minute : Int,
assignments : Array[Assignment],
visits : Array[Visit],
policy : SchedulePolicy,
) -> FeasibilityResult {
let failures : Array[ConstraintFailure] = []
let end_minute = start_minute + visit.duration_minutes
let interval = time_window(start_minute, end_minute)
if worker.unavailable {
failures.push(
constraint_failure(
WorkerUnavailable,
"worker is marked unavailable",
worker.id,
visit.id,
),
)
}
if visit.forbids(worker.id) {
failures.push(
constraint_failure(
WorkerForbidden,
"worker is explicitly forbidden for this visit",
worker.id,
visit.id,
),
)
}
for required in missing_skills(worker, visit) {
failures.push(
constraint_failure(
MissingSkill,
"missing skill \{required.name} at level \{required.level}",
worker.id,
visit.id,
),
)
}
if !visit.window.contains(interval) {
failures.push(
constraint_failure(
VisitWindowViolation,
"candidate interval falls outside the visit time window",
worker.id,
visit.id,
),
)
}
if !worker.is_available(interval) {
failures.push(
constraint_failure(
OutsideAvailability,
"candidate interval falls outside worker availability",
worker.id,
visit.id,
),
)
}
if interval_conflicts(assignments, worker.id, interval) {
failures.push(
constraint_failure(
TimeConflict,
"candidate interval overlaps an existing assignment",
worker.id,
visit.id,
),
)
}
let before = previous_assignment(assignments, worker.id, start_minute)
let before_location = previous_location(
worker, assignments, visits, start_minute,
)
let travel_before = travel_minutes(before_location, visit.location, policy)
match before {
Some(previous) => {
let available_gap = start_minute - previous.end_minute
let required_gap = travel_before + worker.min_break_minutes
if available_gap < required_gap {
failures.push(
constraint_failure(
InsufficientTravelBefore,
"previous assignment leaves \{available_gap} minutes but \{required_gap} are required",
worker.id,
visit.id,
),
)
}
}
None => {
let first_available = earliest_containing_start(worker, interval)
match first_available {
Some(available_start) =>
if start_minute - available_start < travel_before {
failures.push(
constraint_failure(
InsufficientTravelBefore,
"worker cannot reach the first visit from home after availability begins",
worker.id,
visit.id,
),
)
}
None => ()
}
}
}
let after = next_assignment(assignments, worker.id, end_minute)
let mut travel_after = 0
match after {
Some(next) =>
match assignment_location(next, visits) {
Some(next_location) => {
travel_after = travel_minutes(visit.location, next_location, policy)
let available_gap = next.start_minute - end_minute
let required_gap = travel_after + worker.min_break_minutes
if available_gap < required_gap {
failures.push(
constraint_failure(
InsufficientTravelAfter,
"next assignment leaves \{available_gap} minutes but \{required_gap} are required",
worker.id,
visit.id,
),
)
}
}
None => ()
}
None => ()
}
if assigned_minutes(assignments, worker.id) + visit.duration_minutes >
worker.max_minutes {
failures.push(
constraint_failure(
MaxMinutesExceeded,
"assignment would exceed worker maximum service minutes",
worker.id,
visit.id,
),
)
}
if assignments_for_worker(assignments, worker.id).length() + 1 >
worker.max_visits {
failures.push(
constraint_failure(
MaxVisitsExceeded,
"assignment would exceed worker maximum visit count",
worker.id,
visit.id,
),
)
}
{
feasible: failures.length() == 0,
failures,
travel_before_minutes: travel_before,
travel_after_minutes: travel_after,
}
}
///|
/// Earliest start of an availability window that contains an interval.
pub fn earliest_containing_start(
worker : Worker,
interval : TimeWindow,
) -> Int? {
let mut earliest : Int? = None
for window in worker.availability {
if window.contains(interval) {
match earliest {
None => earliest = Some(window.start_minute)
Some(value) =>
if window.start_minute < value {
earliest = Some(window.start_minute)
}
}
}
}
earliest
}
///|
/// Collect unique failure codes from several rejected candidates.
pub fn unique_constraint_codes(
results : Array[FeasibilityResult],
) -> Array[String] {
let codes : Array[String] = []
for result in results {
for failure in result.failures {
push_unique_string(codes, constraint_code_name(failure.code))
}
}
codes
}
///|
/// Collect failure descriptions while preserving evaluation order.
pub fn constraint_details(results : Array[FeasibilityResult]) -> Array[String] {
let details : Array[String] = []
for result in results {
for failure in result.failures {
let value = "\{failure.worker_id}: \{failure.message}"
push_unique_string(details, value)
}
}
details
}