///|
/// Public stateless scheduling engine.
pub(all) struct RotaEngine {
version : String
} derive(Debug, Eq)
///|
/// Construct the stable NeighborRota scheduling engine.
pub fn new_rota_engine() -> RotaEngine {
{ version: "0.1.0" }
}
///|
/// Solve through the engine-style API used in the project application.
pub fn RotaEngine::solve(
self : RotaEngine,
request : ScheduleRequest,
) -> Schedule {
let _version = self.version
solve(request)
}
///|
/// Construct an empty blocking result for invalid inputs.
fn invalid_schedule(diagnostics : Array[Diagnostic]) -> Schedule {
{
schema: SCHEMA_VERSION,
assignments: [],
unassigned: [],
diagnostics,
score: {
total: 0,
assigned_priority: 0,
travel_penalty: 0,
fairness_penalty: 0,
continuity_bonus: 0,
preference_bonus: 0,
disruption_penalty: 0,
},
feasible: false,
}
}
///|
/// Convert a rejected candidate search into an unassigned explanation.
fn explain_unassigned(
visit : Visit,
search : CandidateSearch,
) -> UnassignedVisit {
let codes = unique_constraint_codes(search.rejected)
let details = constraint_details(search.rejected)
if codes.length() == 0 {
codes.push("no_candidate")
details.push("no worker and start-time combination was generated")
}
{ visit_id: visit.id, reason_codes: sort_strings(codes), details }
}
///|
/// Build a deterministic schedule with urgency-first constructive search.
pub fn solve_constructive(request : ScheduleRequest) -> Schedule {
let diagnostics = validate_request(request)
if diagnostics_have_errors(diagnostics) {
return invalid_schedule(diagnostics)
}
let assignments : Array[Assignment] = []
let unassigned : Array[UnassignedVisit] = []
for visit in sort_visits_for_scheduling(request.visits) {
let search = search_candidates(
visit,
request.workers,
assignments,
request.visits,
request.policy,
)
if search.candidates.length() > 0 {
assignments.push(search.candidates[0].to_assignment())
} else {
unassigned.push(explain_unassigned(visit, search))
}
}
let ordered = sort_schedule_assignments(assignments)
let score = score_schedule(
ordered,
request.workers,
request.visits,
request.policy,
request.baseline,
)
let mut feasible = true
for missing in unassigned {
match find_visit(request.visits, missing.visit_id) {
Some(visit) => if visit.required { feasible = false }
None => feasible = false
}
}
{
schema: SCHEMA_VERSION,
assignments: ordered,
unassigned,
diagnostics,
score,
feasible,
}
}
///|
/// Remove one assignment by visit id.
pub fn without_assignment(
assignments : Array[Assignment],
visit_id : String,
) -> Array[Assignment] {
let result : Array[Assignment] = []
for assignment in assignments {
if assignment.visit_id != visit_id {
result.push(assignment)
}
}
result
}
///|
/// Replace one assignment by visit id while preserving all others.
pub fn replace_assignment(
assignments : Array[Assignment],
replacement : Assignment,
) -> Array[Assignment] {
let result = without_assignment(assignments, replacement.visit_id)
result.push(replacement)
sort_schedule_assignments(result)
}
///|
/// Attempt all alternative candidates for each scheduled visit once.
fn improve_round(schedule : Schedule, request : ScheduleRequest) -> Schedule {
let mut best = schedule
for original in schedule.assignments {
match find_visit(request.visits, original.visit_id) {
None => ()
Some(visit) => {
let remaining = without_assignment(best.assignments, visit.id)
let search = search_candidates(
visit,
request.workers,
remaining,
request.visits,
request.policy,
)
let limit = min_int(search.candidates.length(), 12)
for index = 0; index < limit; index = index + 1 {
let proposal = replace_assignment(
remaining,
search.candidates[index].to_assignment(),
)
let score = score_schedule(
proposal,
request.workers,
request.visits,
request.policy,
request.baseline,
)
if score.total > best.score.total {
best = { ..best, assignments: proposal, score }
}
}
}
}
}
best
}
///|
/// Improve a constructive schedule without sacrificing determinism.
pub fn improve_schedule(
schedule : Schedule,
request : ScheduleRequest,
) -> Schedule {
let mut current = schedule
let mut round = 0
while round < request.policy.local_search_rounds {
let improved = improve_round(current, request)
if improved.score.total <= current.score.total {
break
}
current = improved
round = round + 1
}
current
}
///|
/// Main scheduling function: validate, construct and locally improve.
pub fn solve(request : ScheduleRequest) -> Schedule {
let initial = solve_constructive(request)
if diagnostics_have_errors(initial.diagnostics) {
return initial
}
improve_schedule(initial, request)
}
///|
/// Verify every assignment independently from the solver implementation.
pub fn audit_schedule(
schedule : Schedule,
request : ScheduleRequest,
) -> Array[Diagnostic] {
let issues : Array[Diagnostic] = []
let seen_visits : Array[String] = []
for assignment in schedule.assignments {
if string_array_contains(seen_visits, assignment.visit_id) {
issues.push(
error_diagnostic(
"audit.duplicate_visit", "visit appears in more than one assignment",
).for_entity(assignment.visit_id),
)
}
push_unique_string(seen_visits, assignment.visit_id)
match
(
find_worker(request.workers, assignment.worker_id),
find_visit(request.visits, assignment.visit_id),
) {
(None, _) =>
issues.push(
error_diagnostic(
"audit.unknown_worker", "assignment references an unknown worker",
).for_entity(assignment.visit_id),
)
(_, None) =>
issues.push(
error_diagnostic(
"audit.unknown_visit", "assignment references an unknown visit",
).for_entity(assignment.visit_id),
)
(Some(worker), Some(visit)) => {
let others = without_assignment(
schedule.assignments,
assignment.visit_id,
)
let result = evaluate_feasibility(
worker,
visit,
assignment.start_minute,
others,
request.visits,
request.policy,
)
for failure in result.failures {
issues.push(
error_diagnostic(
"audit." + constraint_code_name(failure.code),
failure.message,
).for_entity(assignment.visit_id),
)
}
}
}
}
for visit in request.visits {
if visit.required && !string_array_contains(seen_visits, visit.id) {
let mut recorded_unassigned = false
for missing in schedule.unassigned {
if missing.visit_id == visit.id {
recorded_unassigned = true
}
}
if !recorded_unassigned {
issues.push(
error_diagnostic(
"audit.missing_required_visit", "required visit is neither assigned nor explained as unassigned",
).for_entity(visit.id),
)
}
}
}
issues
}