//! Assignment-coverage rule for field-service visits. //! //! List variables should contain each service visit exactly once. This rule //! catches three beginner-relevant failures: a missing visit, a duplicated visit, //! and a route list entry that points outside the visit collection. use crate::domain::FieldServicePlan; use solverforge::prelude::*; use solverforge::IncrementalConstraint; use solverforge_core::ConstraintRef; /// HARD: every service visit must appear exactly once in a technician route. pub fn constraint() -> impl IncrementalConstraint { AssignedVisitsConstraint::new() } struct AssignedVisitsConstraint { constraint_ref: ConstraintRef, } impl AssignedVisitsConstraint { fn new() -> Self { Self { constraint_ref: ConstraintRef::new("field_service_routing", "Assigned Visits"), } } } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] struct AssignmentIssues { unassigned: i64, duplicate_assignments: i64, invalid_assignments: i64, } impl AssignmentIssues { fn total(self) -> i64 { self.unassigned + self.duplicate_assignments + self.invalid_assignments } } impl IncrementalConstraint for AssignedVisitsConstraint { fn evaluate(&self, solution: &FieldServicePlan) -> HardSoftScore { HardSoftScore::of(-assignment_issues(solution).total(), 0) } fn match_count(&self, solution: &FieldServicePlan) -> usize { assignment_issues(solution).total() as usize } fn initialize(&mut self, solution: &FieldServicePlan) -> HardSoftScore { self.evaluate(solution) } fn on_insert( &mut self, solution: &FieldServicePlan, _entity_index: usize, _descriptor_index: usize, ) -> HardSoftScore { self.evaluate(solution) } fn on_retract( &mut self, solution: &FieldServicePlan, _entity_index: usize, _descriptor_index: usize, ) -> HardSoftScore { -self.evaluate(solution) } fn reset(&mut self) {} fn name(&self) -> &str { &self.constraint_ref.name } fn is_hard(&self) -> bool { true } fn weight(&self) -> HardSoftScore { HardSoftScore::of(1, 0) } fn constraint_ref(&self) -> &ConstraintRef { &self.constraint_ref } } fn assignment_issues(plan: &FieldServicePlan) -> AssignmentIssues { // `counts[i]` records how often service visit `i` appears across every // technician route. A valid list-variable solution leaves every count at 1. let mut counts = vec![0usize; plan.service_visits.len()]; let mut issues = AssignmentIssues::default(); for route in &plan.technician_routes { for &visit_idx in &route.visits { if let Some(count) = counts.get_mut(visit_idx) { *count += 1; } else { issues.invalid_assignments += 1; } } } for count in counts { match count { 0 => issues.unassigned += 1, 1 => {} extra => issues.duplicate_assignments += (extra - 1) as i64, } } issues } #[cfg(test)] mod tests { use super::*; use crate::domain::{ FieldServicePlan, ServiceVisit, ServiceVisitInit, TechnicianRoute, TechnicianRouteInit, }; use solverforge::IncrementalConstraint; #[test] fn empty_routes_are_penalized_for_unassigned_visits() { let score = constraint().evaluate(&sample_plan(vec![vec![]])); assert_eq!(score, HardSoftScore::of(-2, 0)); } #[test] fn every_visit_once_is_feasible() { let score = constraint().evaluate(&sample_plan(vec![vec![0, 1]])); assert_eq!(score, HardSoftScore::ZERO); } #[test] fn duplicate_or_invalid_visit_indexes_are_hard_issues() { let score = constraint().evaluate(&sample_plan(vec![vec![0, 0, 99]])); assert_eq!(score, HardSoftScore::of(-3, 0)); } fn sample_plan(route_visits: Vec>) -> FieldServicePlan { let service_visits = (0..2) .map(|idx| { ServiceVisit::new(ServiceVisitInit { id: format!("visit-{idx}"), name: format!("Visit {idx}"), customer: format!("Customer {idx}"), location_idx: idx, duration_minutes: 30, earliest_minute: 480, latest_minute: 1020, required_skill_mask: 0, required_parts_mask: 0, priority: 1, territory: "center".to_string(), }) }) .collect(); let technician_routes = route_visits .into_iter() .enumerate() .map(|(idx, visits)| { let mut route = TechnicianRoute::new(TechnicianRouteInit { id: format!("route-{idx}"), technician_id: format!("tech-{idx}"), technician_name: format!("Tech {idx}"), color: "#2563eb".to_string(), start_location_idx: 0, end_location_idx: 0, shift_start_minute: 480, shift_end_minute: 1020, max_route_minutes: 480, skill_mask: 0, inventory_mask: 0, territory: "center".to_string(), }); route.visits = visits; route }) .collect(); FieldServicePlan::new(Vec::new(), service_visits, Vec::new(), technician_routes) } }