Skip to main content

u_schedule/dispatching/rules/
mod.rs

1//! Built-in dispatching rules.
2//!
3//! # Categories
4//!
5//! - **Time-based**: SPT, LPT, LWKR, MWKR, WSPT
6//! - **Due-date**: EDD, MST, CR, SRO, ATC
7//! - **Queue/Load**: FIFO, WINQ, LPUL
8//! - **Priority**: PRIORITY
9//!
10//! # Score Convention
11//! All rules return lower scores for higher priority tasks.
12//!
13//! # References
14//! - Pinedo (2016), "Scheduling: Theory, Algorithms, and Systems", Ch. 4
15//! - Haupt (1989), "A Survey of Priority Rule-Based Scheduling"
16
17use super::{DispatchingRule, RuleScore, SchedulingContext};
18use crate::models::Task;
19
20// ======================== Time-based rules ========================
21
22/// Shortest Processing Time.
23///
24/// Prioritizes tasks with shorter total processing time.
25/// Minimizes average flow time and WIP (Work-In-Process).
26///
27/// # Reference
28/// Smith (1956), optimal for minimizing mean flow time on single machine.
29#[derive(Debug, Clone, Copy)]
30pub struct Spt;
31
32impl DispatchingRule for Spt {
33    fn name(&self) -> &'static str {
34        "SPT"
35    }
36
37    fn evaluate(&self, task: &Task, _context: &SchedulingContext) -> RuleScore {
38        task.total_duration_ms() as f64
39    }
40
41    fn description(&self) -> &'static str {
42        "Shortest Processing Time"
43    }
44}
45
46/// Longest Processing Time.
47///
48/// Prioritizes tasks with longer total processing time.
49/// Useful for load balancing in parallel machine environments.
50#[derive(Debug, Clone, Copy)]
51pub struct Lpt;
52
53impl DispatchingRule for Lpt {
54    fn name(&self) -> &'static str {
55        "LPT"
56    }
57
58    fn evaluate(&self, task: &Task, _context: &SchedulingContext) -> RuleScore {
59        -(task.total_duration_ms() as f64)
60    }
61
62    fn description(&self) -> &'static str {
63        "Longest Processing Time"
64    }
65}
66
67/// Least Work Remaining.
68///
69/// Prioritizes tasks closer to completion. Uses `context.remaining_work`
70/// if available, falls back to total duration.
71#[derive(Debug, Clone, Copy)]
72pub struct Lwkr;
73
74impl DispatchingRule for Lwkr {
75    fn name(&self) -> &'static str {
76        "LWKR"
77    }
78
79    fn evaluate(&self, task: &Task, context: &SchedulingContext) -> RuleScore {
80        context
81            .remaining_work
82            .get(&task.id)
83            .copied()
84            .unwrap_or_else(|| task.total_duration_ms()) as f64
85    }
86
87    fn description(&self) -> &'static str {
88        "Least Work Remaining"
89    }
90}
91
92/// Most Work Remaining.
93///
94/// Prioritizes tasks with the most remaining work.
95/// Prevents starvation of long tasks.
96#[derive(Debug, Clone, Copy)]
97pub struct Mwkr;
98
99impl DispatchingRule for Mwkr {
100    fn name(&self) -> &'static str {
101        "MWKR"
102    }
103
104    fn evaluate(&self, task: &Task, context: &SchedulingContext) -> RuleScore {
105        let remaining = context
106            .remaining_work
107            .get(&task.id)
108            .copied()
109            .unwrap_or_else(|| task.total_duration_ms());
110        -(remaining as f64)
111    }
112
113    fn description(&self) -> &'static str {
114        "Most Work Remaining"
115    }
116}
117
118/// Weighted Shortest Processing Time.
119///
120/// Prioritizes by the ratio of importance to processing time.
121/// Weight is derived from priority: `weight = 1000 / (priority + 1)`.
122///
123/// # Reference
124/// Smith (1956), optimal for minimizing weighted mean flow time.
125#[derive(Debug, Clone, Copy)]
126pub struct Wspt;
127
128impl DispatchingRule for Wspt {
129    fn name(&self) -> &'static str {
130        "WSPT"
131    }
132
133    fn evaluate(&self, task: &Task, _context: &SchedulingContext) -> RuleScore {
134        let processing_time = task.total_duration_ms() as f64;
135        if processing_time <= 0.0 {
136            return f64::MAX;
137        }
138        let weight = 1000.0 / (task.priority as f64 + 1.0);
139        -(weight / processing_time) // Higher ratio = higher priority → negate
140    }
141
142    fn description(&self) -> &'static str {
143        "Weighted Shortest Processing Time"
144    }
145}
146
147// ======================== Due-date rules ========================
148
149/// Earliest Due Date.
150///
151/// Prioritizes tasks with earlier deadlines. Tasks without deadlines
152/// are assigned lowest priority.
153///
154/// # Reference
155/// Jackson (1955), optimal for minimizing maximum lateness on single machine.
156#[derive(Debug, Clone, Copy)]
157pub struct Edd;
158
159impl DispatchingRule for Edd {
160    fn name(&self) -> &'static str {
161        "EDD"
162    }
163
164    fn evaluate(&self, task: &Task, _context: &SchedulingContext) -> RuleScore {
165        task.deadline.map(|d| d as f64).unwrap_or(f64::MAX)
166    }
167
168    fn description(&self) -> &'static str {
169        "Earliest Due Date"
170    }
171}
172
173/// Minimum Slack Time.
174///
175/// Slack = (deadline - current_time) - remaining_work.
176/// Prioritizes tasks with least slack (most urgent).
177///
178/// Tasks without deadlines get maximum slack (lowest priority).
179#[derive(Debug, Clone, Copy)]
180pub struct Mst;
181
182impl DispatchingRule for Mst {
183    fn name(&self) -> &'static str {
184        "MST"
185    }
186
187    fn evaluate(&self, task: &Task, context: &SchedulingContext) -> RuleScore {
188        let deadline = match task.deadline {
189            Some(d) => d,
190            None => return f64::MAX,
191        };
192
193        let remaining = context
194            .remaining_work
195            .get(&task.id)
196            .copied()
197            .unwrap_or_else(|| task.total_duration_ms());
198
199        let time_until_deadline = deadline - context.current_time_ms;
200        (time_until_deadline - remaining) as f64
201    }
202
203    fn description(&self) -> &'static str {
204        "Minimum Slack Time"
205    }
206}
207
208/// Critical Ratio.
209///
210/// CR = (deadline - current_time) / remaining_work.
211/// - CR < 1.0: behind schedule
212/// - CR = 1.0: on track
213/// - CR > 1.0: ahead of schedule
214///
215/// Prioritizes tasks with lowest CR (most behind).
216#[derive(Debug, Clone, Copy)]
217pub struct Cr;
218
219impl DispatchingRule for Cr {
220    fn name(&self) -> &'static str {
221        "CR"
222    }
223
224    fn evaluate(&self, task: &Task, context: &SchedulingContext) -> RuleScore {
225        let deadline = match task.deadline {
226            Some(d) => d,
227            None => return f64::MAX,
228        };
229
230        let remaining = context
231            .remaining_work
232            .get(&task.id)
233            .copied()
234            .unwrap_or_else(|| task.total_duration_ms());
235
236        if remaining <= 0 {
237            return f64::MAX; // Already done
238        }
239
240        let time_until_deadline = (deadline - context.current_time_ms) as f64;
241        time_until_deadline / remaining as f64
242    }
243
244    fn description(&self) -> &'static str {
245        "Critical Ratio"
246    }
247}
248
249/// Slack per Remaining Operations.
250///
251/// S/RO = slack / remaining_operation_count.
252/// Accounts for the number of remaining steps, not just total work.
253#[derive(Debug, Clone, Copy)]
254pub struct Sro;
255
256impl DispatchingRule for Sro {
257    fn name(&self) -> &'static str {
258        "S/RO"
259    }
260
261    fn evaluate(&self, task: &Task, context: &SchedulingContext) -> RuleScore {
262        let deadline = match task.deadline {
263            Some(d) => d,
264            None => return f64::MAX,
265        };
266
267        let remaining_work = context
268            .remaining_work
269            .get(&task.id)
270            .copied()
271            .unwrap_or_else(|| task.total_duration_ms());
272
273        let op_count = task.activity_count().max(1);
274        let slack = (deadline - context.current_time_ms - remaining_work) as f64;
275        slack / op_count as f64
276    }
277
278    fn description(&self) -> &'static str {
279        "Slack per Remaining Operations"
280    }
281}
282
283/// Apparent Tardiness Cost.
284///
285/// Combines WSPT with deadline urgency using an exponential function.
286/// The parameter `k` controls the balance:
287/// - k > 2: more SPT-like (processing time dominates)
288/// - k < 2: more EDD-like (deadline dominates)
289///
290/// # Reference
291/// Vepsalainen & Morton (1987), "Priority Rules for Job Shops with
292/// Weighted Tardiness Costs"
293#[derive(Debug, Clone, Copy)]
294pub struct Atc {
295    /// Lookahead parameter (default: 2.0).
296    pub k: f64,
297}
298
299impl Default for Atc {
300    fn default() -> Self {
301        Self { k: 2.0 }
302    }
303}
304
305impl Atc {
306    /// Creates an ATC rule with custom k parameter.
307    pub fn with_k(k: f64) -> Self {
308        Self { k }
309    }
310}
311
312impl DispatchingRule for Atc {
313    fn name(&self) -> &'static str {
314        "ATC"
315    }
316
317    fn evaluate(&self, task: &Task, context: &SchedulingContext) -> RuleScore {
318        let processing_time = task.total_duration_ms() as f64;
319        if processing_time <= 0.0 {
320            return f64::MAX;
321        }
322
323        let weight = 1000.0 / (task.priority as f64 + 1.0);
324
325        let deadline = match task.deadline {
326            Some(d) => d as f64,
327            None => return -(weight / processing_time), // Fallback to WSPT
328        };
329
330        let slack = deadline - processing_time - context.current_time_ms as f64;
331        let p_avg = context
332            .average_processing_time
333            .unwrap_or(processing_time)
334            .max(1.0);
335
336        let urgency = if slack <= 0.0 {
337            1.0
338        } else {
339            (-slack / (self.k * p_avg)).exp()
340        };
341
342        -(weight / processing_time * urgency) // Higher ATC = higher priority → negate
343    }
344
345    fn description(&self) -> &'static str {
346        "Apparent Tardiness Cost"
347    }
348}
349
350// ======================== Queue/Load rules ========================
351
352/// First In First Out.
353///
354/// Prioritizes tasks by arrival time. Uses `context.arrival_times`
355/// or falls back to `task.release_time`.
356#[derive(Debug, Clone, Copy)]
357pub struct Fifo;
358
359impl DispatchingRule for Fifo {
360    fn name(&self) -> &'static str {
361        "FIFO"
362    }
363
364    fn evaluate(&self, task: &Task, context: &SchedulingContext) -> RuleScore {
365        context
366            .arrival_times
367            .get(&task.id)
368            .copied()
369            .unwrap_or_else(|| task.release_time.unwrap_or(0)) as f64
370    }
371
372    fn description(&self) -> &'static str {
373        "First In First Out"
374    }
375}
376
377/// Work In Next Queue.
378///
379/// Prioritizes tasks whose next resource has the shortest queue.
380/// Uses `context.next_queue_length`.
381#[derive(Debug, Clone, Copy)]
382pub struct Winq;
383
384impl DispatchingRule for Winq {
385    fn name(&self) -> &'static str {
386        "WINQ"
387    }
388
389    fn evaluate(&self, task: &Task, context: &SchedulingContext) -> RuleScore {
390        context
391            .next_queue_length
392            .get(&task.id)
393            .copied()
394            .unwrap_or(0) as f64
395    }
396
397    fn description(&self) -> &'static str {
398        "Work In Next Queue"
399    }
400}
401
402/// Least Planned Utilization Level.
403///
404/// Prioritizes tasks whose candidate resources have the lowest utilization.
405/// Uses `context.resource_utilization` and `task.activities[0].resource_requirements`.
406#[derive(Debug, Clone, Copy)]
407pub struct Lpul;
408
409impl DispatchingRule for Lpul {
410    fn name(&self) -> &'static str {
411        "LPUL"
412    }
413
414    fn evaluate(&self, task: &Task, context: &SchedulingContext) -> RuleScore {
415        if let Some(activity) = task.activities.first() {
416            let min_util = activity
417                .resource_requirements
418                .iter()
419                .flat_map(|req| req.candidates.iter())
420                .filter_map(|res_id| context.resource_utilization.get(res_id))
421                .copied()
422                .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
423
424            min_util.unwrap_or(0.0)
425        } else {
426            0.0
427        }
428    }
429
430    fn description(&self) -> &'static str {
431        "Least Planned Utilization Level"
432    }
433}
434
435// ======================== Priority-based rule ========================
436
437/// Simple priority rule.
438///
439/// Prioritizes tasks with higher `task.priority` values.
440/// (Negated because lower score = higher priority in convention.)
441#[derive(Debug, Clone, Copy)]
442pub struct Priority;
443
444impl DispatchingRule for Priority {
445    fn name(&self) -> &'static str {
446        "PRIORITY"
447    }
448
449    fn evaluate(&self, task: &Task, _context: &SchedulingContext) -> RuleScore {
450        -(task.priority as f64)
451    }
452
453    fn description(&self) -> &'static str {
454        "Task Priority"
455    }
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461    use crate::models::{Activity, ActivityDuration, ResourceRequirement};
462
463    fn make_task(id: &str, duration_ms: i64, deadline: Option<i64>, priority: i32) -> Task {
464        let mut task = Task::new(id).with_priority(priority).with_activity(
465            Activity::new(format!("{id}_O1"), id, 0)
466                .with_duration(ActivityDuration::fixed(duration_ms)),
467        );
468        task.deadline = deadline;
469        task
470    }
471
472    #[test]
473    fn test_spt() {
474        let ctx = SchedulingContext::at_time(0);
475        let short = make_task("short", 1000, None, 0);
476        let long = make_task("long", 5000, None, 0);
477        assert!(Spt.evaluate(&short, &ctx) < Spt.evaluate(&long, &ctx));
478    }
479
480    #[test]
481    fn test_lpt() {
482        let ctx = SchedulingContext::at_time(0);
483        let short = make_task("short", 1000, None, 0);
484        let long = make_task("long", 5000, None, 0);
485        assert!(Lpt.evaluate(&long, &ctx) < Lpt.evaluate(&short, &ctx));
486    }
487
488    #[test]
489    fn test_lwkr_with_context() {
490        let ctx = SchedulingContext::at_time(0)
491            .with_remaining_work("almost_done", 100)
492            .with_remaining_work("lots_left", 5000);
493
494        let t1 = make_task("almost_done", 10000, None, 0);
495        let t2 = make_task("lots_left", 10000, None, 0);
496        assert!(Lwkr.evaluate(&t1, &ctx) < Lwkr.evaluate(&t2, &ctx));
497    }
498
499    #[test]
500    fn test_lwkr_fallback() {
501        let ctx = SchedulingContext::at_time(0); // No remaining_work data
502        let t1 = make_task("short", 1000, None, 0);
503        let t2 = make_task("long", 5000, None, 0);
504        assert!(Lwkr.evaluate(&t1, &ctx) < Lwkr.evaluate(&t2, &ctx));
505    }
506
507    #[test]
508    fn test_mwkr() {
509        let ctx = SchedulingContext::at_time(0)
510            .with_remaining_work("a", 100)
511            .with_remaining_work("b", 5000);
512        let t1 = make_task("a", 10000, None, 0);
513        let t2 = make_task("b", 10000, None, 0);
514        assert!(Mwkr.evaluate(&t2, &ctx) < Mwkr.evaluate(&t1, &ctx));
515    }
516
517    #[test]
518    fn test_wspt() {
519        let ctx = SchedulingContext::at_time(0);
520        // High priority + short duration → highest WSPT
521        let important_short = make_task("is", 1000, None, 1);
522        // Low priority + long duration → lowest WSPT
523        let unimportant_long = make_task("ul", 5000, None, 10);
524        assert!(Wspt.evaluate(&important_short, &ctx) < Wspt.evaluate(&unimportant_long, &ctx));
525    }
526
527    #[test]
528    fn test_edd() {
529        let ctx = SchedulingContext::at_time(0);
530        let early = make_task("early", 1000, Some(10_000), 0);
531        let late = make_task("late", 1000, Some(50_000), 0);
532        let none = make_task("none", 1000, None, 0);
533        assert!(Edd.evaluate(&early, &ctx) < Edd.evaluate(&late, &ctx));
534        assert!(Edd.evaluate(&late, &ctx) < Edd.evaluate(&none, &ctx));
535    }
536
537    #[test]
538    fn test_mst() {
539        let ctx = SchedulingContext::at_time(1000);
540        // Deadline 5000, remaining 3000 → slack = (5000-1000)-3000 = 1000
541        let urgent = make_task("urgent", 3000, Some(5000), 0);
542        // Deadline 50000, remaining 3000 → slack = (50000-1000)-3000 = 46000
543        let relaxed = make_task("relaxed", 3000, Some(50000), 0);
544        assert!(Mst.evaluate(&urgent, &ctx) < Mst.evaluate(&relaxed, &ctx));
545    }
546
547    #[test]
548    fn test_cr() {
549        let ctx = SchedulingContext::at_time(1000);
550        // Deadline 4000, remaining 3000 → CR = (4000-1000)/3000 = 1.0
551        let on_track = make_task("on_track", 3000, Some(4000), 0);
552        // Deadline 10000, remaining 3000 → CR = (10000-1000)/3000 = 3.0
553        let ahead = make_task("ahead", 3000, Some(10000), 0);
554        assert!(Cr.evaluate(&on_track, &ctx) < Cr.evaluate(&ahead, &ctx));
555    }
556
557    #[test]
558    fn test_cr_behind_schedule() {
559        let ctx = SchedulingContext::at_time(5000);
560        // Deadline 4000 at t=5000 → CR = (4000-5000)/3000 = -0.33 (negative = behind)
561        let behind = make_task("behind", 3000, Some(4000), 0);
562        let normal = make_task("normal", 3000, Some(20000), 0);
563        assert!(Cr.evaluate(&behind, &ctx) < Cr.evaluate(&normal, &ctx));
564    }
565
566    #[test]
567    fn test_sro() {
568        let ctx = SchedulingContext::at_time(0);
569        let few_ops = make_task("few", 1000, Some(5000), 0); // 1 op, slack=4000, S/RO=4000
570                                                             // Task with 3 activities
571        let mut many_ops = Task::new("many").with_priority(0);
572        many_ops.deadline = Some(5000);
573        for i in 0..3 {
574            many_ops.activities.push(
575                Activity::new(format!("many_O{i}"), "many", i)
576                    .with_duration(ActivityDuration::fixed(333)),
577            );
578        }
579        // slack = 5000-0-999 = 4001, S/RO = 4001/3 ≈ 1333
580        assert!(Sro.evaluate(&many_ops, &ctx) < Sro.evaluate(&few_ops, &ctx));
581    }
582
583    #[test]
584    fn test_atc() {
585        let ctx = SchedulingContext::at_time(0).with_average_processing_time(2000.0);
586        let atc = Atc::default();
587        let urgent = make_task("urgent", 1000, Some(2000), 0); // Tight deadline
588        let relaxed = make_task("relaxed", 1000, Some(100_000), 0); // Loose deadline
589                                                                    // Urgent has higher ATC score → lower (more negative) return
590        assert!(atc.evaluate(&urgent, &ctx) < atc.evaluate(&relaxed, &ctx));
591    }
592
593    #[test]
594    fn test_atc_no_deadline() {
595        let ctx = SchedulingContext::at_time(0);
596        let atc = Atc::default();
597        let no_dl = make_task("no_dl", 1000, None, 0);
598        // Falls back to WSPT
599        assert!(atc.evaluate(&no_dl, &ctx).is_finite());
600    }
601
602    #[test]
603    fn test_fifo() {
604        let ctx = SchedulingContext::at_time(5000)
605            .with_arrival_time("first", 1000)
606            .with_arrival_time("second", 3000);
607        let t1 = make_task("first", 2000, None, 0);
608        let t2 = make_task("second", 2000, None, 0);
609        assert!(Fifo.evaluate(&t1, &ctx) < Fifo.evaluate(&t2, &ctx));
610    }
611
612    #[test]
613    fn test_fifo_fallback() {
614        let ctx = SchedulingContext::at_time(0);
615        let mut t1 = make_task("t1", 1000, None, 0);
616        t1.release_time = Some(500);
617        let mut t2 = make_task("t2", 1000, None, 0);
618        t2.release_time = Some(1000);
619        assert!(Fifo.evaluate(&t1, &ctx) < Fifo.evaluate(&t2, &ctx));
620    }
621
622    #[test]
623    fn test_winq() {
624        let ctx = SchedulingContext::at_time(0)
625            .with_next_queue("short_q", 2)
626            .with_next_queue("long_q", 10);
627        let t1 = make_task("short_q", 1000, None, 0);
628        let t2 = make_task("long_q", 1000, None, 0);
629        assert!(Winq.evaluate(&t1, &ctx) < Winq.evaluate(&t2, &ctx));
630    }
631
632    #[test]
633    fn test_lpul() {
634        let ctx = SchedulingContext::at_time(0)
635            .with_utilization("M1", 0.3)
636            .with_utilization("M2", 0.9);
637
638        let t1 = Task::new("t1").with_activity(
639            Activity::new("t1_O1", "t1", 0)
640                .with_process_time(1000)
641                .with_requirement(
642                    ResourceRequirement::new("Machine").with_candidates(vec!["M1".into()]),
643                ),
644        );
645        let t2 = Task::new("t2").with_activity(
646            Activity::new("t2_O1", "t2", 0)
647                .with_process_time(1000)
648                .with_requirement(
649                    ResourceRequirement::new("Machine").with_candidates(vec!["M2".into()]),
650                ),
651        );
652
653        assert!(Lpul.evaluate(&t1, &ctx) < Lpul.evaluate(&t2, &ctx));
654    }
655
656    #[test]
657    fn test_priority() {
658        let ctx = SchedulingContext::at_time(0);
659        let high = make_task("high", 1000, None, 100);
660        let low = make_task("low", 1000, None, 1);
661        assert!(Priority.evaluate(&high, &ctx) < Priority.evaluate(&low, &ctx));
662    }
663}