| |
| |
| |
| |
|
|
| use parking_lot::RwLock; |
| use rand::Rng; |
| use solverforge::prelude::*; |
| use solverforge::TypedScoreDirector; |
| use std::collections::HashMap; |
| use std::sync::Arc; |
| use std::time::{Duration, Instant}; |
| use tokio::sync::oneshot; |
| use tracing::{debug, info}; |
|
|
| use crate::console::{self, PhaseTimer}; |
| use crate::constraints::create_fluent_constraints; |
| use crate::domain::EmployeeSchedule; |
|
|
| |
| const DEFAULT_TIME_LIMIT_SECS: u64 = 30; |
|
|
| |
| const LATE_ACCEPTANCE_SIZE: usize = 400; |
|
|
| |
| #[derive(Debug, Clone, Default)] |
| pub struct SolverConfig { |
| |
| pub time_limit: Option<Duration>, |
| |
| pub unimproved_time_limit: Option<Duration>, |
| |
| pub step_limit: Option<u64>, |
| |
| pub unimproved_step_limit: Option<u64>, |
| } |
|
|
| impl SolverConfig { |
| |
| pub fn default_config() -> Self { |
| Self { |
| time_limit: Some(Duration::from_secs(DEFAULT_TIME_LIMIT_SECS)), |
| ..Default::default() |
| } |
| } |
|
|
| |
| fn should_terminate( |
| &self, |
| elapsed: Duration, |
| steps: u64, |
| time_since_improvement: Duration, |
| steps_since_improvement: u64, |
| ) -> bool { |
| if let Some(limit) = self.time_limit { |
| if elapsed >= limit { |
| return true; |
| } |
| } |
| if let Some(limit) = self.unimproved_time_limit { |
| if time_since_improvement >= limit { |
| return true; |
| } |
| } |
| if let Some(limit) = self.step_limit { |
| if steps >= limit { |
| return true; |
| } |
| } |
| if let Some(limit) = self.unimproved_step_limit { |
| if steps_since_improvement >= limit { |
| return true; |
| } |
| } |
| false |
| } |
| } |
|
|
| |
| #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] |
| #[serde(rename_all = "SCREAMING_SNAKE_CASE")] |
| pub enum SolverStatus { |
| |
| NotSolving, |
| |
| Solving, |
| } |
|
|
| impl SolverStatus { |
| |
| |
| |
| |
| |
| |
| |
| |
| pub fn as_str(self) -> &'static str { |
| match self { |
| SolverStatus::NotSolving => "NOT_SOLVING", |
| SolverStatus::Solving => "SOLVING", |
| } |
| } |
| } |
|
|
| |
| pub struct SolveJob { |
| |
| pub id: String, |
| |
| pub status: SolverStatus, |
| |
| pub schedule: EmployeeSchedule, |
| |
| pub config: SolverConfig, |
| |
| stop_signal: Option<oneshot::Sender<()>>, |
| } |
|
|
| impl SolveJob { |
| |
| pub fn new(id: String, schedule: EmployeeSchedule) -> Self { |
| Self { |
| id, |
| status: SolverStatus::NotSolving, |
| schedule, |
| config: SolverConfig::default_config(), |
| stop_signal: None, |
| } |
| } |
|
|
| |
| pub fn with_config(id: String, schedule: EmployeeSchedule, config: SolverConfig) -> Self { |
| Self { |
| id, |
| status: SolverStatus::NotSolving, |
| schedule, |
| config, |
| stop_signal: None, |
| } |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| pub struct SolverService { |
| jobs: RwLock<HashMap<String, Arc<RwLock<SolveJob>>>>, |
| } |
|
|
| impl SolverService { |
| |
| pub fn new() -> Self { |
| Self { |
| jobs: RwLock::new(HashMap::new()), |
| } |
| } |
|
|
| |
| pub fn create_job(&self, id: String, schedule: EmployeeSchedule) -> Arc<RwLock<SolveJob>> { |
| let job = Arc::new(RwLock::new(SolveJob::new(id.clone(), schedule))); |
| self.jobs.write().insert(id, job.clone()); |
| job |
| } |
|
|
| |
| pub fn create_job_with_config( |
| &self, |
| id: String, |
| schedule: EmployeeSchedule, |
| config: SolverConfig, |
| ) -> Arc<RwLock<SolveJob>> { |
| let job = Arc::new(RwLock::new(SolveJob::with_config(id.clone(), schedule, config))); |
| self.jobs.write().insert(id, job.clone()); |
| job |
| } |
|
|
| |
| pub fn get_job(&self, id: &str) -> Option<Arc<RwLock<SolveJob>>> { |
| self.jobs.read().get(id).cloned() |
| } |
|
|
| |
| pub fn list_jobs(&self) -> Vec<String> { |
| self.jobs.read().keys().cloned().collect() |
| } |
|
|
| |
| pub fn remove_job(&self, id: &str) -> Option<Arc<RwLock<SolveJob>>> { |
| self.jobs.write().remove(id) |
| } |
|
|
| |
| pub fn start_solving(&self, job: Arc<RwLock<SolveJob>>) { |
| let (tx, rx) = oneshot::channel(); |
| let config = job.read().config.clone(); |
|
|
| { |
| let mut job_guard = job.write(); |
| job_guard.status = SolverStatus::Solving; |
| job_guard.stop_signal = Some(tx); |
| } |
|
|
| let job_clone = job.clone(); |
|
|
| tokio::task::spawn_blocking(move || { |
| solve_blocking(job_clone, rx, config); |
| }); |
| } |
|
|
| |
| pub fn stop_solving(&self, id: &str) -> bool { |
| if let Some(job) = self.get_job(id) { |
| let mut job_guard = job.write(); |
| if let Some(stop_signal) = job_guard.stop_signal.take() { |
| let _ = stop_signal.send(()); |
| job_guard.status = SolverStatus::NotSolving; |
| return true; |
| } |
| } |
| false |
| } |
| } |
|
|
| impl Default for SolverService { |
| fn default() -> Self { |
| Self::new() |
| } |
| } |
|
|
| |
| fn solve_blocking( |
| job: Arc<RwLock<SolveJob>>, |
| mut stop_rx: oneshot::Receiver<()>, |
| config: SolverConfig, |
| ) { |
| let initial_schedule = job.read().schedule.clone(); |
| let job_id = job.read().id.clone(); |
| let solve_start = Instant::now(); |
|
|
| |
| console::print_config( |
| initial_schedule.shifts.len(), |
| initial_schedule.employees.len(), |
| ); |
|
|
| info!( |
| job_id = %job_id, |
| shifts = initial_schedule.shifts.len(), |
| employees = initial_schedule.employees.len(), |
| "Starting Employee Scheduling solver" |
| ); |
|
|
| |
| let constraints = create_fluent_constraints(); |
| let mut director = TypedScoreDirector::new(initial_schedule.clone(), constraints); |
|
|
| |
| let mut ch_timer = PhaseTimer::start("ConstructionHeuristic", 0); |
| let mut current_score = construction_heuristic(&mut director, &mut ch_timer); |
| ch_timer.finish(); |
|
|
| |
| console::print_solving_started( |
| solve_start.elapsed().as_millis() as u64, |
| ¤t_score.to_string(), |
| initial_schedule.shifts.len(), |
| initial_schedule.shifts.len(), |
| initial_schedule.employees.len(), |
| ); |
|
|
| |
| update_job(&job, &director, current_score); |
|
|
| |
| let n_employees = director.working_solution().employees.len(); |
| if n_employees == 0 { |
| info!("No employees to optimize"); |
| console::print_solving_ended( |
| solve_start.elapsed(), |
| 0, |
| 1, |
| ¤t_score.to_string(), |
| current_score.is_feasible(), |
| ); |
| finish_job(&job, &director, current_score); |
| return; |
| } |
|
|
| let mut ls_timer = PhaseTimer::start("LateAcceptance", 1); |
| let mut late_scores = vec![current_score; LATE_ACCEPTANCE_SIZE]; |
| let mut step: u64 = 0; |
| let mut rng = rand::thread_rng(); |
|
|
| |
| let mut best_score = current_score; |
| let mut last_improvement_time = solve_start; |
| let mut last_improvement_step: u64 = 0; |
|
|
| loop { |
| |
| let elapsed = solve_start.elapsed(); |
| let time_since_improvement = last_improvement_time.elapsed(); |
| let steps_since_improvement = step - last_improvement_step; |
|
|
| if config.should_terminate(elapsed, step, time_since_improvement, steps_since_improvement) { |
| debug!("Termination condition met"); |
| break; |
| } |
|
|
| |
| if stop_rx.try_recv().is_ok() { |
| info!("Solving terminated early by user"); |
| break; |
| } |
|
|
| |
| if let Some((shift_idx, new_employee_idx)) = generate_move(&director, &mut rng) { |
| ls_timer.record_move(); |
|
|
| |
| let old_score = current_score; |
| let old_employee_idx = apply_move(&mut director, shift_idx, new_employee_idx); |
| let new_score = director.get_score(); |
|
|
| |
| let late_idx = (step as usize) % LATE_ACCEPTANCE_SIZE; |
| let late_score = late_scores[late_idx]; |
|
|
| if new_score >= old_score || new_score >= late_score { |
| |
| ls_timer.record_accepted(¤t_score.to_string()); |
| current_score = new_score; |
| late_scores[late_idx] = new_score; |
|
|
| |
| if new_score > best_score { |
| best_score = new_score; |
| last_improvement_time = Instant::now(); |
| last_improvement_step = step; |
| } |
|
|
| |
| if ls_timer.steps_accepted().is_multiple_of(1000) { |
| update_job(&job, &director, current_score); |
| debug!( |
| step, |
| moves_accepted = ls_timer.steps_accepted(), |
| score = %current_score, |
| elapsed_secs = solve_start.elapsed().as_secs(), |
| "Progress update" |
| ); |
| } |
|
|
| |
| if ls_timer.moves_evaluated().is_multiple_of(10000) { |
| console::print_step_progress( |
| ls_timer.steps_accepted(), |
| ls_timer.elapsed(), |
| ls_timer.moves_evaluated(), |
| ¤t_score.to_string(), |
| ); |
| } |
| } else { |
| |
| undo_move(&mut director, shift_idx, old_employee_idx); |
| } |
|
|
| step += 1; |
| } |
| } |
|
|
| ls_timer.finish(); |
|
|
| let total_duration = solve_start.elapsed(); |
|
|
| info!( |
| job_id = %job_id, |
| duration_secs = total_duration.as_secs_f64(), |
| steps = step, |
| score = %current_score, |
| feasible = current_score.is_feasible(), |
| "Solving complete" |
| ); |
|
|
| console::print_solving_ended( |
| total_duration, |
| step, |
| 2, |
| ¤t_score.to_string(), |
| current_score.is_feasible(), |
| ); |
|
|
| finish_job(&job, &director, current_score); |
| } |
|
|
| |
| fn construction_heuristic( |
| director: &mut TypedScoreDirector<EmployeeSchedule, impl ConstraintSet<EmployeeSchedule, HardSoftDecimalScore>>, |
| timer: &mut PhaseTimer, |
| ) -> HardSoftDecimalScore { |
| |
| let _ = director.calculate_score(); |
|
|
| let n_shifts = director.working_solution().shifts.len(); |
| let n_employees = director.working_solution().employees.len(); |
|
|
| if n_employees == 0 || n_shifts == 0 { |
| return director.get_score(); |
| } |
|
|
| |
| let assigned_count = director |
| .working_solution() |
| .shifts |
| .iter() |
| .filter(|s| s.employee_idx.is_some()) |
| .count(); |
|
|
| |
| if assigned_count == n_shifts { |
| info!("All shifts already assigned, skipping construction heuristic"); |
| return director.get_score(); |
| } |
|
|
| |
| let mut employee_idx = 0; |
| for shift_idx in 0..n_shifts { |
| if director.working_solution().shifts[shift_idx].employee_idx.is_some() { |
| continue; |
| } |
|
|
| timer.record_move(); |
| director.before_variable_changed(shift_idx); |
| director.working_solution_mut().shifts[shift_idx].employee_idx = Some(employee_idx); |
| director.after_variable_changed(shift_idx); |
|
|
| let score = director.get_score(); |
| timer.record_accepted(&score.to_string()); |
|
|
| employee_idx = (employee_idx + 1) % n_employees; |
| } |
|
|
| director.get_score() |
| } |
|
|
| |
| fn generate_move<R: Rng>( |
| director: &TypedScoreDirector<EmployeeSchedule, impl ConstraintSet<EmployeeSchedule, HardSoftDecimalScore>>, |
| rng: &mut R, |
| ) -> Option<(usize, Option<usize>)> { |
| let solution = director.working_solution(); |
| let n_shifts = solution.shifts.len(); |
| let n_employees = solution.employees.len(); |
|
|
| if n_shifts == 0 || n_employees == 0 { |
| return None; |
| } |
|
|
| |
| let shift_idx = rng.gen_range(0..n_shifts); |
| let current_employee = solution.shifts[shift_idx].employee_idx; |
|
|
| |
| let new_employee_idx = rng.gen_range(0..n_employees); |
|
|
| |
| if current_employee == Some(new_employee_idx) { |
| return None; |
| } |
|
|
| Some((shift_idx, Some(new_employee_idx))) |
| } |
|
|
| |
| fn apply_move( |
| director: &mut TypedScoreDirector<EmployeeSchedule, impl ConstraintSet<EmployeeSchedule, HardSoftDecimalScore>>, |
| shift_idx: usize, |
| new_employee_idx: Option<usize>, |
| ) -> Option<usize> { |
| let old_employee_idx = director.working_solution().shifts[shift_idx].employee_idx; |
|
|
| director.before_variable_changed(shift_idx); |
| director.working_solution_mut().shifts[shift_idx].employee_idx = new_employee_idx; |
| director.after_variable_changed(shift_idx); |
|
|
| old_employee_idx |
| } |
|
|
| |
| fn undo_move( |
| director: &mut TypedScoreDirector<EmployeeSchedule, impl ConstraintSet<EmployeeSchedule, HardSoftDecimalScore>>, |
| shift_idx: usize, |
| old_employee_idx: Option<usize>, |
| ) { |
| director.before_variable_changed(shift_idx); |
| director.working_solution_mut().shifts[shift_idx].employee_idx = old_employee_idx; |
| director.after_variable_changed(shift_idx); |
| } |
|
|
| |
| fn update_job( |
| job: &Arc<RwLock<SolveJob>>, |
| director: &TypedScoreDirector<EmployeeSchedule, impl ConstraintSet<EmployeeSchedule, HardSoftDecimalScore>>, |
| score: HardSoftDecimalScore, |
| ) { |
| let mut job_guard = job.write(); |
| job_guard.schedule = director.clone_working_solution(); |
| job_guard.schedule.score = Some(score); |
| } |
|
|
| |
| fn finish_job( |
| job: &Arc<RwLock<SolveJob>>, |
| director: &TypedScoreDirector<EmployeeSchedule, impl ConstraintSet<EmployeeSchedule, HardSoftDecimalScore>>, |
| score: HardSoftDecimalScore, |
| ) { |
| let mut job_guard = job.write(); |
| job_guard.schedule = director.clone_working_solution(); |
| job_guard.schedule.score = Some(score); |
| job_guard.status = SolverStatus::NotSolving; |
| } |
|
|
| #[cfg(test)] |
| mod tests { |
| use super::*; |
| use crate::demo_data::{generate, DemoData}; |
|
|
| #[test] |
| fn test_construction_heuristic() { |
| let schedule = generate(DemoData::Small); |
| let constraints = create_fluent_constraints(); |
| let mut director = TypedScoreDirector::new(schedule, constraints); |
|
|
| let mut timer = PhaseTimer::start("ConstructionHeuristic", 0); |
| let score = construction_heuristic(&mut director, &mut timer); |
|
|
| |
| let assigned_count = director |
| .working_solution() |
| .shifts |
| .iter() |
| .filter(|s| s.employee_idx.is_some()) |
| .count(); |
| let total_shifts = director.working_solution().shifts.len(); |
| assert_eq!(assigned_count, total_shifts); |
| assert!(score.hard_scaled() <= 0); |
| } |
| } |
|
|