1use std::collections::HashMap;
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10pub enum JobStatus {
11 Pending,
13 Running,
15 Completed,
17 Expired,
19 Cancelled,
21}
22
23#[derive(Clone, Debug)]
25pub struct InferenceJob {
26 pub job_id: u64,
28 pub goal: String,
30 pub priority: u32,
32 pub deadline_tick: Option<u64>,
34 pub estimated_cost_ms: u64,
36 pub status: JobStatus,
38 pub submitted_at_tick: u64,
40 pub started_at_tick: Option<u64>,
42 pub completed_at_tick: Option<u64>,
44}
45
46impl InferenceJob {
47 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 pub fn is_terminal(&self) -> bool {
57 matches!(
58 self.status,
59 JobStatus::Completed | JobStatus::Expired | JobStatus::Cancelled
60 )
61 }
62}
63
64#[derive(Clone, Debug)]
66pub struct SchedulerConfig {
67 pub max_concurrent: usize,
69 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#[derive(Clone, Debug, Default)]
84pub struct SchedulerStats {
85 pub total_submitted: u64,
88 pub total_completed: u64,
90 pub total_expired: u64,
92 pub total_cancelled: u64,
94 pub queue_depth: usize,
96 pub running_count: usize,
98}
99
100impl SchedulerStats {
101 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
114pub struct TensorInferenceScheduler {
120 pub jobs: HashMap<u64, InferenceJob>,
122 pub next_job_id: u64,
124 pub config: SchedulerConfig,
126 pub stats: SchedulerStats,
128}
129
130impl TensorInferenceScheduler {
131 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 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 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 pub fn tick(&mut self, current_tick: u64) {
190 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 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 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 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 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 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 pub fn queue_depth(&self) -> usize {
294 self.jobs
295 .values()
296 .filter(|j| j.status == JobStatus::Pending)
297 .count()
298 }
299
300 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 pub fn job(&self, job_id: u64) -> Option<&InferenceJob> {
313 self.jobs.get(&job_id)
314 }
315
316 pub fn stats(&self) -> &SchedulerStats {
318 &self.stats
319 }
320}
321
322#[cfg(test)]
327mod tests {
328 use super::*;
329
330 fn default_scheduler() -> TensorInferenceScheduler {
331 TensorInferenceScheduler::new(SchedulerConfig::default())
332 }
333
334 #[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 #[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 #[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 assert!(sched.submit("goal3", 1, None, 10, 0).is_none());
372 }
373
374 #[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 #[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 #[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 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 #[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 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 #[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); assert_eq!(
462 sched.job(id).expect("test: should succeed").status,
463 JobStatus::Running
464 );
465 sched.tick(6); assert_eq!(
467 sched.job(id).expect("test: should succeed").status,
468 JobStatus::Expired
469 );
470 }
471
472 #[test]
474 fn test_tick_expires_pending_jobs_past_deadline() {
475 let config = SchedulerConfig {
476 max_concurrent: 0, 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); assert_eq!(
485 sched.job(id).expect("test: should succeed").status,
486 JobStatus::Expired
487 );
488 }
489
490 #[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); assert_eq!(
499 sched.job(id).expect("test: should succeed").status,
500 JobStatus::Expired
501 );
502 }
503
504 #[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 #[test]
528 fn test_complete_false_for_unknown_job() {
529 let mut sched = default_scheduler();
530 assert!(!sched.complete(9999, 1));
531 }
532
533 #[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 assert!(!sched.complete(id, 1));
542 }
543
544 #[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 #[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 #[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)); }
589
590 #[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)); }
602
603 #[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 #[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); assert_eq!(sched.queue_depth(), 0);
635 }
636
637 #[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); 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 let mut expected = ids.clone();
649 expected.sort();
650 assert_eq!(ids, expected);
651 }
652
653 #[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 let rate = sched.stats().completion_rate();
668 assert!((rate - 0.5).abs() < f64::EPSILON);
669 }
670
671 #[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 #[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 #[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); assert_eq!(sched.stats.queue_depth, 0);
698 }
699
700 #[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}