Skip to main content

ipfrs_tensorlogic/
op_dispatcher.rs

1//! TensorOpDispatcher — routes tensor operations to registered backends
2//! (CPU / GPU / Remote / Simulated) based on op type and backend availability,
3//! with priority-ordered fallback chains and per-backend statistics.
4
5use std::collections::HashMap;
6
7// ---------------------------------------------------------------------------
8// BackendKind
9// ---------------------------------------------------------------------------
10
11/// Identifies the kind (class) of a compute backend.
12#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
13pub enum BackendKind {
14    /// Host CPU backend.
15    Cpu,
16    /// GPU backend (CUDA / Vulkan / OpenCL / Metal).
17    Gpu,
18    /// Remote / distributed backend reached over a network.
19    Remote,
20    /// Simulated backend used in unit tests.
21    Simulated,
22}
23
24// ---------------------------------------------------------------------------
25// DispatchOp
26// ---------------------------------------------------------------------------
27
28/// A tensor operation that must be dispatched to a backend.
29#[derive(Clone, Debug, PartialEq)]
30pub enum DispatchOp {
31    /// General matrix multiplication: (m × k) × (k × n).
32    MatMul { m: usize, n: usize, k: usize },
33    /// Element-wise operation applied to a flat tensor.
34    ElementWise {
35        op_name: String,
36        element_count: usize,
37    },
38    /// Reduction over one or more dimensions.
39    Reduction { op_name: String, dims: Vec<usize> },
40    /// Convolution with a given kernel size and channel count.
41    Convolution { kernel_size: usize, channels: usize },
42}
43
44// ---------------------------------------------------------------------------
45// BackendRegistration
46// ---------------------------------------------------------------------------
47
48/// Describes a single registered backend and the operations it supports.
49#[derive(Clone, Debug)]
50pub struct BackendRegistration {
51    /// What kind of backend this is.
52    pub kind: BackendKind,
53    /// Higher priority values are preferred over lower ones.
54    pub priority: u32,
55    /// Canonical op names this backend can handle (used for ElementWise,
56    /// Reduction, and the "matmul" / "conv" capability checks for Gpu/Remote).
57    pub supported_ops: Vec<String>,
58    /// Whether the backend is currently reachable / healthy.
59    pub is_available: bool,
60}
61
62impl BackendRegistration {
63    /// Returns `true` when this backend is capable of executing `op`.
64    ///
65    /// Matching rules:
66    /// - [`DispatchOp::MatMul`]: always supported by `Cpu` and `Simulated`;
67    ///   `Gpu` and `Remote` must list `"matmul"` in `supported_ops`.
68    /// - [`DispatchOp::ElementWise`]: `supported_ops` must contain `op_name`.
69    /// - [`DispatchOp::Reduction`]: `supported_ops` must contain `op_name`.
70    /// - [`DispatchOp::Convolution`]: `supported_ops` must contain `"conv"`.
71    pub fn supports_op(&self, op: &DispatchOp) -> bool {
72        match op {
73            DispatchOp::MatMul { .. } => match self.kind {
74                BackendKind::Cpu | BackendKind::Simulated => true,
75                BackendKind::Gpu | BackendKind::Remote => {
76                    self.supported_ops.iter().any(|s| s == "matmul")
77                }
78            },
79            DispatchOp::ElementWise { op_name, .. } => {
80                self.supported_ops.iter().any(|s| s == op_name)
81            }
82            DispatchOp::Reduction { op_name, .. } => {
83                self.supported_ops.iter().any(|s| s == op_name)
84            }
85            DispatchOp::Convolution { .. } => self.supported_ops.iter().any(|s| s == "conv"),
86        }
87    }
88}
89
90// ---------------------------------------------------------------------------
91// DispatchResult
92// ---------------------------------------------------------------------------
93
94/// The outcome of a successful dispatch attempt.
95#[derive(Clone, Debug, PartialEq)]
96pub struct DispatchResult {
97    /// The operation that was dispatched.
98    pub op: DispatchOp,
99    /// The backend that was ultimately selected to handle the operation.
100    pub selected_backend: BackendKind,
101    /// `true` when the highest-priority capable backend was unavailable and a
102    /// lower-priority backend was chosen instead.
103    pub fallback_used: bool,
104}
105
106// ---------------------------------------------------------------------------
107// BackendStats
108// ---------------------------------------------------------------------------
109
110/// Per-backend accounting data.
111#[derive(Clone, Debug)]
112pub struct BackendStats {
113    /// Which backend these statistics belong to.
114    pub backend: BackendKind,
115    /// Total number of operations dispatched *to* this backend.
116    pub total_dispatched: u64,
117    /// Number of times this backend was selected as a *fallback* (i.e., at
118    /// least one higher-priority capable backend was unavailable first).
119    pub total_fallback_selected: u64,
120}
121
122impl BackendStats {
123    fn new(backend: BackendKind) -> Self {
124        Self {
125            backend,
126            total_dispatched: 0,
127            total_fallback_selected: 0,
128        }
129    }
130}
131
132// ---------------------------------------------------------------------------
133// DispatcherStats
134// ---------------------------------------------------------------------------
135
136/// Aggregate statistics for the [`TensorOpDispatcher`].
137#[derive(Clone, Debug, Default)]
138pub struct DispatcherStats {
139    /// Total operations successfully dispatched.
140    pub total_dispatched: u64,
141    /// Total operations dispatched via a fallback backend.
142    pub total_fallbacks: u64,
143    /// Total operations where *no* capable backend was found.
144    pub total_failed: u64,
145}
146
147// ---------------------------------------------------------------------------
148// TensorOpDispatcher
149// ---------------------------------------------------------------------------
150
151/// Routes [`DispatchOp`] instances to the best available registered backend,
152/// maintaining per-backend and aggregate statistics.
153///
154/// # Priority
155///
156/// Backends are stored in descending priority order.  `dispatch` always picks
157/// the first *available* backend that `supports_op` returns `true` for.
158///
159/// # Fallback
160///
161/// `fallback_used` is set to `true` in the [`DispatchResult`] when at least
162/// one backend that *could* handle the op (i.e., `supports_op` → `true`) was
163/// skipped because `is_available` was `false`.
164pub struct TensorOpDispatcher {
165    /// Backends ordered by priority (highest first).
166    pub backends: Vec<BackendRegistration>,
167    /// Per-backend statistics keyed by [`BackendKind`].
168    pub backend_stats: HashMap<BackendKind, BackendStats>,
169    /// Aggregate dispatcher statistics.
170    pub dispatcher_stats: DispatcherStats,
171}
172
173impl TensorOpDispatcher {
174    /// Creates an empty dispatcher with no registered backends.
175    pub fn new() -> Self {
176        Self {
177            backends: Vec::new(),
178            backend_stats: HashMap::new(),
179            dispatcher_stats: DispatcherStats::default(),
180        }
181    }
182
183    /// Registers a backend and re-sorts the list so highest-priority backends
184    /// come first.  Initialises a [`BackendStats`] entry if one does not already
185    /// exist.
186    pub fn register_backend(&mut self, registration: BackendRegistration) {
187        self.backend_stats
188            .entry(registration.kind)
189            .or_insert_with(|| BackendStats::new(registration.kind));
190        self.backends.push(registration);
191        // Maintain descending priority order.
192        self.backends.sort_by_key(|b| std::cmp::Reverse(b.priority));
193    }
194
195    /// Dispatches `op` to the highest-priority available backend that supports
196    /// it.
197    ///
198    /// Returns `None` and increments `total_failed` when no capable backend is
199    /// available.
200    pub fn dispatch(&mut self, op: DispatchOp) -> Option<DispatchResult> {
201        // Determine whether any capable-but-unavailable backend precedes the
202        // selected one (fallback detection) in a single linear pass.
203        let mut found_unavailable_capable = false;
204        let mut selected: Option<BackendKind> = None;
205
206        for backend in &self.backends {
207            if !backend.supports_op(&op) {
208                continue;
209            }
210            if !backend.is_available {
211                // This backend could handle the op but is currently down.
212                found_unavailable_capable = true;
213                continue;
214            }
215            // First available capable backend.
216            selected = Some(backend.kind);
217            break;
218        }
219
220        match selected {
221            None => {
222                self.dispatcher_stats.total_failed += 1;
223                None
224            }
225            Some(kind) => {
226                let fallback_used = found_unavailable_capable;
227
228                // Update dispatcher-level stats.
229                self.dispatcher_stats.total_dispatched += 1;
230                if fallback_used {
231                    self.dispatcher_stats.total_fallbacks += 1;
232                }
233
234                // Update per-backend stats.
235                if let Some(stats) = self.backend_stats.get_mut(&kind) {
236                    stats.total_dispatched += 1;
237                    if fallback_used {
238                        stats.total_fallback_selected += 1;
239                    }
240                }
241
242                Some(DispatchResult {
243                    op,
244                    selected_backend: kind,
245                    fallback_used,
246                })
247            }
248        }
249    }
250
251    /// Toggles the availability flag of a backend identified by `kind`.
252    /// Has no effect if `kind` is not registered.
253    pub fn set_backend_available(&mut self, kind: BackendKind, available: bool) {
254        for backend in &mut self.backends {
255            if backend.kind == kind {
256                backend.is_available = available;
257            }
258        }
259    }
260
261    /// Returns a reference to the aggregate [`DispatcherStats`].
262    pub fn stats(&self) -> &DispatcherStats {
263        &self.dispatcher_stats
264    }
265
266    /// Returns per-backend statistics for `kind`, or `None` if that backend
267    /// has never been registered.
268    pub fn backend_stats(&self, kind: BackendKind) -> Option<&BackendStats> {
269        self.backend_stats.get(&kind)
270    }
271}
272
273impl Default for TensorOpDispatcher {
274    fn default() -> Self {
275        Self::new()
276    }
277}
278
279// ---------------------------------------------------------------------------
280// Tests
281// ---------------------------------------------------------------------------
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    // ------------------------------------------------------------------
288    // Helpers
289    // ------------------------------------------------------------------
290
291    fn cpu_backend(priority: u32) -> BackendRegistration {
292        BackendRegistration {
293            kind: BackendKind::Cpu,
294            priority,
295            supported_ops: vec![
296                "add".to_string(),
297                "mul".to_string(),
298                "sum".to_string(),
299                "mean".to_string(),
300                "conv".to_string(),
301                "relu".to_string(),
302            ],
303            is_available: true,
304        }
305    }
306
307    fn gpu_backend(priority: u32) -> BackendRegistration {
308        BackendRegistration {
309            kind: BackendKind::Gpu,
310            priority,
311            supported_ops: vec![
312                "matmul".to_string(),
313                "add".to_string(),
314                "mul".to_string(),
315                "sum".to_string(),
316                "conv".to_string(),
317                "relu".to_string(),
318            ],
319            is_available: true,
320        }
321    }
322
323    fn remote_backend(priority: u32) -> BackendRegistration {
324        BackendRegistration {
325            kind: BackendKind::Remote,
326            priority,
327            supported_ops: vec!["matmul".to_string(), "sum".to_string()],
328            is_available: true,
329        }
330    }
331
332    fn sim_backend(priority: u32) -> BackendRegistration {
333        BackendRegistration {
334            kind: BackendKind::Simulated,
335            priority,
336            supported_ops: vec![
337                "add".to_string(),
338                "mul".to_string(),
339                "sum".to_string(),
340                "conv".to_string(),
341            ],
342            is_available: true,
343        }
344    }
345
346    fn matmul_op() -> DispatchOp {
347        DispatchOp::MatMul { m: 4, n: 4, k: 4 }
348    }
349
350    fn ew_op(name: &str) -> DispatchOp {
351        DispatchOp::ElementWise {
352            op_name: name.to_string(),
353            element_count: 1024,
354        }
355    }
356
357    fn red_op(name: &str) -> DispatchOp {
358        DispatchOp::Reduction {
359            op_name: name.to_string(),
360            dims: vec![0, 1],
361        }
362    }
363
364    fn conv_op() -> DispatchOp {
365        DispatchOp::Convolution {
366            kernel_size: 3,
367            channels: 64,
368        }
369    }
370
371    // ------------------------------------------------------------------
372    // 1. register_backend — priority order is maintained
373    // ------------------------------------------------------------------
374
375    #[test]
376    fn test_register_maintains_priority_order_ascending_insert() {
377        let mut d = TensorOpDispatcher::new();
378        d.register_backend(cpu_backend(10));
379        d.register_backend(gpu_backend(50));
380        d.register_backend(remote_backend(30));
381
382        assert_eq!(d.backends[0].priority, 50);
383        assert_eq!(d.backends[1].priority, 30);
384        assert_eq!(d.backends[2].priority, 10);
385    }
386
387    #[test]
388    fn test_register_maintains_priority_order_descending_insert() {
389        let mut d = TensorOpDispatcher::new();
390        d.register_backend(gpu_backend(100));
391        d.register_backend(cpu_backend(10));
392
393        assert_eq!(d.backends[0].kind, BackendKind::Gpu);
394        assert_eq!(d.backends[1].kind, BackendKind::Cpu);
395    }
396
397    #[test]
398    fn test_register_creates_stats_entry() {
399        let mut d = TensorOpDispatcher::new();
400        d.register_backend(cpu_backend(10));
401        assert!(d.backend_stats(BackendKind::Cpu).is_some());
402    }
403
404    #[test]
405    fn test_register_same_kind_twice_does_not_duplicate_stats_entry() {
406        let mut d = TensorOpDispatcher::new();
407        d.register_backend(cpu_backend(10));
408        d.register_backend(cpu_backend(5)); // second CPU at lower priority
409                                            // Two registrations but stats initialised once
410        assert_eq!(
411            d.backend_stats(BackendKind::Cpu)
412                .expect("test: should succeed")
413                .total_dispatched,
414            0
415        );
416        // Two entries in backends list (different priority slots)
417        assert_eq!(d.backends.len(), 2);
418    }
419
420    // ------------------------------------------------------------------
421    // 2. dispatch — selects highest priority available backend
422    // ------------------------------------------------------------------
423
424    #[test]
425    fn test_dispatch_selects_highest_priority() {
426        let mut d = TensorOpDispatcher::new();
427        d.register_backend(cpu_backend(10));
428        d.register_backend(gpu_backend(50)); // GPU wins
429
430        let result = d.dispatch(matmul_op()).expect("should dispatch");
431        assert_eq!(result.selected_backend, BackendKind::Gpu);
432        assert!(!result.fallback_used);
433    }
434
435    #[test]
436    fn test_dispatch_selects_only_capable_backend() {
437        let mut d = TensorOpDispatcher::new();
438        // GPU does NOT list "add" in its supported ops
439        d.register_backend(BackendRegistration {
440            kind: BackendKind::Gpu,
441            priority: 100,
442            supported_ops: vec!["matmul".to_string()],
443            is_available: true,
444        });
445        d.register_backend(cpu_backend(10));
446
447        let result = d.dispatch(ew_op("add")).expect("should dispatch");
448        assert_eq!(result.selected_backend, BackendKind::Cpu);
449        // GPU does not support "add" so there is no capable-but-unavailable
450        // backend ahead of CPU — fallback_used must be false.
451        assert!(!result.fallback_used);
452    }
453
454    // ------------------------------------------------------------------
455    // 3. fallback when primary unavailable
456    // ------------------------------------------------------------------
457
458    #[test]
459    fn test_dispatch_fallback_when_primary_unavailable() {
460        let mut d = TensorOpDispatcher::new();
461        let mut gpu = gpu_backend(100);
462        gpu.is_available = false;
463        d.register_backend(gpu);
464        d.register_backend(cpu_backend(10));
465
466        let result = d.dispatch(matmul_op()).expect("should dispatch");
467        assert_eq!(result.selected_backend, BackendKind::Cpu);
468        assert!(result.fallback_used);
469    }
470
471    // ------------------------------------------------------------------
472    // 4. fallback_used true / false
473    // ------------------------------------------------------------------
474
475    #[test]
476    fn test_fallback_used_false_when_primary_available() {
477        let mut d = TensorOpDispatcher::new();
478        d.register_backend(gpu_backend(100));
479        d.register_backend(cpu_backend(10));
480
481        let result = d.dispatch(matmul_op()).expect("should dispatch");
482        assert!(!result.fallback_used);
483    }
484
485    #[test]
486    fn test_fallback_used_true_when_skipping_unavailable_capable() {
487        let mut d = TensorOpDispatcher::new();
488        let mut gpu = gpu_backend(100);
489        gpu.is_available = false;
490        d.register_backend(gpu);
491        d.register_backend(cpu_backend(10));
492
493        let result = d.dispatch(matmul_op()).expect("should dispatch");
494        assert!(result.fallback_used);
495    }
496
497    #[test]
498    fn test_fallback_used_false_when_incapable_backend_skipped() {
499        // The GPU does not support "add" — skipping it should NOT count as a
500        // fallback.
501        let mut d = TensorOpDispatcher::new();
502        d.register_backend(BackendRegistration {
503            kind: BackendKind::Gpu,
504            priority: 200,
505            supported_ops: vec!["matmul".to_string()],
506            is_available: true,
507        });
508        d.register_backend(cpu_backend(10));
509
510        let result = d.dispatch(ew_op("add")).expect("should dispatch");
511        assert_eq!(result.selected_backend, BackendKind::Cpu);
512        assert!(!result.fallback_used);
513    }
514
515    // ------------------------------------------------------------------
516    // 5. dispatch returns None when no backend found
517    // ------------------------------------------------------------------
518
519    #[test]
520    fn test_dispatch_none_when_no_backends() {
521        let mut d = TensorOpDispatcher::new();
522        assert!(d.dispatch(matmul_op()).is_none());
523    }
524
525    #[test]
526    fn test_dispatch_none_when_all_unavailable() {
527        let mut d = TensorOpDispatcher::new();
528        let mut gpu = gpu_backend(100);
529        gpu.is_available = false;
530        let mut cpu = cpu_backend(10);
531        cpu.is_available = false;
532        d.register_backend(gpu);
533        d.register_backend(cpu);
534
535        assert!(d.dispatch(matmul_op()).is_none());
536    }
537
538    #[test]
539    fn test_dispatch_none_when_op_unsupported_by_all() {
540        let mut d = TensorOpDispatcher::new();
541        d.register_backend(BackendRegistration {
542            kind: BackendKind::Gpu,
543            priority: 50,
544            supported_ops: vec!["matmul".to_string()],
545            is_available: true,
546        });
547        // "sigmoid" not listed in any backend
548        assert!(d.dispatch(ew_op("sigmoid")).is_none());
549    }
550
551    // ------------------------------------------------------------------
552    // 6. set_backend_available toggles availability
553    // ------------------------------------------------------------------
554
555    #[test]
556    fn test_set_backend_available_disables() {
557        let mut d = TensorOpDispatcher::new();
558        d.register_backend(gpu_backend(100));
559        d.register_backend(cpu_backend(10));
560
561        d.set_backend_available(BackendKind::Gpu, false);
562        let result = d.dispatch(matmul_op()).expect("CPU should be fallback");
563        assert_eq!(result.selected_backend, BackendKind::Cpu);
564        assert!(result.fallback_used);
565    }
566
567    #[test]
568    fn test_set_backend_available_re_enables() {
569        let mut d = TensorOpDispatcher::new();
570        let mut gpu = gpu_backend(100);
571        gpu.is_available = false;
572        d.register_backend(gpu);
573        d.register_backend(cpu_backend(10));
574
575        // GPU is down — CPU selected
576        let r1 = d.dispatch(matmul_op()).expect("should dispatch");
577        assert_eq!(r1.selected_backend, BackendKind::Cpu);
578
579        // Re-enable GPU
580        d.set_backend_available(BackendKind::Gpu, true);
581        let r2 = d.dispatch(matmul_op()).expect("should dispatch");
582        assert_eq!(r2.selected_backend, BackendKind::Gpu);
583        assert!(!r2.fallback_used);
584    }
585
586    // ------------------------------------------------------------------
587    // 7. MatMul always works on Cpu
588    // ------------------------------------------------------------------
589
590    #[test]
591    fn test_matmul_always_supported_on_cpu() {
592        let reg = cpu_backend(10);
593        // Even with an empty supported_ops Cpu still supports MatMul
594        let minimal_cpu = BackendRegistration {
595            kind: BackendKind::Cpu,
596            priority: 10,
597            supported_ops: vec![],
598            is_available: true,
599        };
600        assert!(minimal_cpu.supports_op(&matmul_op()));
601        assert!(reg.supports_op(&matmul_op()));
602    }
603
604    #[test]
605    fn test_matmul_always_supported_on_simulated() {
606        let minimal_sim = BackendRegistration {
607            kind: BackendKind::Simulated,
608            priority: 1,
609            supported_ops: vec![],
610            is_available: true,
611        };
612        assert!(minimal_sim.supports_op(&matmul_op()));
613    }
614
615    #[test]
616    fn test_matmul_requires_capability_on_gpu() {
617        let gpu_no_mm = BackendRegistration {
618            kind: BackendKind::Gpu,
619            priority: 10,
620            supported_ops: vec!["add".to_string()],
621            is_available: true,
622        };
623        assert!(!gpu_no_mm.supports_op(&matmul_op()));
624
625        let gpu_with_mm = BackendRegistration {
626            kind: BackendKind::Gpu,
627            priority: 10,
628            supported_ops: vec!["matmul".to_string()],
629            is_available: true,
630        };
631        assert!(gpu_with_mm.supports_op(&matmul_op()));
632    }
633
634    #[test]
635    fn test_matmul_requires_capability_on_remote() {
636        let remote_no_mm = BackendRegistration {
637            kind: BackendKind::Remote,
638            priority: 10,
639            supported_ops: vec!["sum".to_string()],
640            is_available: true,
641        };
642        assert!(!remote_no_mm.supports_op(&matmul_op()));
643    }
644
645    // ------------------------------------------------------------------
646    // 8. ElementWise matches op_name
647    // ------------------------------------------------------------------
648
649    #[test]
650    fn test_elementwise_matches_op_name() {
651        let cpu = cpu_backend(10); // supports "add", "mul", "relu"
652        assert!(cpu.supports_op(&ew_op("add")));
653        assert!(cpu.supports_op(&ew_op("mul")));
654        assert!(cpu.supports_op(&ew_op("relu")));
655        assert!(!cpu.supports_op(&ew_op("sigmoid")));
656    }
657
658    #[test]
659    fn test_dispatch_elementwise_correct_backend() {
660        let mut d = TensorOpDispatcher::new();
661        // GPU supports "mul" but not "relu"
662        d.register_backend(BackendRegistration {
663            kind: BackendKind::Gpu,
664            priority: 100,
665            supported_ops: vec!["matmul".to_string(), "mul".to_string()],
666            is_available: true,
667        });
668        d.register_backend(cpu_backend(10)); // supports "relu"
669
670        let r = d.dispatch(ew_op("relu")).expect("should dispatch");
671        assert_eq!(r.selected_backend, BackendKind::Cpu);
672        // GPU did not support "relu" — not a capable-but-unavailable skip.
673        assert!(!r.fallback_used);
674    }
675
676    // ------------------------------------------------------------------
677    // 9. Convolution matches "conv"
678    // ------------------------------------------------------------------
679
680    #[test]
681    fn test_convolution_matches_conv_keyword() {
682        let backend = BackendRegistration {
683            kind: BackendKind::Gpu,
684            priority: 10,
685            supported_ops: vec!["conv".to_string(), "matmul".to_string()],
686            is_available: true,
687        };
688        assert!(backend.supports_op(&conv_op()));
689    }
690
691    #[test]
692    fn test_convolution_fails_without_conv_keyword() {
693        let backend = BackendRegistration {
694            kind: BackendKind::Gpu,
695            priority: 10,
696            supported_ops: vec!["matmul".to_string()],
697            is_available: true,
698        };
699        assert!(!backend.supports_op(&conv_op()));
700    }
701
702    #[test]
703    fn test_dispatch_convolution_selects_correct_backend() {
704        let mut d = TensorOpDispatcher::new();
705        d.register_backend(BackendRegistration {
706            kind: BackendKind::Remote,
707            priority: 200,
708            supported_ops: vec!["matmul".to_string()], // no conv
709            is_available: true,
710        });
711        d.register_backend(gpu_backend(100)); // has conv
712
713        let r = d.dispatch(conv_op()).expect("GPU should handle conv");
714        assert_eq!(r.selected_backend, BackendKind::Gpu);
715        assert!(!r.fallback_used);
716    }
717
718    // ------------------------------------------------------------------
719    // 10. Reduction matches op_name
720    // ------------------------------------------------------------------
721
722    #[test]
723    fn test_reduction_matches_op_name() {
724        let cpu = cpu_backend(10);
725        assert!(cpu.supports_op(&red_op("sum")));
726        assert!(cpu.supports_op(&red_op("mean")));
727        assert!(!cpu.supports_op(&red_op("prod")));
728    }
729
730    // ------------------------------------------------------------------
731    // 11. dispatcher_stats counts
732    // ------------------------------------------------------------------
733
734    #[test]
735    fn test_dispatcher_stats_total_dispatched() {
736        let mut d = TensorOpDispatcher::new();
737        d.register_backend(cpu_backend(10));
738        d.dispatch(matmul_op());
739        d.dispatch(matmul_op());
740        assert_eq!(d.stats().total_dispatched, 2);
741    }
742
743    #[test]
744    fn test_dispatcher_stats_total_failed() {
745        let mut d = TensorOpDispatcher::new();
746        d.dispatch(matmul_op()); // no backends
747        d.dispatch(ew_op("nonexistent"));
748        assert_eq!(d.stats().total_failed, 2);
749    }
750
751    #[test]
752    fn test_dispatcher_stats_total_fallbacks() {
753        let mut d = TensorOpDispatcher::new();
754        let mut gpu = gpu_backend(100);
755        gpu.is_available = false;
756        d.register_backend(gpu);
757        d.register_backend(cpu_backend(10));
758
759        d.dispatch(matmul_op()); // fallback
760        d.dispatch(matmul_op()); // fallback again
761        assert_eq!(d.stats().total_fallbacks, 2);
762    }
763
764    #[test]
765    fn test_dispatcher_stats_mixed_outcomes() {
766        let mut d = TensorOpDispatcher::new();
767        d.register_backend(gpu_backend(100));
768        d.register_backend(cpu_backend(10));
769
770        d.dispatch(matmul_op()); // normal
771        d.set_backend_available(BackendKind::Gpu, false);
772        d.dispatch(matmul_op()); // fallback
773        d.dispatch(ew_op("sigmoid")); // failed (unsupported)
774
775        assert_eq!(d.stats().total_dispatched, 2);
776        assert_eq!(d.stats().total_fallbacks, 1);
777        assert_eq!(d.stats().total_failed, 1);
778    }
779
780    // ------------------------------------------------------------------
781    // 12. backend_stats per backend
782    // ------------------------------------------------------------------
783
784    #[test]
785    fn test_backend_stats_dispatched_count() {
786        let mut d = TensorOpDispatcher::new();
787        d.register_backend(cpu_backend(10));
788
789        d.dispatch(matmul_op());
790        d.dispatch(ew_op("add"));
791        let stats = d.backend_stats(BackendKind::Cpu).expect("stats exist");
792        assert_eq!(stats.total_dispatched, 2);
793    }
794
795    #[test]
796    fn test_backend_stats_fallback_count() {
797        let mut d = TensorOpDispatcher::new();
798        let mut gpu = gpu_backend(100);
799        gpu.is_available = false;
800        d.register_backend(gpu);
801        d.register_backend(cpu_backend(10));
802
803        d.dispatch(matmul_op()); // CPU as fallback
804        d.dispatch(matmul_op()); // CPU as fallback again
805
806        let cpu_stats = d.backend_stats(BackendKind::Cpu).expect("CPU stats");
807        assert_eq!(cpu_stats.total_dispatched, 2);
808        assert_eq!(cpu_stats.total_fallback_selected, 2);
809
810        // GPU was never selected
811        let gpu_stats = d.backend_stats(BackendKind::Gpu).expect("GPU stats");
812        assert_eq!(gpu_stats.total_dispatched, 0);
813    }
814
815    #[test]
816    fn test_backend_stats_none_for_unregistered() {
817        let d = TensorOpDispatcher::new();
818        assert!(d.backend_stats(BackendKind::Remote).is_none());
819    }
820
821    #[test]
822    fn test_backend_stats_independent_per_backend() {
823        let mut d = TensorOpDispatcher::new();
824        d.register_backend(gpu_backend(100));
825        d.register_backend(cpu_backend(10));
826
827        // MatMul → GPU (priority wins)
828        d.dispatch(matmul_op());
829        // Disable GPU → CPU fallback
830        d.set_backend_available(BackendKind::Gpu, false);
831        d.dispatch(matmul_op());
832
833        let gpu_stats = d
834            .backend_stats(BackendKind::Gpu)
835            .expect("test: should succeed");
836        assert_eq!(gpu_stats.total_dispatched, 1);
837        assert_eq!(gpu_stats.total_fallback_selected, 0);
838
839        let cpu_stats = d
840            .backend_stats(BackendKind::Cpu)
841            .expect("test: should succeed");
842        assert_eq!(cpu_stats.total_dispatched, 1);
843        assert_eq!(cpu_stats.total_fallback_selected, 1);
844    }
845
846    // ------------------------------------------------------------------
847    // 13. Simulated backend
848    // ------------------------------------------------------------------
849
850    #[test]
851    fn test_simulated_backend_matmul_always_supported() {
852        let mut d = TensorOpDispatcher::new();
853        d.register_backend(sim_backend(5));
854
855        let r = d
856            .dispatch(matmul_op())
857            .expect("simulated should handle matmul");
858        assert_eq!(r.selected_backend, BackendKind::Simulated);
859        assert!(!r.fallback_used);
860    }
861
862    #[test]
863    fn test_simulated_backend_elementwise_by_op_name() {
864        let mut d = TensorOpDispatcher::new();
865        d.register_backend(sim_backend(5)); // supports "add", "mul", "sum", "conv"
866
867        let r = d
868            .dispatch(ew_op("add"))
869            .expect("simulated should handle add");
870        assert_eq!(r.selected_backend, BackendKind::Simulated);
871
872        assert!(d.dispatch(ew_op("relu")).is_none()); // not in sim supported_ops
873    }
874
875    // ------------------------------------------------------------------
876    // 14. Dispatch result carries the original op
877    // ------------------------------------------------------------------
878
879    #[test]
880    fn test_dispatch_result_carries_op() {
881        let mut d = TensorOpDispatcher::new();
882        d.register_backend(cpu_backend(10));
883
884        let op = DispatchOp::MatMul { m: 8, n: 16, k: 32 };
885        let r = d.dispatch(op.clone()).expect("should dispatch");
886        assert_eq!(r.op, op);
887    }
888
889    // ------------------------------------------------------------------
890    // 15. Multiple backends of same kind (e.g., two CPU registrations)
891    // ------------------------------------------------------------------
892
893    #[test]
894    fn test_multiple_registrations_same_kind_priority_wins() {
895        let mut d = TensorOpDispatcher::new();
896        d.register_backend(cpu_backend(20)); // higher priority CPU
897        d.register_backend(cpu_backend(10)); // lower priority CPU
898
899        // First registration (priority 20) should be selected.
900        let r = d.dispatch(matmul_op()).expect("should dispatch");
901        assert_eq!(r.selected_backend, BackendKind::Cpu);
902        assert!(!r.fallback_used);
903        // Exactly one op dispatched to CPU
904        assert_eq!(
905            d.backend_stats(BackendKind::Cpu)
906                .expect("test: should succeed")
907                .total_dispatched,
908            1
909        );
910    }
911}