Skip to main content

ipfrs_tensorlogic/
inference_scheduler.rs

1//! TensorInferenceScheduler — deadline-aware priority scheduling for TensorLogic inference jobs.
2//!
3//! Schedules inference jobs across available compute slots with priority queuing,
4//! resource budgets, and deadline enforcement.
5
6use std::collections::HashMap;
7
8/// Status of an inference job in the scheduler lifecycle.
9#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10pub enum JobStatus {
11    /// Queued but not yet started.
12    Pending,
13    /// Currently executing on a compute slot.
14    Running,
15    /// Finished successfully.
16    Completed,
17    /// Deadline passed before the job could complete.
18    Expired,
19    /// Explicitly cancelled by the caller.
20    Cancelled,
21}
22
23/// A single inference job managed by the scheduler.
24#[derive(Clone, Debug)]
25pub struct InferenceJob {
26    /// Unique identifier assigned at submission time.
27    pub job_id: u64,
28    /// The inference goal string.
29    pub goal: String,
30    /// Higher values are scheduled first.
31    pub priority: u32,
32    /// Optional logical tick after which the job is considered expired.
33    pub deadline_tick: Option<u64>,
34    /// Estimated execution time in milliseconds (advisory).
35    pub estimated_cost_ms: u64,
36    /// Current lifecycle status.
37    pub status: JobStatus,
38    /// Logical tick at which the job was submitted.
39    pub submitted_at_tick: u64,
40    /// Logical tick at which execution began, if started.
41    pub started_at_tick: Option<u64>,
42    /// Logical tick at which execution finished, if completed.
43    pub completed_at_tick: Option<u64>,
44}
45
46impl InferenceJob {
47    /// Returns the end-to-end latency in ticks if the job has completed.
48    pub fn latency_ticks(&self) -> Option<u64> {
49        match (self.status, self.completed_at_tick) {
50            (JobStatus::Completed, Some(done)) => Some(done - self.submitted_at_tick),
51            _ => None,
52        }
53    }
54
55    /// Returns `true` if the job is in a terminal state (no further transitions possible).
56    pub fn is_terminal(&self) -> bool {
57        matches!(
58            self.status,
59            JobStatus::Completed | JobStatus::Expired | JobStatus::Cancelled
60        )
61    }
62}
63
64/// Configuration for [`TensorInferenceScheduler`].
65#[derive(Clone, Debug)]
66pub struct SchedulerConfig {
67    /// Maximum number of jobs that may be Running simultaneously.
68    pub max_concurrent: usize,
69    /// Maximum number of jobs that may be Pending at one time.
70    pub max_queue_size: usize,
71}
72
73impl Default for SchedulerConfig {
74    fn default() -> Self {
75        Self {
76            max_concurrent: 4,
77            max_queue_size: 256,
78        }
79    }
80}
81
82/// Cumulative statistics maintained by the scheduler.
83#[derive(Clone, Debug, Default)]
84pub struct SchedulerStats {
85    /// Total jobs ever submitted (including those that were rejected — note: rejected jobs
86    /// are *not* counted; only accepted submissions increment this).
87    pub total_submitted: u64,
88    /// Total jobs that reached [`JobStatus::Completed`].
89    pub total_completed: u64,
90    /// Total jobs that reached [`JobStatus::Expired`].
91    pub total_expired: u64,
92    /// Total jobs that reached [`JobStatus::Cancelled`].
93    pub total_cancelled: u64,
94    /// Current number of Pending jobs.
95    pub queue_depth: usize,
96    /// Current number of Running jobs.
97    pub running_count: usize,
98}
99
100impl SchedulerStats {
101    /// Fraction of terminal jobs that completed successfully.
102    ///
103    /// Returns `0.0` when no jobs have reached a terminal state.
104    pub fn completion_rate(&self) -> f64 {
105        let denominator = self.total_completed + self.total_expired + self.total_cancelled;
106        if denominator == 0 {
107            0.0
108        } else {
109            self.total_completed as f64 / denominator as f64
110        }
111    }
112}
113
114/// Deadline-aware priority scheduler for TensorLogic inference jobs.
115///
116/// Uses a logical tick counter (driven by the caller) for deadline evaluation
117/// and ordering. All operations are synchronous and single-threaded; wrap with
118/// a mutex for multi-threaded use.
119pub struct TensorInferenceScheduler {
120    /// All known jobs, keyed by job_id.
121    pub jobs: HashMap<u64, InferenceJob>,
122    /// Monotonically increasing counter used to assign job IDs.
123    pub next_job_id: u64,
124    /// Immutable configuration.
125    pub config: SchedulerConfig,
126    /// Incrementally maintained statistics.
127    pub stats: SchedulerStats,
128}
129
130impl TensorInferenceScheduler {
131    /// Creates a new scheduler with the given configuration.
132    pub fn new(config: SchedulerConfig) -> Self {
133        Self {
134            jobs: HashMap::new(),
135            next_job_id: 0,
136            config,
137            stats: SchedulerStats::default(),
138        }
139    }
140
141    /// Submits a new inference job.
142    ///
143    /// Returns `Some(job_id)` on success, or `None` if the pending queue is full.
144    pub fn submit(
145        &mut self,
146        goal: &str,
147        priority: u32,
148        deadline_tick: Option<u64>,
149        estimated_cost_ms: u64,
150        tick: u64,
151    ) -> Option<u64> {
152        // Reject if pending queue is at capacity.
153        let pending_count = self
154            .jobs
155            .values()
156            .filter(|j| j.status == JobStatus::Pending)
157            .count();
158        if pending_count >= self.config.max_queue_size {
159            return None;
160        }
161
162        let job_id = self.next_job_id;
163        self.next_job_id += 1;
164
165        let job = InferenceJob {
166            job_id,
167            goal: goal.to_string(),
168            priority,
169            deadline_tick,
170            estimated_cost_ms,
171            status: JobStatus::Pending,
172            submitted_at_tick: tick,
173            started_at_tick: None,
174            completed_at_tick: None,
175        };
176
177        self.jobs.insert(job_id, job);
178        self.stats.total_submitted += 1;
179        self.stats.queue_depth += 1;
180
181        Some(job_id)
182    }
183
184    /// Advances the scheduler to `current_tick`.
185    ///
186    /// 1. Expires any Running or Pending jobs whose deadline has passed.
187    /// 2. Promotes Pending jobs (highest priority first, ties broken by job_id ascending)
188    ///    into Running state until `max_concurrent` slots are filled.
189    pub fn tick(&mut self, current_tick: u64) {
190        // --- Phase 1: expire overdue jobs ---
191        let mut expired_ids: Vec<u64> = Vec::new();
192        for job in self.jobs.values() {
193            if let Some(dl) = job.deadline_tick {
194                if dl < current_tick
195                    && (job.status == JobStatus::Running || job.status == JobStatus::Pending)
196                {
197                    expired_ids.push(job.job_id);
198                }
199            }
200        }
201        for id in expired_ids {
202            if let Some(job) = self.jobs.get_mut(&id) {
203                let was_pending = job.status == JobStatus::Pending;
204                let was_running = job.status == JobStatus::Running;
205                job.status = JobStatus::Expired;
206                self.stats.total_expired += 1;
207                if was_pending {
208                    self.stats.queue_depth = self.stats.queue_depth.saturating_sub(1);
209                }
210                if was_running {
211                    self.stats.running_count = self.stats.running_count.saturating_sub(1);
212                }
213            }
214        }
215
216        // --- Phase 2: promote pending jobs into running slots ---
217        let running_count = self
218            .jobs
219            .values()
220            .filter(|j| j.status == JobStatus::Running)
221            .count();
222        let available_slots = self.config.max_concurrent.saturating_sub(running_count);
223        if available_slots == 0 {
224            return;
225        }
226
227        // Collect candidates: Pending jobs sorted by (priority DESC, job_id ASC).
228        let mut candidates: Vec<u64> = self
229            .jobs
230            .values()
231            .filter(|j| j.status == JobStatus::Pending)
232            .map(|j| j.job_id)
233            .collect();
234
235        candidates.sort_by(|&a, &b| {
236            let ja = &self.jobs[&a];
237            let jb = &self.jobs[&b];
238            // Higher priority first; equal priority → lower job_id first.
239            jb.priority
240                .cmp(&ja.priority)
241                .then_with(|| ja.job_id.cmp(&jb.job_id))
242        });
243
244        for id in candidates.into_iter().take(available_slots) {
245            if let Some(job) = self.jobs.get_mut(&id) {
246                job.status = JobStatus::Running;
247                job.started_at_tick = Some(current_tick);
248                self.stats.queue_depth = self.stats.queue_depth.saturating_sub(1);
249                self.stats.running_count += 1;
250            }
251        }
252    }
253
254    /// Marks a Running job as Completed.
255    ///
256    /// Returns `false` if the job is not found or is not currently Running.
257    pub fn complete(&mut self, job_id: u64, tick: u64) -> bool {
258        match self.jobs.get_mut(&job_id) {
259            Some(job) if job.status == JobStatus::Running => {
260                job.status = JobStatus::Completed;
261                job.completed_at_tick = Some(tick);
262                self.stats.total_completed += 1;
263                self.stats.running_count = self.stats.running_count.saturating_sub(1);
264                true
265            }
266            _ => false,
267        }
268    }
269
270    /// Cancels a Pending or Running job.
271    ///
272    /// Returns `false` if the job is not found or is already in a terminal state.
273    pub fn cancel(&mut self, job_id: u64) -> bool {
274        match self.jobs.get_mut(&job_id) {
275            Some(job) if !job.is_terminal() => {
276                let was_pending = job.status == JobStatus::Pending;
277                let was_running = job.status == JobStatus::Running;
278                job.status = JobStatus::Cancelled;
279                self.stats.total_cancelled += 1;
280                if was_pending {
281                    self.stats.queue_depth = self.stats.queue_depth.saturating_sub(1);
282                }
283                if was_running {
284                    self.stats.running_count = self.stats.running_count.saturating_sub(1);
285                }
286                true
287            }
288            _ => false,
289        }
290    }
291
292    /// Returns the number of jobs currently in Pending state.
293    pub fn queue_depth(&self) -> usize {
294        self.jobs
295            .values()
296            .filter(|j| j.status == JobStatus::Pending)
297            .count()
298    }
299
300    /// Returns all currently Running jobs, sorted ascending by job_id.
301    pub fn running_jobs(&self) -> Vec<&InferenceJob> {
302        let mut running: Vec<&InferenceJob> = self
303            .jobs
304            .values()
305            .filter(|j| j.status == JobStatus::Running)
306            .collect();
307        running.sort_by_key(|j| j.job_id);
308        running
309    }
310
311    /// Looks up a job by ID.
312    pub fn job(&self, job_id: u64) -> Option<&InferenceJob> {
313        self.jobs.get(&job_id)
314    }
315
316    /// Returns a reference to the current scheduler statistics.
317    pub fn stats(&self) -> &SchedulerStats {
318        &self.stats
319    }
320}
321
322// ─────────────────────────────────────────────────────────────────────────────
323// Tests
324// ─────────────────────────────────────────────────────────────────────────────
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    fn default_scheduler() -> TensorInferenceScheduler {
331        TensorInferenceScheduler::new(SchedulerConfig::default())
332    }
333
334    // 1. new() starts empty
335    #[test]
336    fn test_new_starts_empty() {
337        let sched = default_scheduler();
338        assert!(sched.jobs.is_empty());
339        assert_eq!(sched.next_job_id, 0);
340        assert_eq!(sched.queue_depth(), 0);
341        assert!(sched.running_jobs().is_empty());
342    }
343
344    // 2. submit creates Pending job
345    #[test]
346    fn test_submit_creates_pending_job() {
347        let mut sched = default_scheduler();
348        let id = sched
349            .submit("parent(X,Y)", 10, None, 50, 0)
350            .expect("test: should succeed");
351        let job = sched.job(id).expect("test: should succeed");
352        assert_eq!(job.status, JobStatus::Pending);
353        assert_eq!(job.goal, "parent(X,Y)");
354        assert_eq!(job.priority, 10);
355        assert_eq!(job.submitted_at_tick, 0);
356        assert!(job.started_at_tick.is_none());
357        assert!(job.completed_at_tick.is_none());
358    }
359
360    // 3. submit returns None when queue full
361    #[test]
362    fn test_submit_returns_none_when_queue_full() {
363        let config = SchedulerConfig {
364            max_concurrent: 4,
365            max_queue_size: 2,
366        };
367        let mut sched = TensorInferenceScheduler::new(config);
368        assert!(sched.submit("goal1", 1, None, 10, 0).is_some());
369        assert!(sched.submit("goal2", 1, None, 10, 0).is_some());
370        // Third submission should be rejected
371        assert!(sched.submit("goal3", 1, None, 10, 0).is_none());
372    }
373
374    // 4. submit increments total_submitted
375    #[test]
376    fn test_submit_increments_total_submitted() {
377        let mut sched = default_scheduler();
378        sched.submit("g1", 1, None, 10, 0);
379        assert_eq!(sched.stats().total_submitted, 1);
380        sched.submit("g2", 1, None, 10, 0);
381        assert_eq!(sched.stats().total_submitted, 2);
382    }
383
384    // 5. tick starts Pending jobs up to max_concurrent
385    #[test]
386    fn test_tick_starts_pending_up_to_max_concurrent() {
387        let config = SchedulerConfig {
388            max_concurrent: 2,
389            max_queue_size: 256,
390        };
391        let mut sched = TensorInferenceScheduler::new(config);
392        sched.submit("g1", 1, None, 10, 0);
393        sched.submit("g2", 1, None, 10, 0);
394        sched.submit("g3", 1, None, 10, 0);
395        sched.tick(1);
396        let running = sched.running_jobs();
397        assert_eq!(running.len(), 2);
398        assert_eq!(sched.queue_depth(), 1);
399    }
400
401    // 6. tick respects priority order
402    #[test]
403    fn test_tick_respects_priority_order() {
404        let config = SchedulerConfig {
405            max_concurrent: 1,
406            max_queue_size: 256,
407        };
408        let mut sched = TensorInferenceScheduler::new(config);
409        let low_id = sched
410            .submit("low", 1, None, 10, 0)
411            .expect("test: should succeed");
412        let high_id = sched
413            .submit("high", 100, None, 10, 0)
414            .expect("test: should succeed");
415        sched.tick(1);
416        // High priority job should be running
417        assert_eq!(
418            sched.job(high_id).expect("test: should succeed").status,
419            JobStatus::Running
420        );
421        assert_eq!(
422            sched.job(low_id).expect("test: should succeed").status,
423            JobStatus::Pending
424        );
425    }
426
427    // 7. tick breaks ties by job_id ascending
428    #[test]
429    fn test_tick_breaks_ties_by_job_id_ascending() {
430        let config = SchedulerConfig {
431            max_concurrent: 1,
432            max_queue_size: 256,
433        };
434        let mut sched = TensorInferenceScheduler::new(config);
435        let first_id = sched
436            .submit("first", 5, None, 10, 0)
437            .expect("test: should succeed");
438        let second_id = sched
439            .submit("second", 5, None, 10, 0)
440            .expect("test: should succeed");
441        sched.tick(1);
442        // Lower job_id should win on tie
443        assert_eq!(
444            sched.job(first_id).expect("test: should succeed").status,
445            JobStatus::Running
446        );
447        assert_eq!(
448            sched.job(second_id).expect("test: should succeed").status,
449            JobStatus::Pending
450        );
451    }
452
453    // 8. tick expires Running jobs past deadline
454    #[test]
455    fn test_tick_expires_running_jobs_past_deadline() {
456        let mut sched = default_scheduler();
457        let id = sched
458            .submit("goal", 1, Some(5), 10, 0)
459            .expect("test: should succeed");
460        sched.tick(1); // starts the job
461        assert_eq!(
462            sched.job(id).expect("test: should succeed").status,
463            JobStatus::Running
464        );
465        sched.tick(6); // deadline 5 < current_tick 6 → expire
466        assert_eq!(
467            sched.job(id).expect("test: should succeed").status,
468            JobStatus::Expired
469        );
470    }
471
472    // 9. tick expires Pending jobs past deadline
473    #[test]
474    fn test_tick_expires_pending_jobs_past_deadline() {
475        let config = SchedulerConfig {
476            max_concurrent: 0, // no slots → jobs stay Pending
477            max_queue_size: 256,
478        };
479        let mut sched = TensorInferenceScheduler::new(config);
480        let id = sched
481            .submit("goal", 1, Some(3), 10, 0)
482            .expect("test: should succeed");
483        sched.tick(4); // deadline 3 < 4 → expire
484        assert_eq!(
485            sched.job(id).expect("test: should succeed").status,
486            JobStatus::Expired
487        );
488    }
489
490    // 10. tick does not start expired jobs
491    #[test]
492    fn test_tick_does_not_start_expired_jobs() {
493        let mut sched = default_scheduler();
494        let id = sched
495            .submit("goal", 1, Some(2), 10, 0)
496            .expect("test: should succeed");
497        sched.tick(3); // expire and attempt to start in same tick
498        assert_eq!(
499            sched.job(id).expect("test: should succeed").status,
500            JobStatus::Expired
501        );
502    }
503
504    // 11. complete sets Completed
505    #[test]
506    fn test_complete_sets_completed() {
507        let mut sched = default_scheduler();
508        let id = sched
509            .submit("goal", 1, None, 10, 0)
510            .expect("test: should succeed");
511        sched.tick(1);
512        assert!(sched.complete(id, 5));
513        assert_eq!(
514            sched.job(id).expect("test: should succeed").status,
515            JobStatus::Completed
516        );
517        assert_eq!(
518            sched
519                .job(id)
520                .expect("test: should succeed")
521                .completed_at_tick,
522            Some(5)
523        );
524    }
525
526    // 12. complete returns false for unknown job
527    #[test]
528    fn test_complete_false_for_unknown_job() {
529        let mut sched = default_scheduler();
530        assert!(!sched.complete(9999, 1));
531    }
532
533    // 13. complete returns false for non-Running job
534    #[test]
535    fn test_complete_false_for_non_running_job() {
536        let mut sched = default_scheduler();
537        let id = sched
538            .submit("goal", 1, None, 10, 0)
539            .expect("test: should succeed");
540        // Job is still Pending — not started yet
541        assert!(!sched.complete(id, 1));
542    }
543
544    // 14. cancel Pending job
545    #[test]
546    fn test_cancel_pending_job() {
547        let mut sched = default_scheduler();
548        let id = sched
549            .submit("goal", 1, None, 10, 0)
550            .expect("test: should succeed");
551        assert!(sched.cancel(id));
552        assert_eq!(
553            sched.job(id).expect("test: should succeed").status,
554            JobStatus::Cancelled
555        );
556        assert_eq!(sched.stats().total_cancelled, 1);
557    }
558
559    // 15. cancel Running job
560    #[test]
561    fn test_cancel_running_job() {
562        let mut sched = default_scheduler();
563        let id = sched
564            .submit("goal", 1, None, 10, 0)
565            .expect("test: should succeed");
566        sched.tick(1);
567        assert_eq!(
568            sched.job(id).expect("test: should succeed").status,
569            JobStatus::Running
570        );
571        assert!(sched.cancel(id));
572        assert_eq!(
573            sched.job(id).expect("test: should succeed").status,
574            JobStatus::Cancelled
575        );
576    }
577
578    // 16. cancel returns false for terminal job
579    #[test]
580    fn test_cancel_false_for_terminal_job() {
581        let mut sched = default_scheduler();
582        let id = sched
583            .submit("goal", 1, None, 10, 0)
584            .expect("test: should succeed");
585        sched.tick(1);
586        sched.complete(id, 2);
587        assert!(!sched.cancel(id)); // already Completed
588    }
589
590    // 17. latency_ticks computed correctly
591    #[test]
592    fn test_latency_ticks_correct() {
593        let mut sched = default_scheduler();
594        let id = sched
595            .submit("goal", 1, None, 10, 0)
596            .expect("test: should succeed");
597        sched.tick(3);
598        sched.complete(id, 10);
599        let latency = sched.job(id).expect("test: should succeed").latency_ticks();
600        assert_eq!(latency, Some(10)); // 10 - 0 = 10
601    }
602
603    // 18. is_terminal for each terminal status
604    #[test]
605    fn test_is_terminal_for_each_status() {
606        let make_job = |status: JobStatus| InferenceJob {
607            job_id: 0,
608            goal: "g".to_string(),
609            priority: 0,
610            deadline_tick: None,
611            estimated_cost_ms: 0,
612            status,
613            submitted_at_tick: 0,
614            started_at_tick: None,
615            completed_at_tick: None,
616        };
617
618        assert!(!make_job(JobStatus::Pending).is_terminal());
619        assert!(!make_job(JobStatus::Running).is_terminal());
620        assert!(make_job(JobStatus::Completed).is_terminal());
621        assert!(make_job(JobStatus::Expired).is_terminal());
622        assert!(make_job(JobStatus::Cancelled).is_terminal());
623    }
624
625    // 19. queue_depth counts Pending only
626    #[test]
627    fn test_queue_depth_counts_pending_only() {
628        let mut sched = default_scheduler();
629        sched.submit("g1", 1, None, 10, 0);
630        sched.submit("g2", 1, None, 10, 0);
631        assert_eq!(sched.queue_depth(), 2);
632        sched.tick(1); // starts up to max_concurrent (4) — both start
633                       // Both should now be Running, queue_depth = 0
634        assert_eq!(sched.queue_depth(), 0);
635    }
636
637    // 20. running_jobs sorted by job_id
638    #[test]
639    fn test_running_jobs_sorted_by_job_id() {
640        let mut sched = default_scheduler();
641        sched.submit("g1", 5, None, 10, 0);
642        sched.submit("g2", 10, None, 10, 0); // higher priority → started first
643        sched.submit("g3", 1, None, 10, 0);
644        sched.tick(1);
645        let running = sched.running_jobs();
646        let ids: Vec<u64> = running.iter().map(|j| j.job_id).collect();
647        // Should be sorted ascending regardless of start order
648        let mut expected = ids.clone();
649        expected.sort();
650        assert_eq!(ids, expected);
651    }
652
653    // 21. stats completion_rate correct
654    #[test]
655    fn test_stats_completion_rate_correct() {
656        let mut sched = default_scheduler();
657        let id1 = sched
658            .submit("g1", 1, None, 10, 0)
659            .expect("test: should succeed");
660        let id2 = sched
661            .submit("g2", 1, None, 10, 0)
662            .expect("test: should succeed");
663        sched.tick(1);
664        sched.complete(id1, 2);
665        sched.cancel(id2);
666        // 1 completed, 0 expired, 1 cancelled → rate = 0.5
667        let rate = sched.stats().completion_rate();
668        assert!((rate - 0.5).abs() < f64::EPSILON);
669    }
670
671    // 22. stats total_expired increments
672    #[test]
673    fn test_stats_total_expired_increments() {
674        let mut sched = default_scheduler();
675        sched.submit("g1", 1, Some(2), 10, 0);
676        sched.submit("g2", 1, Some(2), 10, 0);
677        assert_eq!(sched.stats().total_expired, 0);
678        sched.tick(3);
679        assert_eq!(sched.stats().total_expired, 2);
680    }
681
682    // 23. completion_rate returns 0.0 when no terminal jobs
683    #[test]
684    fn test_completion_rate_zero_when_no_terminal() {
685        let sched = default_scheduler();
686        assert_eq!(sched.stats().completion_rate(), 0.0);
687    }
688
689    // 24. stats queue_depth tracks correctly through lifecycle
690    #[test]
691    fn test_stats_queue_depth_lifecycle() {
692        let mut sched = default_scheduler();
693        sched.submit("g1", 1, None, 10, 0);
694        sched.submit("g2", 1, None, 10, 0);
695        assert_eq!(sched.stats.queue_depth, 2);
696        sched.tick(1); // both start
697        assert_eq!(sched.stats.queue_depth, 0);
698    }
699
700    // 25. stats running_count tracks correctly
701    #[test]
702    fn test_stats_running_count_tracks() {
703        let mut sched = default_scheduler();
704        let id1 = sched
705            .submit("g1", 1, None, 10, 0)
706            .expect("test: should succeed");
707        sched.tick(1);
708        assert_eq!(sched.stats.running_count, 1);
709        sched.complete(id1, 2);
710        assert_eq!(sched.stats.running_count, 0);
711    }
712}