Skip to main content

u_schedule/ga/
problem.rs

1//! Scheduling GA problem definition.
2//!
3//! Implements `u_metaheur::ga::GaProblem` for scheduling optimization.
4//! Bridges domain models (Task, Resource) to the generic GA framework.
5//!
6//! # Reference
7//! Cheng et al. (1996), "A Tutorial Survey of JSSP using GA"
8
9use std::collections::HashMap;
10
11use rand::Rng;
12use u_metaheur::ga::GaProblem;
13
14use super::chromosome::ScheduleChromosome;
15use super::operators::GeneticOperators;
16use crate::models::{Assignment, Resource, Schedule, Task, TransitionMatrixCollection};
17
18/// Compact activity descriptor for GA encoding.
19///
20/// Extracted from `Task`/`Activity` to avoid cloning full domain objects.
21#[derive(Debug, Clone)]
22pub struct ActivityInfo {
23    /// Parent task ID.
24    pub task_id: String,
25    /// Activity sequence within task (1-based).
26    pub sequence: i32,
27    /// Processing time (ms).
28    pub process_ms: i64,
29    /// Candidate resource IDs.
30    pub candidates: Vec<String>,
31}
32
33impl ActivityInfo {
34    /// Extracts activity info from domain tasks.
35    pub fn from_tasks(tasks: &[Task]) -> Vec<Self> {
36        let mut infos = Vec::new();
37        for task in tasks {
38            for (i, activity) in task.activities.iter().enumerate() {
39                infos.push(ActivityInfo {
40                    task_id: task.id.clone(),
41                    sequence: (i + 1) as i32,
42                    process_ms: activity.duration.process_ms,
43                    candidates: activity
44                        .candidate_resources()
45                        .into_iter()
46                        .map(|s| s.to_string())
47                        .collect(),
48                });
49            }
50        }
51        infos
52    }
53}
54
55/// GA problem definition for scheduling optimization.
56///
57/// Decodes chromosomes into schedules and evaluates fitness as makespan.
58///
59/// # Example
60/// ```no_run
61/// use u_schedule::ga::{SchedulingGaProblem, ActivityInfo};
62/// use u_schedule::models::{Task, Resource, ResourceType};
63/// use u_metaheur::ga::{GaConfig, GaRunner};
64///
65/// let tasks = vec![/* ... */];
66/// let resources = vec![/* ... */];
67/// let problem = SchedulingGaProblem::new(&tasks, &resources);
68/// let config = GaConfig::default();
69/// let result = GaRunner::run(&problem, &config);
70/// ```
71pub struct SchedulingGaProblem {
72    /// Activity info (extracted from tasks).
73    pub activities: Vec<ActivityInfo>,
74    /// Available resources.
75    pub resources: Vec<Resource>,
76    /// Task categories (task_id → category).
77    pub task_categories: HashMap<String, String>,
78    /// Transition matrices for setup times.
79    pub transition_matrices: TransitionMatrixCollection,
80    /// Task deadlines (task_id → deadline_ms).
81    pub deadlines: HashMap<String, i64>,
82    /// Task release times (task_id → release_ms).
83    pub release_times: HashMap<String, i64>,
84    /// Weight for tardiness in fitness (default: 0.5).
85    pub tardiness_weight: f64,
86    /// Per-resource processing times: `(task_id, sequence, resource_id) → ms`.
87    ///
88    /// Used for SPT (Shortest Processing Time) initialization.
89    /// If empty, SPT initialization is skipped and replaced with load-balanced.
90    pub process_times: HashMap<(String, i32, String), i64>,
91    /// Genetic operators for crossover/mutation strategy selection.
92    ///
93    /// Default: POX crossover + Swap mutation.
94    /// Override with [`with_operators`](SchedulingGaProblem::with_operators).
95    pub operators: GeneticOperators,
96    /// Precomputed index: `(task_id, sequence) → activities index`.
97    ///
98    /// Built once at construction, enables O(1) activity lookup during decode.
99    activity_index: HashMap<(String, i32), usize>,
100}
101
102impl SchedulingGaProblem {
103    /// Creates a problem from domain models.
104    pub fn new(tasks: &[Task], resources: &[Resource]) -> Self {
105        let activities = ActivityInfo::from_tasks(tasks);
106        let mut task_categories = HashMap::new();
107        let mut deadlines = HashMap::new();
108        let mut release_times = HashMap::new();
109
110        for task in tasks {
111            task_categories.insert(task.id.clone(), task.category.clone());
112            if let Some(dl) = task.deadline {
113                deadlines.insert(task.id.clone(), dl);
114            }
115            if let Some(rt) = task.release_time {
116                release_times.insert(task.id.clone(), rt);
117            }
118        }
119
120        // Build (task_id, sequence) → index lookup for O(1) decode
121        let activity_index: HashMap<(String, i32), usize> = activities
122            .iter()
123            .enumerate()
124            .map(|(i, a)| ((a.task_id.clone(), a.sequence), i))
125            .collect();
126
127        Self {
128            activities,
129            resources: resources.to_vec(),
130            task_categories,
131            transition_matrices: TransitionMatrixCollection::new(),
132            deadlines,
133            release_times,
134            tardiness_weight: 0.5,
135            process_times: HashMap::new(),
136            operators: GeneticOperators::default(),
137            activity_index,
138        }
139    }
140
141    /// Sets transition matrices.
142    pub fn with_transition_matrices(mut self, matrices: TransitionMatrixCollection) -> Self {
143        self.transition_matrices = matrices;
144        self
145    }
146
147    /// Sets tardiness weight (0.0 = pure makespan, 1.0 = pure tardiness).
148    pub fn with_tardiness_weight(mut self, weight: f64) -> Self {
149        self.tardiness_weight = weight.clamp(0.0, 1.0);
150        self
151    }
152
153    /// Sets per-resource processing times for SPT initialization.
154    ///
155    /// When set, 25% of the initial population uses SPT (Shortest Processing
156    /// Time) initialization. When empty, that 25% falls back to load-balanced.
157    pub fn with_process_times(
158        mut self,
159        process_times: HashMap<(String, i32, String), i64>,
160    ) -> Self {
161        self.process_times = process_times;
162        self
163    }
164
165    /// Sets the genetic operators (crossover + mutation strategy).
166    ///
167    /// # Example
168    /// ```no_run
169    /// use u_schedule::ga::SchedulingGaProblem;
170    /// use u_schedule::ga::operators::{GeneticOperators, CrossoverType, MutationType};
171    ///
172    /// # let (tasks, resources) = (vec![], vec![]);
173    /// let problem = SchedulingGaProblem::new(&tasks, &resources)
174    ///     .with_operators(GeneticOperators {
175    ///         crossover_type: CrossoverType::LOX,
176    ///         mutation_type: MutationType::Invert,
177    ///     });
178    /// ```
179    pub fn with_operators(mut self, operators: GeneticOperators) -> Self {
180        self.operators = operators;
181        self
182    }
183
184    /// Decodes a chromosome into a Schedule.
185    pub fn decode(&self, chromosome: &ScheduleChromosome) -> Schedule {
186        let mut schedule = Schedule::new();
187        let mut resource_available: HashMap<&str, i64> = HashMap::new();
188        let mut task_available: HashMap<&str, i64> = HashMap::new();
189        let mut last_category: HashMap<&str, &str> = HashMap::new();
190
191        // Initialize resource availability
192        for resource in &self.resources {
193            resource_available.insert(&resource.id, 0);
194        }
195
196        // Decode OSV to get operation order
197        let operation_order = chromosome.decode_osv();
198
199        for (task_id, seq) in &operation_order {
200            // O(1) activity lookup via precomputed index
201            let act = match self.activity_index.get(&(task_id.clone(), *seq)) {
202                Some(&idx) => &self.activities[idx],
203                None => continue,
204            };
205
206            // Get assigned resource from MAV
207            let resource_id = match chromosome.resource_for(task_id, *seq) {
208                Some(r) if !r.is_empty() => r,
209                _ => continue,
210            };
211
212            // Calculate start time
213            let resource_ready = resource_available.get(resource_id).copied().unwrap_or(0);
214            let task_ready = task_available.get(task_id.as_str()).copied().unwrap_or(0);
215            let release = self.release_times.get(task_id).copied().unwrap_or(0);
216            let earliest = resource_ready.max(task_ready).max(release);
217
218            // Setup time
219            let setup = if let Some(&prev_cat) = last_category.get(resource_id) {
220                let task_cat = self
221                    .task_categories
222                    .get(task_id)
223                    .map(|s| s.as_str())
224                    .unwrap_or("");
225                self.transition_matrices
226                    .get_transition_time(resource_id, prev_cat, task_cat)
227            } else {
228                0
229            };
230
231            let start = earliest;
232            let end = start + setup + act.process_ms;
233
234            schedule.add_assignment(
235                Assignment::new(&act.task_id, task_id, resource_id, start, end).with_setup(setup),
236            );
237
238            // Update state
239            resource_available.insert(resource_id, end);
240            task_available.insert(task_id, end);
241            if let Some(cat) = self.task_categories.get(task_id) {
242                last_category.insert(resource_id, cat);
243            }
244        }
245
246        schedule
247    }
248
249    /// Computes fitness: weighted combination of makespan and tardiness.
250    fn compute_fitness(&self, schedule: &Schedule) -> f64 {
251        let makespan = schedule.makespan_ms() as f64;
252
253        let total_tardiness: f64 = self
254            .deadlines
255            .iter()
256            .map(|(task_id, &deadline)| {
257                let completion = schedule.task_completion_time(task_id).unwrap_or(0);
258                (completion - deadline).max(0) as f64
259            })
260            .sum();
261
262        // Weighted combination (both terms in ms, comparable scale)
263        let makespan_weight = 1.0 - self.tardiness_weight;
264        makespan_weight * makespan + self.tardiness_weight * total_tardiness
265    }
266}
267
268impl GaProblem for SchedulingGaProblem {
269    type Individual = ScheduleChromosome;
270
271    fn create_individual<R: Rng>(&self, rng: &mut R) -> ScheduleChromosome {
272        // 50% random, 25% load-balanced, 25% SPT (or load-balanced if no process_times)
273        let roll: f64 = rng.random_range(0.0..1.0);
274        if roll < 0.5 {
275            ScheduleChromosome::random(&self.activities, rng)
276        } else if roll < 0.75 || self.process_times.is_empty() {
277            let cap: HashMap<String, i64> = self
278                .resources
279                .iter()
280                .map(|r| (r.id.clone(), r.capacity as i64))
281                .collect();
282            ScheduleChromosome::with_load_balancing(&self.activities, &cap, rng)
283        } else {
284            ScheduleChromosome::with_shortest_time(&self.activities, &self.process_times, rng)
285        }
286    }
287
288    fn evaluate(&self, individual: &ScheduleChromosome) -> f64 {
289        let schedule = self.decode(individual);
290        self.compute_fitness(&schedule)
291    }
292
293    fn crossover<R: Rng>(
294        &self,
295        parent1: &ScheduleChromosome,
296        parent2: &ScheduleChromosome,
297        rng: &mut R,
298    ) -> Vec<ScheduleChromosome> {
299        let (c1, c2) = self
300            .operators
301            .crossover(parent1, parent2, &self.activities, rng);
302        vec![c1, c2]
303    }
304
305    fn mutate<R: Rng>(&self, individual: &mut ScheduleChromosome, rng: &mut R) {
306        self.operators.mutate(individual, &self.activities, rng);
307    }
308}
309
310// Make SchedulingGaProblem Send + Sync (all fields are owned data)
311unsafe impl Send for SchedulingGaProblem {}
312unsafe impl Sync for SchedulingGaProblem {}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use crate::ga::operators::{CrossoverType, MutationType};
318    use crate::models::{Activity, ActivityDuration, ResourceRequirement, ResourceType};
319    use rand::rngs::SmallRng;
320    use rand::SeedableRng;
321    use u_metaheur::ga::{GaConfig, GaRunner};
322
323    fn make_test_problem() -> (Vec<Task>, Vec<Resource>) {
324        let tasks = vec![
325            Task::new("T1")
326                .with_category("TypeA")
327                .with_priority(5)
328                .with_deadline(10_000)
329                .with_activity(
330                    Activity::new("T1_O1", "T1", 0)
331                        .with_duration(ActivityDuration::fixed(1000))
332                        .with_requirement(
333                            ResourceRequirement::new("Machine")
334                                .with_candidates(vec!["M1".into(), "M2".into()]),
335                        ),
336                )
337                .with_activity(
338                    Activity::new("T1_O2", "T1", 1)
339                        .with_duration(ActivityDuration::fixed(2000))
340                        .with_requirement(
341                            ResourceRequirement::new("Machine").with_candidates(vec!["M2".into()]),
342                        ),
343                ),
344            Task::new("T2")
345                .with_category("TypeB")
346                .with_priority(3)
347                .with_activity(
348                    Activity::new("T2_O1", "T2", 0)
349                        .with_duration(ActivityDuration::fixed(1500))
350                        .with_requirement(
351                            ResourceRequirement::new("Machine")
352                                .with_candidates(vec!["M1".into(), "M3".into()]),
353                        ),
354                ),
355        ];
356
357        let resources = vec![
358            Resource::new("M1", ResourceType::Primary),
359            Resource::new("M2", ResourceType::Primary),
360            Resource::new("M3", ResourceType::Primary),
361        ];
362
363        (tasks, resources)
364    }
365
366    #[test]
367    fn test_activity_info_from_tasks() {
368        let (tasks, _) = make_test_problem();
369        let infos = ActivityInfo::from_tasks(&tasks);
370        assert_eq!(infos.len(), 3);
371        assert_eq!(infos[0].task_id, "T1");
372        assert_eq!(infos[0].sequence, 1);
373        assert_eq!(infos[0].process_ms, 1000);
374        assert_eq!(infos[2].task_id, "T2");
375    }
376
377    #[test]
378    fn test_decode_chromosome() {
379        let (tasks, resources) = make_test_problem();
380        let problem = SchedulingGaProblem::new(&tasks, &resources);
381        let mut rng = SmallRng::seed_from_u64(42);
382        let ch = problem.create_individual(&mut rng);
383
384        let schedule = problem.decode(&ch);
385        // Should have assignments for all 3 activities
386        assert!(schedule.assignment_count() > 0);
387        assert!(schedule.makespan_ms() > 0);
388    }
389
390    #[test]
391    fn test_fitness_computation() {
392        let (tasks, resources) = make_test_problem();
393        let problem = SchedulingGaProblem::new(&tasks, &resources);
394        let mut rng = SmallRng::seed_from_u64(42);
395        let ch = problem.create_individual(&mut rng);
396
397        let fitness = problem.evaluate(&ch);
398        assert!(fitness.is_finite());
399        assert!(fitness > 0.0);
400    }
401
402    #[test]
403    fn test_ga_runner_integration() {
404        let (tasks, resources) = make_test_problem();
405        let problem = SchedulingGaProblem::new(&tasks, &resources);
406        let config = GaConfig::default()
407            .with_population_size(20)
408            .with_max_generations(10)
409            .with_seed(42)
410            .with_parallel(false);
411
412        let result = GaRunner::run(&problem, &config).expect("GA should succeed");
413        assert!(result.best_fitness.is_finite());
414        assert!(result.best_fitness < f64::INFINITY);
415        assert!(result.generations > 0);
416    }
417
418    #[test]
419    fn test_crossover_and_mutation() {
420        let (tasks, resources) = make_test_problem();
421        let problem = SchedulingGaProblem::new(&tasks, &resources);
422        let mut rng = SmallRng::seed_from_u64(42);
423
424        let p1 = problem.create_individual(&mut rng);
425        let p2 = problem.create_individual(&mut rng);
426
427        let children = problem.crossover(&p1, &p2, &mut rng);
428        assert_eq!(children.len(), 2);
429
430        let mut child = children[0].clone();
431        problem.mutate(&mut child, &mut rng);
432        assert_eq!(child.osv.len(), p1.osv.len());
433    }
434
435    #[test]
436    fn test_tardiness_weight() {
437        let (tasks, resources) = make_test_problem();
438        let problem_makespan =
439            SchedulingGaProblem::new(&tasks, &resources).with_tardiness_weight(0.0);
440        let problem_tardy = SchedulingGaProblem::new(&tasks, &resources).with_tardiness_weight(1.0);
441
442        let mut rng = SmallRng::seed_from_u64(42);
443        let ch = problem_makespan.create_individual(&mut rng);
444
445        let f1 = problem_makespan.evaluate(&ch);
446        let f2 = problem_tardy.evaluate(&ch);
447        // Different weights should give different fitness
448        assert!(f1 != f2 || (f1 == 0.0 && f2 == 0.0));
449    }
450
451    #[test]
452    fn test_spt_initialization() {
453        let (tasks, resources) = make_test_problem();
454        let process_times: HashMap<(String, i32, String), i64> = [
455            (("T1".into(), 1, "M1".into()), 500),
456            (("T1".into(), 1, "M2".into()), 900),
457            (("T1".into(), 2, "M2".into()), 2000),
458            (("T2".into(), 1, "M1".into()), 1500),
459            (("T2".into(), 1, "M3".into()), 800),
460        ]
461        .into_iter()
462        .collect();
463
464        let problem =
465            SchedulingGaProblem::new(&tasks, &resources).with_process_times(process_times);
466
467        // Generate many individuals to exercise all 3 init strategies
468        let mut rng = SmallRng::seed_from_u64(42);
469        for _ in 0..100 {
470            let ch = problem.create_individual(&mut rng);
471            assert_eq!(ch.osv.len(), 3);
472            assert_eq!(ch.mav.len(), 3);
473        }
474    }
475
476    #[test]
477    fn test_with_operators_lox_invert() {
478        let (tasks, resources) = make_test_problem();
479        let ops = GeneticOperators {
480            crossover_type: CrossoverType::LOX,
481            mutation_type: MutationType::Invert,
482        };
483        let problem = SchedulingGaProblem::new(&tasks, &resources).with_operators(ops);
484        let config = GaConfig::default()
485            .with_population_size(20)
486            .with_max_generations(10)
487            .with_seed(42)
488            .with_parallel(false);
489
490        let result = GaRunner::run(&problem, &config).expect("GA should succeed");
491        assert!(result.best_fitness.is_finite());
492        assert!(result.best_fitness < f64::INFINITY);
493    }
494
495    #[test]
496    fn test_with_operators_jox_insert() {
497        let (tasks, resources) = make_test_problem();
498        let ops = GeneticOperators {
499            crossover_type: CrossoverType::JOX,
500            mutation_type: MutationType::Insert,
501        };
502        let problem = SchedulingGaProblem::new(&tasks, &resources).with_operators(ops);
503        let config = GaConfig::default()
504            .with_population_size(20)
505            .with_max_generations(10)
506            .with_seed(99)
507            .with_parallel(false);
508
509        let result = GaRunner::run(&problem, &config).expect("GA should succeed");
510        assert!(result.best_fitness.is_finite());
511        assert!(result.best_fitness < f64::INFINITY);
512    }
513
514    #[test]
515    fn test_default_operators_backward_compatible() {
516        // Default operators should be POX + Swap (same as original hardcoded behavior)
517        let (tasks, resources) = make_test_problem();
518        let problem = SchedulingGaProblem::new(&tasks, &resources);
519        assert_eq!(problem.operators.crossover_type, CrossoverType::POX);
520        assert_eq!(problem.operators.mutation_type, MutationType::Swap);
521    }
522
523    #[test]
524    fn test_ga_runner_with_process_times() {
525        let (tasks, resources) = make_test_problem();
526        let process_times: HashMap<(String, i32, String), i64> = [
527            (("T1".into(), 1, "M1".into()), 500),
528            (("T1".into(), 1, "M2".into()), 900),
529            (("T1".into(), 2, "M2".into()), 2000),
530            (("T2".into(), 1, "M1".into()), 1500),
531            (("T2".into(), 1, "M3".into()), 800),
532        ]
533        .into_iter()
534        .collect();
535
536        let problem =
537            SchedulingGaProblem::new(&tasks, &resources).with_process_times(process_times);
538        let config = GaConfig::default()
539            .with_population_size(20)
540            .with_max_generations(10)
541            .with_seed(42)
542            .with_parallel(false);
543
544        let result = GaRunner::run(&problem, &config).expect("GA should succeed");
545        assert!(result.best_fitness.is_finite());
546        assert!(result.best_fitness < f64::INFINITY);
547    }
548}