Skip to main content

nxpu_backend_core/
lib.rs

1#![warn(missing_docs)]
2//! Backend trait and plugin architecture for NxPU.
3//!
4//! Defines the [`Backend`] trait that all NPU code emitters implement,
5//! along with supporting types ([`BackendOptions`], [`BackendOutput`],
6//! [`BackendError`]) and a [`BackendRegistry`] for CLI dispatch.
7
8use std::fmt::{self, Debug};
9
10use nxpu_ir::Module;
11
12/// Target precision for NPU compilation.
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum Precision {
15    /// 32-bit floating point (no conversion).
16    F32,
17    /// 16-bit floating point.
18    F16,
19    /// Brain floating point (16-bit).
20    BF16,
21    /// 8-bit integer (quantized).
22    Int8,
23}
24
25impl fmt::Display for Precision {
26    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27        f.write_str(match self {
28            Self::F32 => "F32",
29            Self::F16 => "F16",
30            Self::BF16 => "BF16",
31            Self::Int8 => "I8",
32        })
33    }
34}
35
36/// Policy for choosing precision during compilation.
37#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
38pub enum PrecisionPolicy {
39    /// Keep the original precision from the WGSL source.
40    Keep,
41    /// Use a specific precision regardless of backend preference.
42    Explicit(Precision),
43    /// Automatically select based on the backend's preferred precision.
44    #[default]
45    Auto,
46}
47
48impl fmt::Display for PrecisionPolicy {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        match self {
51            Self::Keep => f.write_str("Keep"),
52            Self::Explicit(p) => write!(f, "Explicit({p})"),
53            Self::Auto => f.write_str("Auto"),
54        }
55    }
56}
57
58/// A backend that compiles NxPU IR to target-specific output.
59pub trait Backend: Debug + Send + Sync {
60    /// Human-readable name (e.g. "apple-ane").
61    fn name(&self) -> &str;
62
63    /// Target identifiers this backend handles (for `--target` dispatch).
64    fn targets(&self) -> &[&str];
65
66    /// Compile an optimized IR module to backend-specific output.
67    fn compile(
68        &self,
69        module: &Module,
70        opts: &BackendOptions,
71    ) -> Result<BackendOutput, BackendError>;
72
73    /// The precision this backend prefers for optimal NPU execution.
74    fn preferred_precision(&self) -> Precision {
75        Precision::F32
76    }
77}
78
79/// Options passed to a backend during compilation.
80///
81/// The `precision` field controls the target quantization precision.
82/// In the typical workflow, the caller (e.g. `nxpu-cli`) applies the
83/// appropriate quantization pass (`F32ToF16`, `F32ToBf16`, `F32ToInt8`,
84/// or `MixedPrecisionPass`) to the IR module *before* calling
85/// `Backend::compile`. The `precision` field is informational — backends
86/// can read it to emit diagnostics or choose format-specific options,
87/// but the IR has already been rewritten by the quantization pass.
88#[derive(Clone, Debug, Default)]
89pub struct BackendOptions {
90    /// Optimization level (0 = none, 1 = basic, 2 = aggressive).
91    pub opt_level: u8,
92    /// Concrete extent to substitute for tensor dimensions the kernel leaves
93    /// symbolic.
94    ///
95    /// A WGSL kernel over `array<f32>` carries no length, so the emitted graph
96    /// has no size for most dimensions. Formats differ on what to do about
97    /// that: ONNX names them and resolves later, while TFLite requires a
98    /// concrete extent and cannot load a model without one. `None` means the
99    /// caller did not say, and the backend picks the smallest valid extent.
100    ///
101    /// The type forbids a negative value on purpose: writing `-1` here is the
102    /// bug that made every TFLite model this project emitted unloadable.
103    pub symbolic_extent: Option<u32>,
104    /// Precision policy for quantization.
105    ///
106    /// The CLI applies the corresponding quantization pass to the IR before
107    /// compilation. Backends may use this to emit precision-related
108    /// diagnostics or metadata, but should not re-quantize the IR.
109    pub precision: PrecisionPolicy,
110    /// Optional memory plan computed by the memory planning pass.
111    ///
112    /// When present, backends can use this to emit buffer allocation metadata
113    /// (e.g. ONNX `metadata_props`, TFLite buffer info). Backends that do not
114    /// support memory plan metadata may ignore this field.
115    pub memory_plan: Option<MemoryPlan>,
116    /// Optional per-tensor quantization parameters from calibration.
117    ///
118    /// When present, backends embed these parameters in the compiled output
119    /// (e.g. ONNX `metadata_props`, TFLite companion JSON) so downstream
120    /// tools can correctly dequantize tensors.
121    pub quantization_params: Vec<QuantParam>,
122    /// Per-channel quantization parameters for weight tensors.
123    ///
124    /// When present, backends emit per-channel quantization metadata:
125    /// ONNX backends inject QuantizeLinear/DequantizeLinear (QDQ) nodes,
126    /// TFLite backends include them in the companion JSON.
127    pub per_channel_params: Vec<PerChannelParam>,
128    /// Tiling plans computed by the tiling pass.
129    ///
130    /// Backends may use these to emit cache-blocking metadata or
131    /// generate tiled loop structures.
132    pub tiling_plans: Vec<TilingPlanInfo>,
133    /// Vectorization hints computed by the vectorization pass.
134    ///
135    /// Backends may use these to emit SIMD annotations or select
136    /// vector instruction widths.
137    pub vectorization_hints: Vec<VectorizationHintInfo>,
138    /// Contents for tensors the kernel receives as buffers, by name.
139    ///
140    /// A WGSL kernel takes its weights through `var<storage, read>` and there
141    /// is nothing in the source to say what they are — the host binds them per
142    /// dispatch. An NNAPI driver will not accept a convolution on those terms:
143    /// measured on a MediaTek MT6899, `mtk-neuron_shim` accelerates a
144    /// convolution whose filter is a compile-time constant and refuses the
145    /// identical convolution, at the same shapes, whose filter is a graph
146    /// input.
147    ///
148    /// A tensor named here is emitted as a constant and stops being a graph
149    /// input. Empty is not "fill it with zeros": a model that accelerated
150    /// because its weights were invented would run, be attributed to the
151    /// accelerator, and compute the wrong thing.
152    pub constant_tensors: Vec<ConstantTensor>,
153}
154
155/// Contents for one tensor, supplied by the caller rather than by the kernel.
156#[derive(Clone, Debug)]
157pub struct ConstantTensor {
158    /// The tensor's name, as the WGSL binding spells it.
159    pub name: String,
160    /// Raw little-endian contents, in the tensor's own element type.
161    pub data: Vec<u8>,
162}
163
164/// A per-tensor quantization parameter entry.
165#[derive(Clone, Debug)]
166pub struct QuantParam {
167    /// Tensor name.
168    pub name: String,
169    /// Quantization scale factor.
170    pub scale: f32,
171    /// Quantization zero point.
172    pub zero_point: i32,
173}
174
175/// Per-channel quantization parameters for a weight tensor.
176#[derive(Clone, Debug)]
177pub struct PerChannelParam {
178    /// Tensor name (e.g., weight variable name).
179    pub name: String,
180    /// Per-channel scale factors.
181    pub scales: Vec<f32>,
182    /// Per-channel zero points.
183    pub zero_points: Vec<i32>,
184    /// The axis along which channels are quantized (typically 0 for output channels).
185    pub channel_axis: u32,
186}
187
188/// A tiling plan passed through to backends.
189#[derive(Clone, Debug)]
190pub struct TilingPlanInfo {
191    /// Operation name.
192    pub op_name: String,
193    /// Tile dimension configurations.
194    pub tiles: Vec<(String, u32)>,
195    /// Cache reuse factor.
196    pub reuse_factor: f64,
197}
198
199/// A vectorization hint passed through to backends.
200#[derive(Clone, Debug)]
201pub struct VectorizationHintInfo {
202    /// Operation name.
203    pub op_name: String,
204    /// Dimension name to vectorize.
205    pub dim_name: String,
206    /// Number of SIMD lanes.
207    pub lanes: u32,
208    /// Whether this is a reduction dimension.
209    pub is_reduction: bool,
210    /// Whether memory access is contiguous.
211    pub is_contiguous: bool,
212}
213
214impl fmt::Display for BackendOptions {
215    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
216        let mem = if self.memory_plan.is_some() {
217            ", memory_plan: yes"
218        } else {
219            ""
220        };
221        let quant = if self.quantization_params.is_empty() {
222            String::new()
223        } else {
224            format!(", quant_params: {}", self.quantization_params.len())
225        };
226        let per_ch = if self.per_channel_params.is_empty() {
227            String::new()
228        } else {
229            format!(", per_channel: {}", self.per_channel_params.len())
230        };
231        write!(
232            f,
233            "BackendOptions {{ opt_level: {}, precision: {}{}{}{} }}",
234            self.opt_level, self.precision, mem, quant, per_ch
235        )
236    }
237}
238
239/// The output produced by a backend.
240#[derive(Clone, Debug)]
241pub struct BackendOutput {
242    /// One or more output files.
243    pub files: Vec<OutputFile>,
244    /// Non-fatal diagnostics.
245    pub diagnostics: Vec<Diagnostic>,
246}
247
248impl fmt::Display for BackendOutput {
249    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
250        write!(
251            f,
252            "{} file(s), {} diagnostic(s)",
253            self.files.len(),
254            self.diagnostics.len()
255        )
256    }
257}
258
259/// A single output file.
260#[derive(Clone, Debug)]
261pub struct OutputFile {
262    /// Suggested filename (e.g. "output.bin", "module.ir").
263    pub name: String,
264    /// The file content.
265    pub content: OutputContent,
266}
267
268impl fmt::Display for OutputFile {
269    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
270        f.write_str(&self.name)
271    }
272}
273
274/// Content of an output file.
275#[derive(Clone, Debug)]
276pub enum OutputContent {
277    /// UTF-8 text.
278    Text(String),
279    /// Raw binary data.
280    Binary(Vec<u8>),
281}
282
283impl OutputContent {
284    /// Size of the content in bytes — UTF-8 bytes for text, raw bytes for
285    /// binary.
286    ///
287    /// Exists so a caller that only wants to know whether a backend produced
288    /// anything does not have to match on the variant. Five backend test
289    /// suites were each carrying the same two-arm match, and in every one of
290    /// them the text arm was unreachable, because those backends emit binary
291    /// only.
292    pub fn len(&self) -> usize {
293        match self {
294            Self::Text(s) => s.len(),
295            Self::Binary(b) => b.len(),
296        }
297    }
298
299    /// Whether the content is empty.
300    pub fn is_empty(&self) -> bool {
301        self.len() == 0
302    }
303}
304
305impl fmt::Display for OutputContent {
306    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
307        match self {
308            Self::Text(s) => write!(f, "Text({} chars)", s.len()),
309            Self::Binary(b) => write!(f, "Binary({} bytes)", b.len()),
310        }
311    }
312}
313
314/// A non-fatal diagnostic message from a backend.
315#[derive(Clone, Debug)]
316pub struct Diagnostic {
317    /// Severity level.
318    pub level: DiagnosticLevel,
319    /// Human-readable message.
320    pub message: String,
321}
322
323impl fmt::Display for Diagnostic {
324    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
325        write!(f, "[{}] {}", self.level, self.message)
326    }
327}
328
329/// Severity level for diagnostics.
330#[derive(Clone, Copy, Debug, PartialEq, Eq)]
331pub enum DiagnosticLevel {
332    /// A warning that does not prevent compilation.
333    Warning,
334    /// An informational note.
335    Info,
336}
337
338impl fmt::Display for DiagnosticLevel {
339    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
340        f.write_str(match self {
341            Self::Warning => "Warning",
342            Self::Info => "Info",
343        })
344    }
345}
346
347/// Performance tier for an operator on a specific NPU.
348#[derive(Clone, Copy, Debug, PartialEq, Eq)]
349pub enum PerformanceTier {
350    /// Operator runs natively on the NPU hardware.
351    Native,
352    /// Operator is emulated (e.g. decomposed or run on CPU fallback).
353    Emulated,
354    /// Operator is not supported at all.
355    Unsupported,
356}
357
358impl fmt::Display for PerformanceTier {
359    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
360        f.write_str(match self {
361            Self::Native => "native",
362            Self::Emulated => "emulated",
363            Self::Unsupported => "unsupported",
364        })
365    }
366}
367
368/// Trait describing which operators an NPU supports and at what performance tier.
369///
370/// Uses string-based op names (matching ONNX operator names) to avoid
371/// circular dependencies with `nxpu-analysis`.
372pub trait OperatorSupport {
373    /// Query the support tier for a given operator at a given precision.
374    fn op_support(&self, op_name: &str, precision: Precision) -> PerformanceTier;
375
376    /// Human-readable hardware name.
377    fn hardware_name(&self) -> &str;
378
379    /// Operators that run natively on this hardware.
380    fn native_ops(&self) -> &[&str];
381
382    /// Operators that are emulated (decomposed or CPU fallback).
383    fn emulated_ops(&self) -> &[&str];
384}
385
386/// Validate a set of operator patterns against an [`OperatorSupport`] implementation.
387///
388/// Returns diagnostics for any emulated or unsupported operators.
389pub fn validate_patterns(
390    support: &dyn OperatorSupport,
391    patterns: &[&str],
392    precision: Precision,
393) -> Vec<Diagnostic> {
394    let mut diagnostics = Vec::new();
395    for &op in patterns {
396        match support.op_support(op, precision) {
397            PerformanceTier::Native => {}
398            PerformanceTier::Emulated => {
399                diagnostics.push(Diagnostic {
400                    level: DiagnosticLevel::Warning,
401                    message: format!(
402                        "{}: '{}' at {} will be emulated (may be slower)",
403                        support.hardware_name(),
404                        op,
405                        precision,
406                    ),
407                });
408            }
409            PerformanceTier::Unsupported => {
410                diagnostics.push(Diagnostic {
411                    level: DiagnosticLevel::Warning,
412                    message: format!(
413                        "{}: '{}' at {} is unsupported",
414                        support.hardware_name(),
415                        op,
416                        precision,
417                    ),
418                });
419            }
420        }
421    }
422    diagnostics
423}
424
425// ---------------------------------------------------------------------------
426// Memory plan types
427// ---------------------------------------------------------------------------
428
429/// A unique identifier for a tensor in the memory plan.
430#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
431pub struct TensorId(pub usize);
432
433impl fmt::Display for TensorId {
434    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
435        write!(f, "tensor_{}", self.0)
436    }
437}
438
439/// A buffer allocation for a single tensor.
440#[derive(Clone, Debug, PartialEq, Eq)]
441pub struct BufferAllocation {
442    /// Which tensor this allocation is for.
443    pub tensor_id: TensorId,
444    /// Byte offset within the unified buffer.
445    pub offset: usize,
446    /// Size of this tensor in bytes.
447    pub size_bytes: usize,
448}
449
450/// The result of memory planning: buffer assignments and peak usage.
451#[derive(Clone, Debug, Default, PartialEq, Eq)]
452pub struct MemoryPlan {
453    /// Per-tensor buffer allocations.
454    pub allocations: Vec<BufferAllocation>,
455    /// Peak memory usage in bytes (the minimum buffer size needed).
456    pub peak_bytes: usize,
457}
458
459impl fmt::Display for MemoryPlan {
460    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
461        writeln!(f, "Memory Plan:")?;
462        writeln!(f, "  Peak memory: {} bytes", self.peak_bytes)?;
463        writeln!(f, "  Buffers: {}", self.allocations.len())?;
464
465        let total: usize = self.allocations.iter().map(|a| a.size_bytes).sum();
466        if total > 0 {
467            let ratio = 1.0 - (self.peak_bytes as f64 / total as f64);
468            writeln!(f, "  Total tensor sizes: {total} bytes")?;
469            writeln!(f, "  Reuse savings: {:.1}%", ratio * 100.0)?;
470        }
471
472        for alloc in &self.allocations {
473            writeln!(
474                f,
475                "  {} -> offset: {}, size: {} bytes",
476                alloc.tensor_id, alloc.offset, alloc.size_bytes
477            )?;
478        }
479        Ok(())
480    }
481}
482
483/// Errors that can occur during backend compilation.
484#[derive(Debug, thiserror::Error)]
485pub enum BackendError {
486    /// The module uses an IR feature not supported by this backend.
487    #[error("unsupported: {0}")]
488    Unsupported(String),
489    /// A general backend error.
490    #[error("{0}")]
491    Other(String),
492}
493
494/// Registry of available backends, used for CLI `--target` dispatch.
495pub struct BackendRegistry {
496    backends: Vec<Box<dyn Backend>>,
497}
498
499impl Default for BackendRegistry {
500    fn default() -> Self {
501        Self::new()
502    }
503}
504
505impl BackendRegistry {
506    /// Creates an empty registry.
507    pub fn new() -> Self {
508        Self {
509            backends: Vec::new(),
510        }
511    }
512
513    /// Creates a registry pre-populated with built-in backends.
514    pub fn with_builtins() -> Self {
515        let mut reg = Self::new();
516        reg.register(Box::new(IrDumpBackend));
517        reg
518    }
519
520    /// Registers a backend.
521    pub fn register(&mut self, backend: Box<dyn Backend>) {
522        self.backends.push(backend);
523    }
524
525    /// Finds a backend that handles the given target identifier.
526    pub fn find(&self, target: &str) -> Option<&dyn Backend> {
527        self.backends
528            .iter()
529            .find(|b| b.targets().contains(&target))
530            .map(|b| &**b)
531    }
532
533    /// Lists all supported target identifiers.
534    pub fn list_targets(&self) -> Vec<&str> {
535        self.backends
536            .iter()
537            .flat_map(|b| b.targets().iter().copied())
538            .collect()
539    }
540}
541
542/// Built-in backend that dumps the IR as text using [`nxpu_ir::dump_module`].
543#[derive(Debug)]
544pub struct IrDumpBackend;
545
546impl Backend for IrDumpBackend {
547    fn name(&self) -> &str {
548        "IR Dump"
549    }
550
551    fn targets(&self) -> &[&str] {
552        &["ir-dump", "ir"]
553    }
554
555    fn compile(
556        &self,
557        module: &Module,
558        _opts: &BackendOptions,
559    ) -> Result<BackendOutput, BackendError> {
560        let text = nxpu_ir::dump_module(module);
561        Ok(BackendOutput {
562            files: vec![OutputFile {
563                name: "module.ir".into(),
564                content: OutputContent::Text(text),
565            }],
566            diagnostics: vec![],
567        })
568    }
569}
570
571#[cfg(test)]
572mod output_content_tests {
573    use super::*;
574
575    #[test]
576    fn text_length_is_utf8_bytes() {
577        // Not chars: a caller sizing a buffer needs bytes.
578        assert_eq!(OutputContent::Text("hello".into()).len(), 5);
579        assert_eq!(OutputContent::Text("日本語".into()).len(), 9);
580    }
581
582    #[test]
583    fn binary_length_is_bytes() {
584        assert_eq!(OutputContent::Binary(vec![0u8; 7]).len(), 7);
585    }
586
587    #[test]
588    fn both_variants_report_empty() {
589        assert!(OutputContent::Text(String::new()).is_empty());
590        assert!(OutputContent::Binary(Vec::new()).is_empty());
591        assert!(!OutputContent::Binary(vec![1]).is_empty());
592    }
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598
599    #[test]
600    fn ir_dump_backend_targets() {
601        let backend = IrDumpBackend;
602        assert_eq!(backend.name(), "IR Dump");
603        assert!(backend.targets().contains(&"ir-dump"));
604        assert!(backend.targets().contains(&"ir"));
605    }
606
607    #[test]
608    fn ir_dump_backend_compile() {
609        let module = Module::default();
610        let opts = BackendOptions::default();
611        let output = IrDumpBackend.compile(&module, &opts).unwrap();
612        assert_eq!(output.files.len(), 1);
613        assert_eq!(output.files[0].name, "module.ir");
614        match &output.files[0].content {
615            OutputContent::Text(text) => assert!(text.contains("Types:")),
616            _ => panic!("expected text output"),
617        }
618    }
619
620    #[test]
621    fn registry_find_builtin() {
622        let reg = BackendRegistry::with_builtins();
623        assert!(reg.find("ir-dump").is_some());
624        assert!(reg.find("ir").is_some());
625        assert!(reg.find("nonexistent").is_none());
626    }
627
628    #[test]
629    fn registry_list_targets() {
630        let reg = BackendRegistry::with_builtins();
631        let targets = reg.list_targets();
632        assert!(targets.contains(&"ir-dump"));
633        assert!(targets.contains(&"ir"));
634    }
635
636    #[test]
637    fn registry_custom_backend() {
638        #[derive(Debug)]
639        struct TestBackend;
640        impl Backend for TestBackend {
641            fn name(&self) -> &str {
642                "test"
643            }
644            fn targets(&self) -> &[&str] {
645                &["test-target"]
646            }
647            fn compile(
648                &self,
649                _module: &Module,
650                _opts: &BackendOptions,
651            ) -> Result<BackendOutput, BackendError> {
652                Ok(BackendOutput {
653                    files: vec![],
654                    diagnostics: vec![],
655                })
656            }
657        }
658
659        let mut reg = BackendRegistry::new();
660        reg.register(Box::new(TestBackend));
661        assert!(reg.find("test-target").is_some());
662    }
663
664    #[test]
665    fn display_precision_all_variants() {
666        assert_eq!(format!("{}", Precision::F32), "F32");
667        assert_eq!(format!("{}", Precision::F16), "F16");
668        assert_eq!(format!("{}", Precision::BF16), "BF16");
669        assert_eq!(format!("{}", Precision::Int8), "I8");
670    }
671
672    #[test]
673    fn display_precision_policy_all_variants() {
674        assert_eq!(format!("{}", PrecisionPolicy::Keep), "Keep");
675        assert_eq!(format!("{}", PrecisionPolicy::Auto), "Auto");
676        assert_eq!(
677            format!("{}", PrecisionPolicy::Explicit(Precision::F16)),
678            "Explicit(F16)"
679        );
680    }
681
682    #[test]
683    fn display_backend_options() {
684        let opts = BackendOptions {
685            opt_level: 2,
686            precision: PrecisionPolicy::Explicit(Precision::Int8),
687            ..Default::default()
688        };
689        let s = format!("{opts}");
690        assert!(s.contains("opt_level: 2"));
691        assert!(s.contains("Explicit(I8)"));
692    }
693
694    #[test]
695    fn display_backend_output() {
696        let output = BackendOutput {
697            files: vec![
698                OutputFile {
699                    name: "a.bin".into(),
700                    content: OutputContent::Binary(vec![1, 2, 3]),
701                },
702                OutputFile {
703                    name: "b.txt".into(),
704                    content: OutputContent::Text("hello".into()),
705                },
706            ],
707            diagnostics: vec![Diagnostic {
708                level: DiagnosticLevel::Info,
709                message: "done".into(),
710            }],
711        };
712        assert_eq!(format!("{output}"), "2 file(s), 1 diagnostic(s)");
713    }
714
715    #[test]
716    fn display_output_file() {
717        let f = OutputFile {
718            name: "model.onnx".into(),
719            content: OutputContent::Binary(vec![]),
720        };
721        assert_eq!(format!("{f}"), "model.onnx");
722    }
723
724    #[test]
725    fn display_output_content_all_variants() {
726        assert_eq!(
727            format!("{}", OutputContent::Text("abc".into())),
728            "Text(3 chars)"
729        );
730        assert_eq!(
731            format!("{}", OutputContent::Binary(vec![0; 100])),
732            "Binary(100 bytes)"
733        );
734    }
735
736    #[test]
737    fn display_diagnostic_and_level() {
738        let warn = Diagnostic {
739            level: DiagnosticLevel::Warning,
740            message: "deprecated op".into(),
741        };
742        assert_eq!(format!("{warn}"), "[Warning] deprecated op");
743
744        let info = Diagnostic {
745            level: DiagnosticLevel::Info,
746            message: "classified as Add".into(),
747        };
748        assert_eq!(format!("{info}"), "[Info] classified as Add");
749    }
750
751    #[test]
752    fn display_diagnostic_level_all_variants() {
753        assert_eq!(format!("{}", DiagnosticLevel::Warning), "Warning");
754        assert_eq!(format!("{}", DiagnosticLevel::Info), "Info");
755    }
756
757    #[test]
758    fn registry_empty_list_targets() {
759        let reg = BackendRegistry::new();
760        assert_eq!(reg.list_targets().len(), 0);
761    }
762
763    #[test]
764    fn registry_default_is_empty() {
765        let reg = BackendRegistry::default();
766        assert_eq!(reg.list_targets().len(), 0);
767    }
768
769    #[test]
770    fn preferred_precision_default() {
771        let backend = IrDumpBackend;
772        assert_eq!(backend.preferred_precision(), Precision::F32);
773    }
774
775    #[test]
776    fn display_performance_tier() {
777        assert_eq!(format!("{}", PerformanceTier::Native), "native");
778        assert_eq!(format!("{}", PerformanceTier::Emulated), "emulated");
779        assert_eq!(format!("{}", PerformanceTier::Unsupported), "unsupported");
780    }
781
782    #[test]
783    fn validate_patterns_generates_diagnostics() {
784        struct TestSupport;
785        impl OperatorSupport for TestSupport {
786            fn op_support(&self, op_name: &str, _precision: Precision) -> PerformanceTier {
787                match op_name {
788                    "MatMul" => PerformanceTier::Native,
789                    "Conv" => PerformanceTier::Emulated,
790                    _ => PerformanceTier::Unsupported,
791                }
792            }
793            fn hardware_name(&self) -> &str {
794                "TestNPU"
795            }
796            fn native_ops(&self) -> &[&str] {
797                &["MatMul"]
798            }
799            fn emulated_ops(&self) -> &[&str] {
800                &["Conv"]
801            }
802        }
803
804        let diags = validate_patterns(&TestSupport, &["MatMul", "Conv", "Softmax"], Precision::F16);
805        assert_eq!(diags.len(), 2);
806        assert!(diags[0].message.contains("emulated"));
807        assert!(diags[1].message.contains("unsupported"));
808    }
809
810    #[test]
811    fn backend_error_display() {
812        let e1 = BackendError::Unsupported("int64 tensors".into());
813        assert_eq!(format!("{e1}"), "unsupported: int64 tensors");
814
815        let e2 = BackendError::Other("internal failure".into());
816        assert_eq!(format!("{e2}"), "internal failure");
817    }
818
819    // ---- Memory plan types ----
820
821    #[test]
822    fn tensor_id_display() {
823        assert_eq!(format!("{}", TensorId(0)), "tensor_0");
824        assert_eq!(format!("{}", TensorId(42)), "tensor_42");
825    }
826
827    #[test]
828    fn memory_plan_display() {
829        let plan = MemoryPlan {
830            allocations: vec![
831                BufferAllocation {
832                    tensor_id: TensorId(0),
833                    offset: 0,
834                    size_bytes: 1024,
835                },
836                BufferAllocation {
837                    tensor_id: TensorId(1),
838                    offset: 0,
839                    size_bytes: 512,
840                },
841            ],
842            peak_bytes: 1024,
843        };
844        let s = format!("{plan}");
845        assert!(s.contains("Peak memory: 1024 bytes"));
846        assert!(s.contains("Buffers: 2"));
847        assert!(s.contains("Reuse savings:"));
848        assert!(s.contains("tensor_0"));
849        assert!(s.contains("tensor_1"));
850    }
851
852    #[test]
853    fn memory_plan_default() {
854        let plan = MemoryPlan::default();
855        assert_eq!(plan.allocations.len(), 0);
856        assert_eq!(plan.peak_bytes, 0);
857    }
858
859    #[test]
860    fn display_backend_options_with_memory_plan() {
861        let opts = BackendOptions {
862            opt_level: 1,
863            precision: PrecisionPolicy::Auto,
864            memory_plan: Some(MemoryPlan {
865                allocations: vec![],
866                peak_bytes: 0,
867            }),
868            ..Default::default()
869        };
870        let s = format!("{opts}");
871        assert!(s.contains("memory_plan: yes"));
872    }
873
874    #[test]
875    fn display_backend_options_without_memory_plan() {
876        let opts = BackendOptions {
877            opt_level: 1,
878            precision: PrecisionPolicy::Auto,
879            memory_plan: None,
880            ..Default::default()
881        };
882        let s = format!("{opts}");
883        assert!(!s.contains("memory_plan"));
884    }
885
886    #[test]
887    fn per_channel_param_display() {
888        let opts = BackendOptions {
889            per_channel_params: vec![PerChannelParam {
890                name: "conv_weight".into(),
891                scales: vec![0.1, 0.2, 0.3],
892                zero_points: vec![0, 0, 0],
893                channel_axis: 0,
894            }],
895            ..Default::default()
896        };
897        let s = format!("{opts}");
898        assert!(s.contains("per_channel: 1"));
899    }
900
901    #[test]
902    fn per_channel_param_default_empty() {
903        let opts = BackendOptions::default();
904        assert_eq!(opts.per_channel_params.len(), 0);
905        let s = format!("{opts}");
906        assert!(!s.contains("per_channel"));
907    }
908}