Skip to main content

edgefirst_decoder/
schema.rs

1// SPDX-FileCopyrightText: Copyright 2025-2026 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4//! Schema v2 metadata types for EdgeFirst model configuration.
5//!
6//! Schema v2 introduces a two-layer output model that separates the logical
7//! contract (what the model produces semantically) from the physical
8//! realization (what tensors the converter emitted). Each entry in the
9//! top-level [`SchemaV2::outputs`] array is a [`LogicalOutput`]. A logical
10//! output either IS a physical tensor (when the converter did not split it
11//! further) or contains a `outputs` array of [`PhysicalOutput`] children
12//! that realize it.
13//!
14//! # Example — YOLOv8 detection, flat (TFLite)
15//!
16//! ```
17//! use edgefirst_decoder::schema::SchemaV2;
18//!
19//! let json = r#"{
20//!   "schema_version": 2,
21//!   "outputs": [
22//!     {
23//!       "name": "boxes", "type": "boxes",
24//!       "shape": [1, 64, 8400],
25//!       "dshape": [{"batch": 1}, {"num_features": 64}, {"num_boxes": 8400}],
26//!       "encoding": "dfl", "decoder": "ultralytics", "normalized": true,
27//!       "dtype": "int8",
28//!       "quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "int8"}
29//!     },
30//!     {
31//!       "name": "scores", "type": "scores",
32//!       "shape": [1, 80, 8400],
33//!       "dshape": [{"batch": 1}, {"num_classes": 80}, {"num_boxes": 8400}],
34//!       "decoder": "ultralytics", "score_format": "per_class",
35//!       "dtype": "int8",
36//!       "quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "int8"}
37//!     }
38//!   ]
39//! }"#;
40//!
41//! let schema: SchemaV2 = serde_json::from_str(json).unwrap();
42//! assert_eq!(schema.schema_version, 2);
43//! assert_eq!(schema.outputs.len(), 2);
44//! ```
45
46use crate::configs::{self, deserialize_dshape, DimName, QuantTuple};
47use crate::{ConfigOutput, ConfigOutputs, DecoderError, DecoderResult};
48use serde::{Deserialize, Serialize};
49
50/// Highest `schema_version` this parser accepts. Files with a higher
51/// version are rejected rather than silently parsed against the wrong
52/// grammar.
53pub const MAX_SUPPORTED_SCHEMA_VERSION: u32 = 2;
54
55/// Root of the edgefirst.json schema v2 metadata.
56///
57/// All fields except [`SchemaV2::schema_version`] are optional, so
58/// third-party integrations can include only the sections relevant to
59/// their use case.
60///
61/// The struct is `#[non_exhaustive]` so new optional sections can be added
62/// without a breaking change. Outside this crate, start from
63/// [`SchemaV2::new`] (or [`Default`], or a parser) and assign fields:
64///
65/// ```rust
66/// use edgefirst_decoder::schema::{NmsMode, SchemaV2};
67///
68/// let mut schema = SchemaV2::new();
69/// schema.nms = Some(NmsMode::ClassAware);
70/// schema.nms_multi_label = Some(true);
71/// assert_eq!(schema.schema_version, 2);
72/// ```
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74#[non_exhaustive]
75pub struct SchemaV2 {
76    /// Schema version. Always 2 for v2 metadata.
77    pub schema_version: u32,
78
79    /// Input tensor specification (shape, named dims, camera adaptor).
80    ///
81    /// Required for decoders that need to know the model input resolution:
82    /// DFL dist2bbox scaling, box normalization against input dimensions,
83    /// and per-scale anchor grid generation.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub input: Option<InputSpec>,
86
87    /// Logical outputs describing the model's output tensors and their
88    /// semantic roles.
89    #[serde(default, skip_serializing_if = "Vec::is_empty")]
90    pub outputs: Vec<LogicalOutput>,
91
92    /// HAL NMS mode. Omitted for end-to-end models with embedded NMS.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub nms: Option<NmsMode>,
95
96    /// Emit one box per class above the score threshold (validation decode).
97    /// Tracked decode ignores it; an explicit decoder setting overrides it.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub nms_multi_label: Option<bool>,
100
101    /// YOLO architecture version for Ultralytics decoders.
102    ///
103    /// Values: `yolov5`, `yolov8`, `yolo11`, `yolo26`. `yolo26` is
104    /// end-to-end (embedded NMS).
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub decoder_version: Option<DecoderVersion>,
107}
108
109impl SchemaV2 {
110    /// An empty v2 schema: `schema_version` 2 and every section unset.
111    pub fn new() -> Self {
112        Self::default()
113    }
114}
115
116impl Default for SchemaV2 {
117    fn default() -> Self {
118        Self {
119            schema_version: 2,
120            input: None,
121            outputs: Vec::new(),
122            nms: None,
123            nms_multi_label: None,
124            decoder_version: None,
125        }
126    }
127}
128
129/// Input tensor specification.
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
131pub struct InputSpec {
132    /// Input tensor shape in the model's native layout (NCHW or NHWC).
133    pub shape: Vec<usize>,
134
135    /// Named dimensions ordered to match `shape`. Empty means the layout
136    /// is unspecified and consumers must infer from the format.
137    #[serde(
138        default,
139        deserialize_with = "deserialize_dshape",
140        skip_serializing_if = "Vec::is_empty"
141    )]
142    pub dshape: Vec<(DimName, usize)>,
143
144    /// Camera adaptor input format (`rgb`, `rgba`, `bgr`, `bgra`, `grey`,
145    /// `yuyv`). Free-form string rather than an enum because new adaptor
146    /// formats can appear without breaking parsing.
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub cameraadaptor: Option<String>,
149
150    /// Pre-processing the model expects: normalization, resize policy and
151    /// letterbox geometry. `None` means the producer did not declare it;
152    /// consumers fall back to `validation.normalization` /
153    /// `validation.preprocessing`, then to unsigned + centred letterbox.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub preprocessing: Option<Preprocessing>,
156}
157
158impl InputSpec {
159    /// Channel count of the model input, when the layout can be resolved.
160    ///
161    /// With a `dshape`, it is the size of the single axis not named
162    /// `batch`, `height` or `width`. Without one, a rank-4 `shape` resolves
163    /// only when exactly one of axis 1 (NCHW) and axis 3 (NHWC) holds 1 to
164    /// 4 channels.
165    ///
166    /// ```
167    /// use edgefirst_decoder::schema::InputSpec;
168    ///
169    /// let nchw: InputSpec = serde_json::from_str(r#"{"shape": [1, 3, 512, 512]}"#).unwrap();
170    /// assert_eq!(nchw.channels(), Some(3));
171    /// let ambiguous: InputSpec = serde_json::from_str(r#"{"shape": [1, 3, 4, 3]}"#).unwrap();
172    /// assert_eq!(ambiguous.channels(), None);
173    /// ```
174    pub fn channels(&self) -> Option<usize> {
175        if !self.dshape.is_empty() {
176            let mut rest = self.dshape.iter().enumerate().filter(|(_, (name, _))| {
177                !matches!(name, DimName::Batch | DimName::Height | DimName::Width)
178            });
179            return match (rest.next(), rest.next()) {
180                (Some((i, _)), None) => self.shape.get(i).copied(),
181                _ => None,
182            };
183        }
184        let [_, c1, _, c3] = self.shape.as_slice() else {
185            return None;
186        };
187        let is_channels = |c: usize| (1..=4).contains(&c);
188        match (is_channels(*c1), is_channels(*c3)) {
189            (true, false) => Some(*c1),
190            (false, true) => Some(*c3),
191            _ => None,
192        }
193    }
194}
195
196/// Input pre-processing declared by the model producer.
197///
198/// Every field is optional; an absent field means "not declared" rather
199/// than a specific default, so consumers can apply their own fallback.
200///
201/// ```
202/// use edgefirst_decoder::schema::{InputSpec, NormalizationPreset, Normalization};
203///
204/// let input: InputSpec = serde_json::from_str(r#"{
205///   "shape": [1, 3, 512, 512],
206///   "preprocessing": {
207///     "normalization": "imagenet",
208///     "resize": "letterbox",
209///     "letterbox": {"placement": "top_left", "pad": [0, 0, 0], "pad_space": "normalized"}
210///   }
211/// }"#).unwrap();
212/// let pre = input.preprocessing.unwrap();
213/// assert_eq!(pre.normalization, Some(Normalization::Preset(NormalizationPreset::Imagenet)));
214/// ```
215#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
216#[non_exhaustive]
217pub struct Preprocessing {
218    /// Per-channel normalization applied to 0–255 input values.
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub normalization: Option<Normalization>,
221
222    /// How a frame is fitted to the fixed model input.
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub resize: Option<ResizeMode>,
225
226    /// Resampling filter of the reference pipeline. Informational: a
227    /// runtime may use a different filter and should report the deviation.
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub interpolation: Option<Interpolation>,
230
231    /// Letterbox geometry; meaningful when `resize` is `letterbox`.
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub letterbox: Option<Letterbox>,
234}
235
236/// Input normalization: a named preset or explicit per-channel
237/// `mean`/`std`, both defined as `(x - mean) / std` on 0–255 values.
238#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
239#[serde(untagged)]
240pub enum Normalization {
241    /// Named preset shared with `validation.normalization`.
242    Preset(NormalizationPreset),
243    /// Explicit coefficients on the 0–255 scale. A single element applies
244    /// to every channel.
245    MeanStd {
246        /// Per-channel mean on the 0–255 scale.
247        mean: Vec<f32>,
248        /// Per-channel standard deviation on the 0–255 scale.
249        std: Vec<f32>,
250    },
251}
252
253/// Named normalization presets, matching the `validation.normalization`
254/// vocabulary.
255#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
256#[serde(rename_all = "snake_case")]
257#[non_exhaustive]
258pub enum NormalizationPreset {
259    /// `x / 255` → `[0, 1]`.
260    Unsigned,
261    /// `x / 127.5 - 1` → `[-1, 1]`.
262    Signed,
263    /// Identity: 0–255 values, or normalization performed in the graph.
264    Raw,
265    /// ImageNet RGB mean/std (0.485/0.456/0.406, 0.229/0.224/0.225).
266    Imagenet,
267}
268
269const IMAGENET_MEAN: [f32; 3] = [123.675, 116.28, 103.53];
270const IMAGENET_STD: [f32; 3] = [58.395, 57.12, 57.375];
271
272impl Normalization {
273    /// Resolve to per-channel `(mean, std)` vectors on the 0–255 scale,
274    /// each of length `channels`.
275    ///
276    /// # Errors
277    ///
278    /// [`DecoderError::InvalidConfig`] when the coefficient count is neither
279    /// 1 nor `channels`, when any `std` is zero or non-finite, or when the
280    /// `imagenet` preset is used with a channel count other than 3.
281    ///
282    /// ```
283    /// use edgefirst_decoder::schema::{Normalization, NormalizationPreset};
284    ///
285    /// let (mean, std) = Normalization::Preset(NormalizationPreset::Signed)
286    ///     .mean_std(3)
287    ///     .unwrap();
288    /// assert_eq!(mean, vec![127.5; 3]);
289    /// assert_eq!(std, vec![127.5; 3]);
290    /// ```
291    pub fn mean_std(&self, channels: usize) -> DecoderResult<(Vec<f32>, Vec<f32>)> {
292        let (mean, std): (&[f32], &[f32]) = match self {
293            Normalization::Preset(NormalizationPreset::Unsigned) => (&[0.0], &[255.0]),
294            Normalization::Preset(NormalizationPreset::Signed) => (&[127.5], &[127.5]),
295            Normalization::Preset(NormalizationPreset::Raw) => (&[0.0], &[1.0]),
296            Normalization::Preset(NormalizationPreset::Imagenet) => {
297                if channels != 3 {
298                    return Err(DecoderError::InvalidConfig(format!(
299                        "imagenet normalization needs 3 channels, got {channels}"
300                    )));
301                }
302                (&IMAGENET_MEAN, &IMAGENET_STD)
303            }
304            Normalization::MeanStd { mean, std } => (mean, std),
305        };
306        let mean = broadcast_channels("mean", mean, channels)?;
307        let std = broadcast_channels("std", std, channels)?;
308        if let Some(bad) = mean.iter().find(|m| !m.is_finite()) {
309            return Err(DecoderError::InvalidConfig(format!(
310                "normalization mean must be finite, got {bad}"
311            )));
312        }
313        if let Some(bad) = std.iter().find(|s| **s == 0.0 || !s.is_finite()) {
314            return Err(DecoderError::InvalidConfig(format!(
315                "normalization std must be finite and non-zero, got {bad}"
316            )));
317        }
318        Ok((mean, std))
319    }
320}
321
322fn broadcast_channels(field: &str, values: &[f32], channels: usize) -> DecoderResult<Vec<f32>> {
323    match values.len() {
324        1 => Ok(vec![values[0]; channels]),
325        n if n == channels => Ok(values.to_vec()),
326        n => Err(DecoderError::InvalidConfig(format!(
327            "normalization {field} has {n} values; expected 1 or {channels}"
328        ))),
329    }
330}
331
332/// How a frame is fitted to the fixed model input.
333#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
334#[serde(rename_all = "snake_case")]
335#[non_exhaustive]
336pub enum ResizeMode {
337    /// Scale each axis independently to the input size.
338    Stretch,
339    /// Preserve aspect ratio and pad to the input size.
340    Letterbox,
341}
342
343/// Resampling filter.
344#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
345#[serde(rename_all = "snake_case")]
346#[non_exhaustive]
347pub enum Interpolation {
348    Bilinear,
349    Nearest,
350    Bicubic,
351    Area,
352}
353
354/// Letterbox geometry for `resize: letterbox`.
355#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
356#[non_exhaustive]
357pub struct Letterbox {
358    /// Where the scaled image sits inside the input. Absent means centred.
359    #[serde(default, skip_serializing_if = "Option::is_none")]
360    pub placement: Option<LetterboxPlacement>,
361
362    /// Per-channel pad value, in the space named by `pad_space`. Absent
363    /// means 114 per channel in pixel space.
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub pad: Option<Vec<f32>>,
366
367    /// Space `pad` is expressed in. Absent means pixel space.
368    #[serde(default, skip_serializing_if = "Option::is_none")]
369    pub pad_space: Option<PadSpace>,
370}
371
372/// Letterbox placement of the scaled image.
373#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
374#[serde(rename_all = "snake_case")]
375#[non_exhaustive]
376pub enum LetterboxPlacement {
377    /// Centred; padding split between both sides of each axis.
378    Center,
379    /// Anchored top-left; padding on the bottom and right only.
380    TopLeft,
381}
382
383/// Space a letterbox pad value is expressed in.
384#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
385#[serde(rename_all = "snake_case")]
386#[non_exhaustive]
387pub enum PadSpace {
388    /// 0–255 pixel value, normalized like image content.
389    Pixel,
390    /// Value written as-is into the normalized model input.
391    Normalized,
392}
393
394/// Logical output: the semantic contract the model exposes.
395///
396/// When `outputs` is empty, the logical output IS the physical tensor
397/// (`dtype` and `quantization` carry the tensor-level fields directly).
398/// When `outputs` contains one or more [`PhysicalOutput`] entries, those
399/// children are the real physical tensors and the logical `shape` is
400/// the reconstructed shape produced by the fallback merge path.
401#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
402pub struct LogicalOutput {
403    /// Logical output name (optional at the logical level).
404    #[serde(default, skip_serializing_if = "Option::is_none")]
405    pub name: Option<String>,
406
407    /// Semantic type. `None` marks the output as "additional" — carried in
408    /// the schema for completeness (e.g. diagnostic or auxiliary tensors)
409    /// but not participating in decoder dispatch. See
410    /// [`SchemaV2::to_legacy_config_outputs`] for how typeless outputs are
411    /// filtered out of the legacy config, and the module docs for when to
412    /// use this vs. a recognised [`LogicalType`] variant.
413    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
414    pub type_: Option<LogicalType>,
415
416    /// Reconstructed logical shape (what the fallback dequant+merge path
417    /// produces).
418    pub shape: Vec<usize>,
419
420    /// Named dimensions ordered to match `shape`.
421    #[serde(
422        default,
423        deserialize_with = "deserialize_dshape",
424        skip_serializing_if = "Vec::is_empty"
425    )]
426    pub dshape: Vec<(DimName, usize)>,
427
428    /// Decoder to use for post-processing. Omitted for outputs consumed
429    /// directly (e.g. `protos`) where no decode step is required.
430    #[serde(default, skip_serializing_if = "Option::is_none")]
431    pub decoder: Option<DecoderKind>,
432
433    /// Box encoding. Required on `boxes` logical outputs in v2.
434    #[serde(default, skip_serializing_if = "Option::is_none")]
435    pub encoding: Option<BoxEncoding>,
436
437    /// Score format. Scores only.
438    #[serde(default, skip_serializing_if = "Option::is_none")]
439    pub score_format: Option<ScoreFormat>,
440
441    /// Coordinate format. `true` means `[0, 1]` normalized; `false` means
442    /// pixel coordinates relative to the letterboxed model input. `None`
443    /// means unspecified (decoder must infer). `boxes` and `detections`
444    /// only.
445    #[serde(default, skip_serializing_if = "Option::is_none")]
446    pub normalized: Option<bool>,
447
448    /// Anchor boxes for anchor-encoded logical outputs. Required when
449    /// `encoding: anchor`.
450    #[serde(default, skip_serializing_if = "Option::is_none")]
451    pub anchors: Option<Vec<[f32; 2]>>,
452
453    /// Spatial stride. For non-split logical outputs this is a spatial
454    /// hint (e.g. `protos` at stride 4). For per-scale splits each child
455    /// carries its own `stride` instead.
456    #[serde(default, skip_serializing_if = "Option::is_none")]
457    pub stride: Option<Stride>,
458
459    /// Tensor dtype. Present when `outputs` is empty (this logical IS the
460    /// physical tensor).
461    #[serde(default, skip_serializing_if = "Option::is_none")]
462    pub dtype: Option<DType>,
463
464    /// Quantization parameters. Present when `outputs` is empty. `None`
465    /// means the tensor is not quantized (float model).
466    #[serde(default, skip_serializing_if = "Option::is_none")]
467    pub quantization: Option<Quantization>,
468
469    /// Physical children that realize this logical output. Empty when the
470    /// logical IS the physical tensor. At most one level of nesting is
471    /// permitted.
472    #[serde(default, skip_serializing_if = "Vec::is_empty")]
473    pub outputs: Vec<PhysicalOutput>,
474
475    /// Activation already applied by the model graph or runtime. The
476    /// HAL must NOT re-apply an activation declared here.
477    ///
478    /// On per-scale models the converter writes this on the logical
479    /// parent (e.g. `scores.activation_applied = sigmoid`) when the
480    /// model graph already includes the activation; the per-physical
481    /// children inherit it.
482    #[serde(default, skip_serializing_if = "Option::is_none")]
483    pub activation_applied: Option<Activation>,
484
485    /// Activation NOT yet applied. The HAL MUST apply the declared
486    /// activation before consuming the tensor.
487    ///
488    /// On per-scale models the converter writes this on the logical
489    /// parent (e.g. `scores.activation_required = sigmoid`) when the
490    /// score-activation step was stripped from the model graph and
491    /// must be re-applied by the runtime; per-physical children
492    /// inherit the parent's declaration.
493    #[serde(default, skip_serializing_if = "Option::is_none")]
494    pub activation_required: Option<Activation>,
495
496    /// Semantic segmentation decode fields (`decoder: semantic` and the
497    /// mask-classification types). Serialized flat on the logical output.
498    #[serde(flatten)]
499    pub semantic: SemanticParams,
500}
501
502/// Semantic segmentation decode fields of a logical output.
503///
504/// Serialized flat alongside the other logical-output fields. All fields
505/// are optional; absent means the decoder default.
506#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
507#[non_exhaustive]
508pub struct SemanticParams {
509    /// Upsampling of the score map to the requested resolution.
510    #[serde(default, skip_serializing_if = "Option::is_none")]
511    pub upsample: Option<Upsample>,
512
513    /// Default decode mode; callers may override it at runtime.
514    #[serde(default, skip_serializing_if = "Option::is_none")]
515    pub decode_mode: Option<DecodeMode>,
516
517    /// Foreground threshold for single-channel outputs, in the space of
518    /// the tensor after `activation_applied` (probability for sigmoid,
519    /// logit otherwise).
520    #[serde(default, skip_serializing_if = "Option::is_none")]
521    pub threshold: Option<f32>,
522
523    /// Channel index of the background class, when the model has one.
524    #[serde(default, skip_serializing_if = "Option::is_none")]
525    pub background_index: Option<u32>,
526
527    /// `class_logits` only: index of the "no object" class. Absent means
528    /// the last class.
529    #[serde(default, skip_serializing_if = "Option::is_none")]
530    pub no_object_index: Option<u32>,
531
532    /// `mask_logits` only: activation applied across the mask logits.
533    #[serde(default, skip_serializing_if = "Option::is_none")]
534    pub mask_activation: Option<MaskActivation>,
535}
536
537/// Score-map upsampling.
538#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
539pub struct Upsample {
540    /// Resampling filter.
541    pub method: UpsampleMethod,
542    /// PyTorch `align_corners` semantics for bilinear and bicubic.
543    #[serde(default)]
544    pub align_corners: bool,
545}
546
547/// Score-map upsampling filter.
548#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
549#[serde(rename_all = "snake_case")]
550#[non_exhaustive]
551pub enum UpsampleMethod {
552    Bilinear,
553    Nearest,
554    Bicubic,
555}
556
557/// Semantic decode mode.
558#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
559#[serde(rename_all = "snake_case")]
560#[non_exhaustive]
561pub enum DecodeMode {
562    /// Upsample class scores, then argmax (reference).
563    Accurate,
564    /// Argmax at output resolution, then nearest upsample of the class map.
565    Fast,
566}
567
568impl DecodeMode {
569    /// Numeric code shared by the C and Python bindings.
570    pub const fn code(self) -> u32 {
571        match self {
572            Self::Accurate => 0,
573            Self::Fast => 1,
574        }
575    }
576
577    /// The mode with this [`code`](Self::code).
578    pub const fn from_code(code: u32) -> Option<Self> {
579        match code {
580            0 => Some(Self::Accurate),
581            1 => Some(Self::Fast),
582            _ => None,
583        }
584    }
585}
586
587/// Activation over mask-classification mask logits.
588#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
589#[serde(rename_all = "snake_case")]
590#[non_exhaustive]
591pub enum MaskActivation {
592    /// Independent sigmoid per query (MaskFormer, Mask2Former, OneFormer).
593    Sigmoid,
594    /// Softmax across the query axis (kMaX-DeepLab).
595    SoftmaxOverQueries,
596}
597
598impl LogicalOutput {
599    /// Returns `true` if this logical output has been split into physical
600    /// children by the converter.
601    pub fn is_split(&self) -> bool {
602        !self.outputs.is_empty()
603    }
604}
605
606/// Physical output: a concrete tensor produced by the converter.
607///
608/// Physical outputs carry only tensor-level fields (`dtype`,
609/// `quantization`, `stride`, `activation_applied`/`activation_required`).
610/// Semantic fields live on the [`LogicalOutput`] parent.
611#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
612pub struct PhysicalOutput {
613    /// Physical tensor name as produced by the converter. This name is
614    /// used to bind the metadata to the tensor returned by the inference
615    /// runtime.
616    pub name: String,
617
618    /// Semantic type. Matches the parent's type or declares a sub-split
619    /// such as `boxes_xy` or `boxes_wh`. `None` marks the child as
620    /// type-opaque: it still binds to a physical tensor by `name`/`shape`
621    /// during merging, but is not used to disambiguate against typed
622    /// siblings. Useful when a converter emits extra per-scale tensors
623    /// the HAL has no semantic for but the user manages downstream.
624    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
625    pub type_: Option<PhysicalType>,
626
627    /// Physical tensor shape.
628    pub shape: Vec<usize>,
629
630    /// Named dimensions ordered to match `shape`. Disambiguates NHWC vs
631    /// NCHW per-child rather than assuming a model-wide layout.
632    #[serde(
633        default,
634        deserialize_with = "deserialize_dshape",
635        skip_serializing_if = "Vec::is_empty"
636    )]
637    pub dshape: Vec<(DimName, usize)>,
638
639    /// Quantized data type.
640    pub dtype: DType,
641
642    /// Quantization parameters. Always present in v2; `null` means float
643    /// (no quantization).
644    #[serde(default, skip_serializing_if = "Option::is_none")]
645    pub quantization: Option<Quantization>,
646
647    /// FPN stride. Present on per-scale splits; absent on channel
648    /// sub-splits (e.g. `boxes_xy`/`boxes_wh`).
649    #[serde(default, skip_serializing_if = "Option::is_none")]
650    pub stride: Option<Stride>,
651
652    /// Zero-based index into the parent's strides array. Used for
653    /// parallel iteration with precomputed per-scale state.
654    #[serde(default, skip_serializing_if = "Option::is_none")]
655    pub scale_index: Option<usize>,
656
657    /// Activation already applied by the NPU. The HAL must NOT re-apply
658    /// an activation declared here (e.g. Hailo applies sigmoid to score
659    /// tensors on-chip).
660    #[serde(default, skip_serializing_if = "Option::is_none")]
661    pub activation_applied: Option<Activation>,
662
663    /// Activation NOT yet applied. The HAL MUST apply the declared
664    /// activation before consuming the tensor.
665    #[serde(default, skip_serializing_if = "Option::is_none")]
666    pub activation_required: Option<Activation>,
667}
668
669/// Quantization parameters for a quantized tensor.
670///
671/// Supports per-tensor (scalar `scale`) and per-channel (array `scale`)
672/// quantization. Symmetric quantization is indicated by an absent or
673/// all-zero `zero_point`.
674#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
675pub struct Quantization {
676    /// Scale factor(s). One element for per-tensor quantization, or one
677    /// element per slice for per-channel quantization.
678    #[serde(deserialize_with = "deserialize_scalar_or_vec_f32")]
679    pub scale: Vec<f32>,
680
681    /// Zero point offset(s). Omit or set to all-zero for symmetric
682    /// quantization. For per-channel quantization, length must match
683    /// `scale` length.
684    #[serde(
685        default,
686        deserialize_with = "deserialize_opt_scalar_or_vec_i32",
687        skip_serializing_if = "Option::is_none"
688    )]
689    pub zero_point: Option<Vec<i32>>,
690
691    /// Tensor dimension index that `scale`/`zero_point` arrays correspond
692    /// to. Required when per-channel; ignored otherwise.
693    #[serde(default, skip_serializing_if = "Option::is_none")]
694    pub axis: Option<usize>,
695
696    /// Quantized data type. Required on v2 metadata files; may be absent
697    /// on programmatically-constructed configurations where the dtype is
698    /// resolved at decode time from the actual tensor.
699    #[serde(default, skip_serializing_if = "Option::is_none")]
700    pub dtype: Option<DType>,
701}
702
703impl Quantization {
704    /// Returns `true` when per-tensor (scalar scale).
705    pub fn is_per_tensor(&self) -> bool {
706        self.scale.len() == 1
707    }
708
709    /// Returns `true` when per-channel (array scale of length > 1).
710    pub fn is_per_channel(&self) -> bool {
711        self.scale.len() > 1
712    }
713
714    /// Returns `true` when all zero points are 0 (or absent).
715    pub fn is_symmetric(&self) -> bool {
716        match &self.zero_point {
717            None => true,
718            Some(zps) => zps.iter().all(|&z| z == 0),
719        }
720    }
721
722    /// Returns the zero point for the given channel index, or 0 when the
723    /// quantization is symmetric.
724    pub fn zero_point_at(&self, channel: usize) -> i32 {
725        match &self.zero_point {
726            None => 0,
727            Some(zps) if zps.len() == 1 => zps[0],
728            Some(zps) => zps.get(channel).copied().unwrap_or(0),
729        }
730    }
731
732    /// Returns the scale for the given channel index.
733    pub fn scale_at(&self, channel: usize) -> f32 {
734        if self.scale.len() == 1 {
735            self.scale[0]
736        } else {
737            self.scale.get(channel).copied().unwrap_or(0.0)
738        }
739    }
740}
741
742/// Convert a schema-level `Quantization` (which also carries the quantized
743/// `dtype`) into the tensor-crate `edgefirst_tensor::Quantization` attached
744/// to a `Tensor<T>` at runtime. The `dtype` field is dropped — the tensor's
745/// `T` supplies the storage element type directly.
746///
747/// Returns `Err` on any length-mismatch / axis-out-of-range condition; the
748/// schema's `Quantization::validate_shape` has looser rules (accepts absent
749/// axis on per-channel) that the tensor crate does not. Callers should prefer
750/// the v2 parse path which normalizes.
751impl TryFrom<&Quantization> for edgefirst_tensor::Quantization {
752    type Error = edgefirst_tensor::Error;
753
754    fn try_from(q: &Quantization) -> Result<Self, Self::Error> {
755        match (q.scale.as_slice(), q.zero_point.as_deref(), q.axis) {
756            // Per-tensor symmetric: single scale, no zp, no axis.
757            ([scale], None, None) => Ok(Self::per_tensor_symmetric(*scale)),
758            // Per-tensor asymmetric: single scale, single zp.
759            ([scale], Some([zp]), None) => Ok(Self::per_tensor(*scale, *zp)),
760            // Per-tensor asymmetric with redundant axis — treat as per-tensor.
761            ([scale], Some([zp]), Some(_)) => Ok(Self::per_tensor(*scale, *zp)),
762            ([scale], None, Some(_)) => Ok(Self::per_tensor_symmetric(*scale)),
763            // Per-channel — axis required.
764            (scales, None, Some(axis)) if scales.len() > 1 => {
765                Self::per_channel_symmetric(scales.to_vec(), axis)
766            }
767            (scales, Some(zps), Some(axis)) if scales.len() > 1 => {
768                Self::per_channel(scales.to_vec(), zps.to_vec(), axis)
769            }
770            // Per-channel without axis — invalid.
771            (scales, _, None) if scales.len() > 1 => {
772                Err(edgefirst_tensor::Error::QuantizationInvalid {
773                    field: "axis",
774                    expected: "Some(axis) for per-channel".into(),
775                    got: "None".into(),
776                })
777            }
778            _ => Err(edgefirst_tensor::Error::QuantizationInvalid {
779                field: "scale",
780                expected: "non-empty".into(),
781                got: format!("len={}", q.scale.len()),
782            }),
783        }
784    }
785}
786
787/// Accept a scalar or a JSON array when deserializing a `Vec<f32>`.
788fn deserialize_scalar_or_vec_f32<'de, D>(de: D) -> Result<Vec<f32>, D::Error>
789where
790    D: serde::Deserializer<'de>,
791{
792    #[derive(Deserialize)]
793    #[serde(untagged)]
794    enum OneOrMany {
795        One(f32),
796        Many(Vec<f32>),
797    }
798    match OneOrMany::deserialize(de)? {
799        OneOrMany::One(v) => Ok(vec![v]),
800        OneOrMany::Many(vs) => Ok(vs),
801    }
802}
803
804/// Accept a scalar or a JSON array when deserializing an `Option<Vec<i32>>`.
805fn deserialize_opt_scalar_or_vec_i32<'de, D>(de: D) -> Result<Option<Vec<i32>>, D::Error>
806where
807    D: serde::Deserializer<'de>,
808{
809    #[derive(Deserialize)]
810    #[serde(untagged)]
811    enum OneOrMany {
812        One(i32),
813        Many(Vec<i32>),
814    }
815    match Option::<OneOrMany>::deserialize(de)? {
816        None => Ok(None),
817        Some(OneOrMany::One(v)) => Ok(Some(vec![v])),
818        Some(OneOrMany::Many(vs)) => Ok(Some(vs)),
819    }
820}
821
822/// FPN stride. `Square(s)` means `(s, s)`; `Rect(sx, sy)` supports
823/// non-square inputs.
824#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
825#[serde(untagged)]
826pub enum Stride {
827    Square(u32),
828    Rect([u32; 2]),
829}
830
831impl Stride {
832    /// Horizontal stride.
833    pub fn x(self) -> u32 {
834        match self {
835            Stride::Square(s) => s,
836            Stride::Rect([sx, _]) => sx,
837        }
838    }
839
840    /// Vertical stride.
841    pub fn y(self) -> u32 {
842        match self {
843            Stride::Square(s) => s,
844            Stride::Rect([_, sy]) => sy,
845        }
846    }
847}
848
849/// Semantic type of a logical output.
850#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
851#[serde(rename_all = "snake_case")]
852pub enum LogicalType {
853    /// Bounding box coordinates.
854    Boxes,
855    /// Per-class or class-aggregate scores.
856    Scores,
857    /// Objectness scores (YOLOv5-style `obj_x_class`).
858    Objectness,
859    /// End-to-end class indices.
860    Classes,
861    /// Mask coefficients for instance segmentation.
862    MaskCoefs,
863    /// Instance segmentation prototypes.
864    Protos,
865    /// Facial / keypoint landmarks.
866    Landmarks,
867    /// Fully decoded post-NMS detections (end-to-end models).
868    Detections,
869    /// Semantic segmentation per-pixel class scores (ModelPack, or any
870    /// per-pixel model with `decoder: semantic`).
871    Segmentation,
872    /// Semantic segmentation class map: argmax already applied in the
873    /// graph, one class index per pixel.
874    Masks,
875    /// ModelPack anchor-grid raw output requiring anchor decode.
876    Detection,
877    /// Mask-classification per-query class logits `[1, Q, K+1]`.
878    /// Reserved: described by the schema, not yet decoded by the HAL.
879    ClassLogits,
880    /// Mask-classification per-query mask logits `[1, Q, H, W]`.
881    /// Reserved: described by the schema, not yet decoded by the HAL.
882    MaskLogits,
883}
884
885/// Semantic type of a physical output.
886///
887/// Physical outputs either share their parent's type (per-scale splits
888/// carry the parent's name) or declare a channel sub-split such as
889/// `boxes_xy` / `boxes_wh`.
890#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
891#[serde(rename_all = "snake_case")]
892pub enum PhysicalType {
893    Boxes,
894    Scores,
895    Objectness,
896    Classes,
897    MaskCoefs,
898    Protos,
899    Landmarks,
900    Detections,
901    Segmentation,
902    Masks,
903    Detection,
904    ClassLogits,
905    MaskLogits,
906    /// ARA-2 xy channel sub-split.
907    BoxesXy,
908    /// ARA-2 wh channel sub-split.
909    BoxesWh,
910}
911
912/// Box encoding for `boxes` logical outputs.
913#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
914#[serde(rename_all = "snake_case")]
915pub enum BoxEncoding {
916    /// Distribution Focal Loss: `reg_max × 4` channels, softmax +
917    /// weighted sum recovers 4 coordinates (YOLOv8, YOLO11).
918    Dfl,
919    /// Direct 4-channel coordinates, already decoded (YOLO26,
920    /// ARA-2 post-split).
921    #[serde(alias = "ltrb")]
922    Direct,
923    /// Anchor-based grid offsets with sigmoid + anchor-scale transform
924    /// per cell (YOLOv5, SSD MobileNet, ModelPack).
925    Anchor,
926}
927
928/// Score format for `scores` logical outputs.
929#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
930#[serde(rename_all = "snake_case")]
931pub enum ScoreFormat {
932    /// Each anchor outputs `[nc]` class probabilities directly
933    /// (YOLOv8, YOLO11, YOLO26).
934    PerClass,
935    /// Each anchor outputs `[nc]` class probabilities; final confidence
936    /// is `objectness × class_score` via a separate `objectness` logical
937    /// output (YOLOv5).
938    ObjXClass,
939}
940
941/// Activation function applied to or required by a physical tensor.
942#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
943#[serde(rename_all = "snake_case")]
944pub enum Activation {
945    Sigmoid,
946    Softmax,
947    Tanh,
948}
949
950/// Decoder framework for a logical output.
951///
952/// Numbered by [`configs::DecoderType`], the declaration of this
953/// vocabulary; see [`DecoderKind::code`].
954#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
955#[repr(u32)]
956pub enum DecoderKind {
957    /// Au-Zone ModelPack anchor-based YOLO decoder.
958    #[serde(rename = "modelpack")]
959    ModelPack = configs::decoder_type_code::MODELPACK,
960    /// Ultralytics anchor-free DFL decoder (YOLOv5/v8/v11/v26).
961    #[serde(rename = "ultralytics")]
962    Ultralytics = configs::decoder_type_code::ULTRALYTICS,
963    /// Generic semantic segmentation decoder (per-pixel scores or class
964    /// map).
965    #[serde(rename = "semantic")]
966    Semantic = configs::decoder_type_code::SEMANTIC,
967}
968
969impl DecoderKind {
970    /// The shared code, identical to [`configs::DecoderType::code`].
971    pub const fn code(self) -> u32 {
972        self as u32
973    }
974}
975
976/// YOLO architecture version for Ultralytics decoders.
977///
978/// Numbered by [`configs::DecoderVersion`], the declaration of this
979/// vocabulary; see [`DecoderVersion::code`].
980#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
981#[serde(rename_all = "snake_case")]
982#[repr(u32)]
983pub enum DecoderVersion {
984    Yolov5 = configs::decoder_version_code::YOLOV5,
985    Yolov8 = configs::decoder_version_code::YOLOV8,
986    Yolo11 = configs::decoder_version_code::YOLO11,
987    Yolo26 = configs::decoder_version_code::YOLO26,
988}
989
990impl DecoderVersion {
991    /// The shared code, identical to [`configs::DecoderVersion::code`].
992    pub const fn code(self) -> u32 {
993        self as u32
994    }
995
996    /// Returns `true` for architectures with embedded NMS (YOLO26).
997    pub fn is_end_to_end(self) -> bool {
998        matches!(self, DecoderVersion::Yolo26)
999    }
1000}
1001
1002/// HAL NMS mode.
1003///
1004/// Numbered by [`configs::Nms`], the declaration of this vocabulary; see
1005/// [`NmsMode::code`].
1006#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1007#[serde(rename_all = "snake_case")]
1008#[repr(u32)]
1009pub enum NmsMode {
1010    /// Suppress overlapping boxes regardless of class label.
1011    ClassAgnostic = configs::nms_code::CLASS_AGNOSTIC,
1012    /// Only suppress boxes sharing a class label and overlapping above
1013    /// the IoU threshold.
1014    ClassAware = configs::nms_code::CLASS_AWARE,
1015}
1016
1017/// Quantized or floating-point data type of a model's logical I/O.
1018///
1019/// A subset of [`edgefirst_tensor::DType`], numbered and named by it: the
1020/// discriminant is that enum's code and the serialized string is its
1021/// [`as_str`](edgefirst_tensor::DType::as_str) (`"i8"`, `"u8"`, ...,
1022/// `"f32"`). The older spellings (`"int8"`, `"uint8"`, ..., `"float32"`)
1023/// are still accepted on input; see [`DType::from_str_code`].
1024#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1025#[repr(u32)]
1026pub enum DType {
1027    Int8 = edgefirst_tensor::protocol::dtype::I8,
1028    Uint8 = edgefirst_tensor::protocol::dtype::U8,
1029    Int16 = edgefirst_tensor::protocol::dtype::I16,
1030    Uint16 = edgefirst_tensor::protocol::dtype::U16,
1031    Int32 = edgefirst_tensor::protocol::dtype::I32,
1032    Uint32 = edgefirst_tensor::protocol::dtype::U32,
1033    Float16 = edgefirst_tensor::protocol::dtype::F16,
1034    Float32 = edgefirst_tensor::protocol::dtype::F32,
1035    /// Class maps from an in-graph argmax (TensorFlow/TFLite exports).
1036    Int64 = edgefirst_tensor::protocol::dtype::I64,
1037}
1038
1039/// The pre-`edgefirst_tensor::DType` spellings, accepted on input only.
1040const DTYPE_LEGACY_NAMES: [(&str, DType); 9] = [
1041    ("int8", DType::Int8),
1042    ("uint8", DType::Uint8),
1043    ("int16", DType::Int16),
1044    ("uint16", DType::Uint16),
1045    ("int32", DType::Int32),
1046    ("uint32", DType::Uint32),
1047    ("float16", DType::Float16),
1048    ("float32", DType::Float32),
1049    ("int64", DType::Int64),
1050];
1051
1052impl DType {
1053    /// Every variant, for exhaustive cross-surface tests.
1054    pub fn all() -> &'static [DType] {
1055        &[
1056            DType::Int8,
1057            DType::Uint8,
1058            DType::Int16,
1059            DType::Uint16,
1060            DType::Int32,
1061            DType::Uint32,
1062            DType::Float16,
1063            DType::Float32,
1064            DType::Int64,
1065        ]
1066    }
1067
1068    /// The shared code, identical to [`edgefirst_tensor::DType::code`].
1069    pub const fn code(self) -> u32 {
1070        self as u32
1071    }
1072
1073    /// The tensor dtype with the same code.
1074    pub fn to_tensor_dtype(self) -> edgefirst_tensor::DType {
1075        match self {
1076            DType::Int8 => edgefirst_tensor::DType::I8,
1077            DType::Uint8 => edgefirst_tensor::DType::U8,
1078            DType::Int16 => edgefirst_tensor::DType::I16,
1079            DType::Uint16 => edgefirst_tensor::DType::U16,
1080            DType::Int32 => edgefirst_tensor::DType::I32,
1081            DType::Uint32 => edgefirst_tensor::DType::U32,
1082            DType::Float16 => edgefirst_tensor::DType::F16,
1083            DType::Float32 => edgefirst_tensor::DType::F32,
1084            DType::Int64 => edgefirst_tensor::DType::I64,
1085        }
1086    }
1087
1088    /// The model I/O dtype for a tensor dtype; `None` for one a model's
1089    /// logical I/O does not carry (`u64`, `f64`).
1090    pub fn from_tensor_dtype(dtype: edgefirst_tensor::DType) -> Option<Self> {
1091        Self::all()
1092            .iter()
1093            .copied()
1094            .find(|d| d.code() == dtype.code())
1095    }
1096
1097    /// Decode a shared code; `None` for an unknown code or a tensor dtype a
1098    /// model's logical I/O does not carry.
1099    pub fn from_code(code: u32) -> Option<Self> {
1100        edgefirst_tensor::DType::from_code(code).and_then(Self::from_tensor_dtype)
1101    }
1102
1103    /// The string form, identical to [`edgefirst_tensor::DType::as_str`].
1104    pub fn as_str(self) -> &'static str {
1105        self.to_tensor_dtype().as_str()
1106    }
1107
1108    /// Parse the string form, also accepting the older `"int8"`, `"uint8"`,
1109    /// ..., `"float32"` spellings.
1110    pub fn from_str_code(s: &str) -> Option<Self> {
1111        edgefirst_tensor::DType::from_str_code(s)
1112            .and_then(Self::from_tensor_dtype)
1113            .or_else(|| {
1114                DTYPE_LEGACY_NAMES
1115                    .iter()
1116                    .find(|(name, _)| *name == s)
1117                    .map(|(_, d)| *d)
1118            })
1119    }
1120
1121    /// Every accepted input spelling, current and legacy.
1122    pub fn accepted_names() -> impl Iterator<Item = &'static str> {
1123        Self::all()
1124            .iter()
1125            .map(|d| d.as_str())
1126            .chain(DTYPE_LEGACY_NAMES.iter().map(|(name, _)| *name))
1127    }
1128}
1129
1130impl Serialize for DType {
1131    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1132        serializer.serialize_str(self.as_str())
1133    }
1134}
1135
1136impl<'de> Deserialize<'de> for DType {
1137    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1138        let s = std::borrow::Cow::<'de, str>::deserialize(deserializer)?;
1139        DType::from_str_code(&s).ok_or_else(|| {
1140            let expected: Vec<&str> = DType::accepted_names().collect();
1141            serde::de::Error::custom(format!(
1142                "unknown dtype `{s}`, expected one of: {}",
1143                expected.join(", ")
1144            ))
1145        })
1146    }
1147}
1148
1149impl DType {
1150    /// Returns the tensor's byte width per element.
1151    pub fn size_bytes(self) -> usize {
1152        match self {
1153            DType::Int8 | DType::Uint8 => 1,
1154            DType::Int16 | DType::Uint16 | DType::Float16 => 2,
1155            DType::Int32 | DType::Uint32 | DType::Float32 => 4,
1156            DType::Int64 => 8,
1157        }
1158    }
1159
1160    /// Returns `true` for integer dtypes (quantized tensors).
1161    pub fn is_integer(self) -> bool {
1162        matches!(
1163            self,
1164            DType::Int8
1165                | DType::Uint8
1166                | DType::Int16
1167                | DType::Uint16
1168                | DType::Int32
1169                | DType::Uint32
1170                | DType::Int64
1171        )
1172    }
1173
1174    /// Returns `true` for floating-point dtypes.
1175    pub fn is_float(self) -> bool {
1176        matches!(self, DType::Float16 | DType::Float32)
1177    }
1178}
1179
1180// =============================================================================
1181// Parsing entry points + legacy v1 compatibility shim.
1182// =============================================================================
1183
1184impl SchemaV2 {
1185    /// Parse schema metadata from a JSON string.
1186    ///
1187    /// Auto-detects the schema version from the `schema_version` field.
1188    /// Absent or `1` → legacy v1 metadata converted to v2 in memory.
1189    /// `2` → parsed as v2 directly. Any version higher than
1190    /// [`MAX_SUPPORTED_SCHEMA_VERSION`] is rejected with
1191    /// [`DecoderError::NotSupported`].
1192    pub fn parse_json(s: &str) -> DecoderResult<Self> {
1193        let value: serde_json::Value = serde_json::from_str(s)?;
1194        Self::from_json_value(value)
1195    }
1196
1197    /// Parse schema metadata from a YAML string.
1198    ///
1199    /// Same version-detection logic as [`SchemaV2::parse_json`].
1200    pub fn parse_yaml(s: &str) -> DecoderResult<Self> {
1201        let value: serde_yaml::Value = serde_yaml::from_str(s)?;
1202        let json = serde_json::to_value(value)
1203            .map_err(|e| DecoderError::InvalidConfig(format!("yaml→json bridge failed: {e}")))?;
1204        Self::from_json_value(json)
1205    }
1206
1207    /// Parse schema metadata from a file, auto-detecting JSON vs YAML
1208    /// from the file extension. Unknown extensions are parsed as JSON
1209    /// first then as YAML as a fallback.
1210    pub fn parse_file(path: impl AsRef<std::path::Path>) -> DecoderResult<Self> {
1211        let path = path.as_ref();
1212        let content = std::fs::read_to_string(path)
1213            .map_err(|e| DecoderError::InvalidConfig(format!("read {}: {e}", path.display())))?;
1214        let ext = path
1215            .extension()
1216            .and_then(|e| e.to_str())
1217            .map(str::to_ascii_lowercase);
1218        match ext.as_deref() {
1219            Some("json") => Self::parse_json(&content),
1220            Some("yaml") | Some("yml") => Self::parse_yaml(&content),
1221            _ => Self::parse_json(&content).or_else(|_| Self::parse_yaml(&content)),
1222        }
1223    }
1224
1225    /// Parse from an already-deserialized `serde_json::Value`. Useful for
1226    /// callers that have already done the initial deserialization step.
1227    pub fn from_json_value(value: serde_json::Value) -> DecoderResult<Self> {
1228        let version = value
1229            .get("schema_version")
1230            .and_then(|v| v.as_u64())
1231            .map(|v| v as u32)
1232            .unwrap_or(1);
1233
1234        if version > MAX_SUPPORTED_SCHEMA_VERSION {
1235            return Err(DecoderError::NotSupported(format!(
1236                "schema_version {version} is not supported by this HAL \
1237                 (maximum supported version is {MAX_SUPPORTED_SCHEMA_VERSION}); \
1238                 upgrade the HAL or downgrade the metadata"
1239            )));
1240        }
1241
1242        if version >= 2 {
1243            serde_json::from_value(value).map_err(DecoderError::Json)
1244        } else {
1245            let v1: ConfigOutputs = serde_json::from_value(value).map_err(DecoderError::Json)?;
1246            Self::from_v1(&v1)
1247        }
1248    }
1249
1250    /// Convert a legacy v1 [`ConfigOutputs`] to an equivalent v2
1251    /// [`SchemaV2`] in memory.
1252    ///
1253    /// The conversion preserves:
1254    /// - Output order and types (mapped to their v2 [`LogicalType`]).
1255    /// - Quantization (v1 `QuantTuple(scale, zp)` → v2 [`Quantization`] with
1256    ///   a single scalar scale/zero_point and unspecified dtype).
1257    /// - `dshape`, `shape`, `anchors`, `normalized` fields.
1258    /// - Root-level `nms` and `decoder_version`.
1259    ///
1260    /// Fields v1 does not carry (tensor `dtype`, per-channel quant, box
1261    /// encoding, score format, activation metadata, stride on physical
1262    /// children) are left as `None`. The v2 decoder is expected to infer
1263    /// these from the runtime tensor type and the legacy decoder
1264    /// dispatch rules.
1265    pub fn from_v1(v1: &ConfigOutputs) -> DecoderResult<Self> {
1266        let outputs = v1
1267            .outputs
1268            .iter()
1269            .map(logical_from_v1)
1270            .collect::<DecoderResult<Vec<_>>>()?;
1271        Ok(SchemaV2 {
1272            schema_version: 2,
1273            input: None,
1274            outputs,
1275            nms: v1.nms.as_ref().and_then(NmsMode::from_v1),
1276            nms_multi_label: v1.nms_multi_label,
1277            decoder_version: v1.decoder_version.as_ref().map(DecoderVersion::from_v1),
1278        })
1279    }
1280}
1281
1282impl SchemaV2 {
1283    /// Downconvert a v2 schema to a legacy [`ConfigOutputs`] for the
1284    /// v1 decoder dispatch path.
1285    ///
1286    /// Each [`LogicalOutput`] maps to one [`ConfigOutput`] variant
1287    /// selected by [`LogicalType`]; per-tensor scalar quantization
1288    /// becomes a `QuantTuple(scale, zp)`; and `decoder`, `anchors`,
1289    /// `normalized` are copied verbatim.
1290    ///
1291    /// This conversion does **not** reject logical outputs that also
1292    /// declare physical children (per-scale FPN splits, or channel
1293    /// sub-splits such as ARA-2 `boxes_xy` / `boxes_wh`). The returned
1294    /// legacy config captures the logical-level metadata, while the
1295    /// physical-to-logical merge is handled separately by the
1296    /// `DecodeProgram` that
1297    /// [`DecoderBuilder::build`](crate::DecoderBuilder::build)
1298    /// compiles alongside this legacy config.
1299    ///
1300    /// Returns [`DecoderError::NotSupported`] when the schema uses
1301    /// features the v1 decoder cannot express at the logical level:
1302    /// - Per-channel quantization arrays on a logical output.
1303    /// - `encoding: dfl` on a **flat** logical output (no physical
1304    ///   children). DFL combined with per-scale children is handled by
1305    ///   the merge path (see `DecodeProgram`)
1306    ///   which decodes the distribution before producing the merged
1307    ///   post-decode `(1, 4, total_anchors)` tensor the legacy decoder
1308    ///   consumes.
1309    pub fn to_legacy_config_outputs(&self) -> DecoderResult<ConfigOutputs> {
1310        let mut outputs = Vec::with_capacity(self.outputs.len());
1311        for logical in &self.outputs {
1312            // Typeless logical outputs are carried for round-tripping
1313            // but are not required for decoding. Skip them — the decoder
1314            // builder will surface its own "missing required output"
1315            // error if a decode-critical role (e.g. `boxes`) is absent,
1316            // which is more actionable than a serde "missing type" error.
1317            if logical.type_.is_none() {
1318                continue;
1319            }
1320            // Flat DFL (no children) remains unsupported — the HAL has
1321            // no path that applies softmax + dist2bbox to a single
1322            // `(1, 4·reg_max, anchors)` tensor yet. DFL with per-scale
1323            // children is decoded by the merge path, so we let it
1324            // through here and rely on the merged logical shape (post-
1325            // decode 4 channels) being valid for the legacy dispatch.
1326            if logical.type_ == Some(LogicalType::Boxes)
1327                && logical.encoding == Some(BoxEncoding::Dfl)
1328                && logical.outputs.is_empty()
1329            {
1330                return Err(DecoderError::NotSupported(format!(
1331                    "`boxes` output `{}` has `encoding: dfl` on a flat \
1332                     logical (no per-scale children); the HAL's DFL \
1333                     decode kernel only runs inside the per-scale merge \
1334                     path. Split the boxes output into per-FPN-level \
1335                     children (Hailo convention) or pre-decode to 4 \
1336                     channels in the model graph (TFLite convention).",
1337                    logical.name.as_deref().unwrap_or("<anonymous>"),
1338                )));
1339            }
1340            if let Some(q) = &logical.quantization {
1341                if q.is_per_channel() {
1342                    return Err(DecoderError::NotSupported(format!(
1343                        "logical `{}` uses per-channel quantization \
1344                         (axis {:?}, {} scales); the v1 decoder only \
1345                         supports per-tensor quantization",
1346                        logical.name.as_deref().unwrap_or("<anonymous>"),
1347                        q.axis,
1348                        q.scale.len(),
1349                    )));
1350                }
1351            }
1352            outputs.push(logical_to_legacy_config_output(logical)?);
1353        }
1354        Ok(ConfigOutputs {
1355            outputs,
1356            nms: self.nms.map(NmsMode::to_v1),
1357            nms_multi_label: self.nms_multi_label,
1358            decoder_version: self.decoder_version.map(|v| v.to_v1()),
1359        })
1360    }
1361
1362    /// Validate the schema against the rules the HAL enforces.
1363    ///
1364    /// Rules checked:
1365    /// - `schema_version` in `[1, MAX_SUPPORTED_SCHEMA_VERSION]`.
1366    /// - Physical children, when present, carry non-empty `name` fields
1367    ///   so tensor binding by name is unambiguous.
1368    /// - All physical children of a given logical output have pairwise
1369    ///   distinct shapes (shape-based binding safety, per HailoRT spec).
1370    /// - For a `boxes` logical output with `encoding: dfl`, every
1371    ///   physical child shape has a `num_features` (or last) dimension
1372    ///   divisible by 4 (the box-coordinate count).
1373    /// - Per-scale splits carry `stride` on every child.
1374    /// - Mixed per-scale + channel-sub-split decompositions are
1375    ///   rejected (the spec permits only one merge strategy per logical).
1376    /// - `end2end` models (decoder_version=yolo26 with `detections`
1377    ///   output) do not also declare per-scale split children on that
1378    ///   output.
1379    pub fn validate(&self) -> DecoderResult<()> {
1380        if self.schema_version == 0 || self.schema_version > MAX_SUPPORTED_SCHEMA_VERSION {
1381            return Err(DecoderError::InvalidConfig(format!(
1382                "schema_version {} outside supported range [1, {MAX_SUPPORTED_SCHEMA_VERSION}]",
1383                self.schema_version
1384            )));
1385        }
1386
1387        if let Some(input) = &self.input {
1388            if let Some(pre) = &input.preprocessing {
1389                validate_preprocessing(input, pre)?;
1390            }
1391        }
1392
1393        for logical in &self.outputs {
1394            validate_logical(logical)?;
1395        }
1396
1397        Ok(())
1398    }
1399}
1400
1401fn validate_preprocessing(input: &InputSpec, pre: &Preprocessing) -> DecoderResult<()> {
1402    let channels = input.channels();
1403    let mut vectors: Vec<(&str, &[f32])> = Vec::new();
1404    if let Some(Normalization::MeanStd { mean, std }) = &pre.normalization {
1405        vectors.push(("normalization mean", mean));
1406        vectors.push(("normalization std", std));
1407    }
1408    if let Some(pad) = pre.letterbox.as_ref().and_then(|l| l.pad.as_ref()) {
1409        vectors.push(("letterbox pad", pad));
1410    }
1411
1412    // A single value broadcasts; longer vectors must match the input
1413    // channels, or each other when the channel count is unknown.
1414    let mut expected = channels;
1415    for (field, values) in &vectors {
1416        if values.is_empty() || values.iter().any(|v| !v.is_finite()) {
1417            return Err(DecoderError::InvalidConfig(format!(
1418                "{field} {values:?} must hold one or more finite values"
1419            )));
1420        }
1421        if values.len() == 1 {
1422            continue;
1423        }
1424        match expected {
1425            Some(n) if n != values.len() => {
1426                return Err(DecoderError::InvalidConfig(format!(
1427                    "{field} has {} values; expected 1 or {n} (one per input channel)",
1428                    values.len()
1429                )));
1430            }
1431            Some(_) => {}
1432            None => expected = Some(values.len()),
1433        }
1434    }
1435
1436    if let Some(norm) = &pre.normalization {
1437        let n = match (norm, expected) {
1438            (_, Some(n)) => n,
1439            // Presets other than imagenet and scalar coefficients are
1440            // channel-agnostic; imagenet is RGB.
1441            (Normalization::Preset(NormalizationPreset::Imagenet), None) => 3,
1442            (_, None) => 1,
1443        };
1444        norm.mean_std(n)?;
1445    }
1446    Ok(())
1447}
1448
1449fn validate_logical(logical: &LogicalOutput) -> DecoderResult<()> {
1450    if logical.outputs.is_empty() {
1451        return Ok(());
1452    }
1453
1454    // All children must carry a name for unambiguous tensor binding.
1455    for child in &logical.outputs {
1456        if child.name.is_empty() {
1457            return Err(DecoderError::InvalidConfig(format!(
1458                "physical child of logical `{}` is missing `name`; name is \
1459                 required for tensor binding",
1460                logical.name.as_deref().unwrap_or("<anonymous>")
1461            )));
1462        }
1463    }
1464
1465    // Uniqueness of physical child shapes *within the same type* — two
1466    // children with distinct types (e.g. ARA-2 `boxes_xy` + `boxes_wh`)
1467    // may legitimately share shape, since type disambiguates the binding.
1468    //
1469    // Typeless children are excluded from this check: shape uniqueness
1470    // only matters when we need to bind a tensor by type+shape, and
1471    // typeless children are treated as opaque passthrough — name-based
1472    // binding still disambiguates them from each other.
1473    for (i, a) in logical.outputs.iter().enumerate() {
1474        for b in &logical.outputs[i + 1..] {
1475            let (Some(ta), Some(tb)) = (a.type_, b.type_) else {
1476                continue;
1477            };
1478            if a.shape == b.shape && ta == tb {
1479                return Err(DecoderError::InvalidConfig(format!(
1480                    "physical children `{}` and `{}` share shape {:?} and \
1481                     type; tensor binding cannot be resolved",
1482                    a.name, b.name, a.shape
1483                )));
1484            }
1485        }
1486    }
1487
1488    // Merge strategy must be uniform: either all children carry `stride`
1489    // (per-scale split) or none (channel sub-split). Mixed decompositions
1490    // are ill-defined.
1491    let strided: Vec<_> = logical.outputs.iter().map(|c| c.stride.is_some()).collect();
1492    let all_strided = strided.iter().all(|&b| b);
1493    let none_strided = strided.iter().all(|&b| !b);
1494    if !(all_strided || none_strided) {
1495        return Err(DecoderError::InvalidConfig(format!(
1496            "logical `{}` mixes per-scale children (with stride) and \
1497             channel sub-split children (without stride); decomposition \
1498             must be uniform",
1499            logical.name.as_deref().unwrap_or("<anonymous>")
1500        )));
1501    }
1502
1503    // DFL boxes: every child's feature axis must be divisible by 4
1504    // (otherwise `reg_max` is not an integer).
1505    if logical.type_ == Some(LogicalType::Boxes) && logical.encoding == Some(BoxEncoding::Dfl) {
1506        for child in &logical.outputs {
1507            if let Some(feat) = last_feature_axis(child) {
1508                if feat % 4 != 0 {
1509                    return Err(DecoderError::InvalidConfig(format!(
1510                        "DFL boxes child `{}` feature axis {feat} is not \
1511                         divisible by 4 (reg_max×4)",
1512                        child.name
1513                    )));
1514                }
1515            }
1516        }
1517    }
1518
1519    Ok(())
1520}
1521
1522/// Resolve the channel / feature count from a physical child's dshape
1523/// when present, otherwise from the last dimension of its shape.
1524pub(crate) fn last_feature_axis(child: &PhysicalOutput) -> Option<usize> {
1525    // Prefer explicit named dimensions: NumFeatures, NumClasses,
1526    // NumProtos, BoxCoords, NumAnchorsXFeatures.
1527    for (name, size) in &child.dshape {
1528        if matches!(
1529            name,
1530            DimName::NumFeatures
1531                | DimName::NumClasses
1532                | DimName::NumProtos
1533                | DimName::BoxCoords
1534                | DimName::NumAnchorsXFeatures
1535        ) {
1536            return Some(*size);
1537        }
1538    }
1539    child.shape.last().copied()
1540}
1541
1542fn quantization_from_v1(q: Option<QuantTuple>) -> Option<Quantization> {
1543    q.map(|QuantTuple(scale, zp)| Quantization {
1544        scale: vec![scale],
1545        zero_point: Some(vec![zp]),
1546        axis: None,
1547        dtype: None,
1548    })
1549}
1550
1551fn logical_from_v1(v1: &ConfigOutput) -> DecoderResult<LogicalOutput> {
1552    match v1 {
1553        ConfigOutput::Detection(d) => {
1554            // v1 Detection covers two semantic cases that v2 separates:
1555            //   - ModelPack anchor-grid (decoder=modelpack, anchors present)
1556            //   - YOLO legacy combined (decoder=ultralytics, no anchors)
1557            // Both map to v2 LogicalType::Detection; the decoder dispatch
1558            // still differentiates via the `decoder` + `anchors` fields.
1559            let encoding = match (d.decoder, d.anchors.is_some()) {
1560                (configs::DecoderType::ModelPack, true) => Some(BoxEncoding::Anchor),
1561                (configs::DecoderType::Ultralytics, _) => Some(BoxEncoding::Direct),
1562                // ModelPack without anchors — keep encoding unset; the
1563                // decoder may not need it.
1564                (configs::DecoderType::ModelPack, false) => None,
1565                (configs::DecoderType::Semantic, _) => None,
1566            };
1567            Ok(LogicalOutput {
1568                name: None,
1569                type_: Some(LogicalType::Detection),
1570                shape: d.shape.clone(),
1571                dshape: d.dshape.clone(),
1572                decoder: Some(DecoderKind::from_v1(d.decoder)),
1573                encoding,
1574                score_format: None,
1575                normalized: d.normalized,
1576                anchors: d.anchors.clone(),
1577                stride: None,
1578                dtype: None,
1579                quantization: quantization_from_v1(d.quantization),
1580                outputs: Vec::new(),
1581                activation_applied: None,
1582                activation_required: None,
1583                semantic: Default::default(),
1584            })
1585        }
1586        ConfigOutput::Boxes(b) => Ok(LogicalOutput {
1587            name: None,
1588            type_: Some(LogicalType::Boxes),
1589            shape: b.shape.clone(),
1590            dshape: b.dshape.clone(),
1591            decoder: Some(DecoderKind::from_v1(b.decoder)),
1592            // v1 boxes are always pre-decoded 4-channel (the legacy
1593            // convention). Explicitly declare Direct so the v2 dispatch
1594            // doesn't try DFL decoding on them.
1595            encoding: Some(BoxEncoding::Direct),
1596            score_format: None,
1597            normalized: b.normalized,
1598            anchors: None,
1599            stride: None,
1600            dtype: None,
1601            quantization: quantization_from_v1(b.quantization),
1602            outputs: Vec::new(),
1603            activation_applied: None,
1604            activation_required: None,
1605            semantic: Default::default(),
1606        }),
1607        ConfigOutput::Scores(s) => Ok(LogicalOutput {
1608            name: None,
1609            type_: Some(LogicalType::Scores),
1610            shape: s.shape.clone(),
1611            dshape: s.dshape.clone(),
1612            decoder: Some(DecoderKind::from_v1(s.decoder)),
1613            encoding: None,
1614            // v1 does not declare score format explicitly; assume per_class
1615            // (YOLOv8-style). YOLOv5 users must migrate to v2 to get
1616            // obj_x_class behaviour.
1617            score_format: Some(ScoreFormat::PerClass),
1618            normalized: None,
1619            anchors: None,
1620            stride: None,
1621            dtype: None,
1622            quantization: quantization_from_v1(s.quantization),
1623            outputs: Vec::new(),
1624            activation_applied: None,
1625            activation_required: None,
1626            semantic: Default::default(),
1627        }),
1628        ConfigOutput::Protos(p) => Ok(LogicalOutput {
1629            name: None,
1630            type_: Some(LogicalType::Protos),
1631            shape: p.shape.clone(),
1632            dshape: p.dshape.clone(),
1633            // protos are consumed directly; decoder field is informational.
1634            decoder: Some(DecoderKind::from_v1(p.decoder)),
1635            encoding: None,
1636            score_format: None,
1637            normalized: None,
1638            anchors: None,
1639            stride: None,
1640            dtype: None,
1641            quantization: quantization_from_v1(p.quantization),
1642            outputs: Vec::new(),
1643            activation_applied: None,
1644            activation_required: None,
1645            semantic: Default::default(),
1646        }),
1647        ConfigOutput::MaskCoefficients(m) => Ok(LogicalOutput {
1648            name: None,
1649            type_: Some(LogicalType::MaskCoefs),
1650            shape: m.shape.clone(),
1651            dshape: m.dshape.clone(),
1652            decoder: Some(DecoderKind::from_v1(m.decoder)),
1653            encoding: None,
1654            score_format: None,
1655            normalized: None,
1656            anchors: None,
1657            stride: None,
1658            dtype: None,
1659            quantization: quantization_from_v1(m.quantization),
1660            outputs: Vec::new(),
1661            activation_applied: None,
1662            activation_required: None,
1663            semantic: Default::default(),
1664        }),
1665        ConfigOutput::Segmentation(seg) => Ok(LogicalOutput {
1666            name: None,
1667            type_: Some(LogicalType::Segmentation),
1668            shape: seg.shape.clone(),
1669            dshape: seg.dshape.clone(),
1670            decoder: Some(DecoderKind::from_v1(seg.decoder)),
1671            encoding: None,
1672            score_format: None,
1673            normalized: None,
1674            anchors: None,
1675            stride: None,
1676            dtype: None,
1677            quantization: quantization_from_v1(seg.quantization),
1678            outputs: Vec::new(),
1679            activation_applied: None,
1680            activation_required: None,
1681            semantic: Default::default(),
1682        }),
1683        ConfigOutput::Mask(m) => Ok(LogicalOutput {
1684            name: None,
1685            type_: Some(LogicalType::Masks),
1686            shape: m.shape.clone(),
1687            dshape: m.dshape.clone(),
1688            decoder: Some(DecoderKind::from_v1(m.decoder)),
1689            encoding: None,
1690            score_format: None,
1691            normalized: None,
1692            anchors: None,
1693            stride: None,
1694            dtype: None,
1695            quantization: quantization_from_v1(m.quantization),
1696            outputs: Vec::new(),
1697            activation_applied: None,
1698            activation_required: None,
1699            semantic: Default::default(),
1700        }),
1701        ConfigOutput::Classes(c) => Ok(LogicalOutput {
1702            name: None,
1703            type_: Some(LogicalType::Classes),
1704            shape: c.shape.clone(),
1705            dshape: c.dshape.clone(),
1706            decoder: Some(DecoderKind::from_v1(c.decoder)),
1707            encoding: None,
1708            score_format: None,
1709            normalized: None,
1710            anchors: None,
1711            stride: None,
1712            dtype: None,
1713            quantization: quantization_from_v1(c.quantization),
1714            outputs: Vec::new(),
1715            activation_applied: None,
1716            activation_required: None,
1717            semantic: Default::default(),
1718        }),
1719    }
1720}
1721
1722impl DecoderKind {
1723    /// Convert a legacy v1 [`configs::DecoderType`] to a v2 [`DecoderKind`].
1724    pub fn from_v1(v: configs::DecoderType) -> Self {
1725        match v {
1726            configs::DecoderType::ModelPack => DecoderKind::ModelPack,
1727            configs::DecoderType::Ultralytics => DecoderKind::Ultralytics,
1728            configs::DecoderType::Semantic => DecoderKind::Semantic,
1729        }
1730    }
1731
1732    /// Convert back to the legacy v1 [`configs::DecoderType`].
1733    pub fn to_v1(self) -> configs::DecoderType {
1734        match self {
1735            DecoderKind::ModelPack => configs::DecoderType::ModelPack,
1736            DecoderKind::Ultralytics => configs::DecoderType::Ultralytics,
1737            DecoderKind::Semantic => configs::DecoderType::Semantic,
1738        }
1739    }
1740}
1741
1742impl DecoderVersion {
1743    /// Convert a legacy v1 [`configs::DecoderVersion`] to a v2 [`DecoderVersion`].
1744    pub fn from_v1(v: &configs::DecoderVersion) -> Self {
1745        match v {
1746            configs::DecoderVersion::Yolov5 => DecoderVersion::Yolov5,
1747            configs::DecoderVersion::Yolov8 => DecoderVersion::Yolov8,
1748            configs::DecoderVersion::Yolo11 => DecoderVersion::Yolo11,
1749            configs::DecoderVersion::Yolo26 => DecoderVersion::Yolo26,
1750        }
1751    }
1752
1753    /// Convert back to the legacy v1 [`configs::DecoderVersion`].
1754    pub fn to_v1(self) -> configs::DecoderVersion {
1755        match self {
1756            DecoderVersion::Yolov5 => configs::DecoderVersion::Yolov5,
1757            DecoderVersion::Yolov8 => configs::DecoderVersion::Yolov8,
1758            DecoderVersion::Yolo11 => configs::DecoderVersion::Yolo11,
1759            DecoderVersion::Yolo26 => configs::DecoderVersion::Yolo26,
1760        }
1761    }
1762}
1763
1764impl NmsMode {
1765    /// The shared code, identical to [`configs::Nms::code`].
1766    pub const fn code(self) -> u32 {
1767        self as u32
1768    }
1769
1770    /// Convert a legacy v1 [`configs::Nms`]; `Auto` defers to the builder default.
1771    pub fn from_v1(v: &configs::Nms) -> Option<Self> {
1772        match v {
1773            configs::Nms::Auto => None,
1774            configs::Nms::ClassAgnostic => Some(NmsMode::ClassAgnostic),
1775            configs::Nms::ClassAware => Some(NmsMode::ClassAware),
1776        }
1777    }
1778
1779    /// Convert back to the legacy v1 [`configs::Nms`].
1780    pub fn to_v1(self) -> configs::Nms {
1781        match self {
1782            NmsMode::ClassAgnostic => configs::Nms::ClassAgnostic,
1783            NmsMode::ClassAware => configs::Nms::ClassAware,
1784        }
1785    }
1786}
1787
1788/// Convert a quantized v2 [`Quantization`] to a v1 [`QuantTuple`]. Only
1789/// valid for per-tensor scalar quantization.
1790fn quantization_to_legacy(q: &Quantization) -> DecoderResult<QuantTuple> {
1791    if q.is_per_channel() {
1792        return Err(DecoderError::NotSupported(
1793            "per-channel quantization cannot be expressed as a v1 QuantTuple".into(),
1794        ));
1795    }
1796    let scale = *q.scale.first().unwrap_or(&0.0);
1797    let zp = q.zero_point_at(0);
1798    Ok(QuantTuple(scale, zp))
1799}
1800
1801/// Drop axes named `padding` (always size 1 per spec) from the given
1802/// shape/dshape pair. ARA-2 emits logical shapes like
1803/// `[1, 4, 8400, 1]` with a trailing `padding=1` dim to satisfy the
1804/// converter's rank requirements — the decoder only cares about the
1805/// semantic axes, so squeezing is safe.
1806pub(crate) fn squeeze_padding_dims(
1807    shape: Vec<usize>,
1808    dshape: Vec<(DimName, usize)>,
1809) -> (Vec<usize>, Vec<(DimName, usize)>) {
1810    // dshape is `#[serde(default)]`; a logical output without named dims
1811    // arrives here with an empty dshape. `zip` would stop at the shorter
1812    // iterator and silently truncate shape to `[]`, so short-circuit.
1813    if dshape.is_empty() {
1814        return (shape, dshape);
1815    }
1816    let keep: Vec<bool> = dshape
1817        .iter()
1818        .map(|(n, _)| !matches!(n, DimName::Padding))
1819        .collect();
1820    let shape = shape
1821        .into_iter()
1822        .zip(keep.iter())
1823        .filter_map(|(s, &k)| k.then_some(s))
1824        .collect();
1825    let dshape = dshape
1826        .into_iter()
1827        .zip(keep.iter())
1828        .filter_map(|(d, &k)| k.then_some(d))
1829        .collect();
1830    (shape, dshape)
1831}
1832
1833/// Return the list of axis indices in `dshape` that carry the
1834/// `padding` dim name. Indices are returned in descending order so
1835/// that `remove_axis` calls can be applied directly without tracking
1836/// index shifts.
1837pub(crate) fn padding_axes(dshape: &[(DimName, usize)]) -> Vec<usize> {
1838    let mut v: Vec<usize> = dshape
1839        .iter()
1840        .enumerate()
1841        .filter_map(|(i, (n, _))| matches!(n, DimName::Padding).then_some(i))
1842        .collect();
1843    v.sort_by(|a, b| b.cmp(a));
1844    v
1845}
1846
1847fn logical_to_legacy_config_output(logical: &LogicalOutput) -> DecoderResult<ConfigOutput> {
1848    let decoder = match logical.decoder {
1849        None => configs::DecoderType::Ultralytics,
1850        Some(DecoderKind::Semantic) => {
1851            return Err(DecoderError::NotSupported(format!(
1852                "logical `{}` uses decoder `Semantic`, which this HAL \
1853                 version does not decode",
1854                logical.name.as_deref().unwrap_or("<anonymous>")
1855            )))
1856        }
1857        Some(kind) => kind.to_v1(),
1858    };
1859    let quantization = logical
1860        .quantization
1861        .as_ref()
1862        .map(quantization_to_legacy)
1863        .transpose()?;
1864    // Squeeze explicit `padding` dims before handing to the legacy
1865    // dispatch: the v1 decoder's `verify_yolo_*` helpers require rank-3
1866    // shapes, but v2 metadata often carries an explicit `padding: 1`
1867    // axis (ARA-2). ModelPack boxes are validated as 4D, so keep
1868    // padding dims for ModelPack outputs.
1869    let (shape, dshape) = match logical.decoder {
1870        Some(DecoderKind::ModelPack) => (logical.shape.clone(), logical.dshape.clone()),
1871        _ => squeeze_padding_dims(logical.shape.clone(), logical.dshape.clone()),
1872    };
1873
1874    let ty = logical.type_.ok_or_else(|| {
1875        // Defense-in-depth: `to_legacy_config_outputs` already filters
1876        // typeless outputs, so reaching here means a new caller added a
1877        // bypass. Surface it as an internal error rather than panicking.
1878        DecoderError::InvalidConfig(format!(
1879            "logical output `{}` has no type; typeless outputs should be \
1880             filtered before legacy conversion",
1881            logical.name.as_deref().unwrap_or("<anonymous>")
1882        ))
1883    })?;
1884
1885    let ctx = LegacyConvertCtx {
1886        decoder,
1887        quantization,
1888        shape,
1889        dshape,
1890        normalized: logical.normalized,
1891        anchors: logical.anchors.clone(),
1892    };
1893
1894    match ty {
1895        LogicalType::Boxes => Ok(boxes_to_legacy(&ctx)),
1896        LogicalType::Scores => Ok(scores_to_legacy(&ctx)),
1897        LogicalType::Protos => Ok(protos_to_legacy(&ctx)),
1898        LogicalType::MaskCoefs => Ok(mask_coefs_to_legacy(&ctx)),
1899        LogicalType::Segmentation => Ok(segmentation_to_legacy(&ctx)),
1900        LogicalType::Masks => Ok(masks_to_legacy(&ctx)),
1901        LogicalType::Classes => Ok(classes_to_legacy(&ctx)),
1902        LogicalType::Detection | LogicalType::Detections => Ok(detection_to_legacy(&ctx)),
1903        // Objectness / Landmarks have no direct v1 equivalent; the v1
1904        // YOLOv5 path embedded objectness in the combined Detection.
1905        LogicalType::Objectness
1906        | LogicalType::Landmarks
1907        | LogicalType::ClassLogits
1908        | LogicalType::MaskLogits => Err(DecoderError::NotSupported(format!(
1909            "logical type {:?} has no legacy v1 equivalent; use the \
1910                 native v2 decoder path",
1911            ty
1912        ))),
1913    }
1914}
1915
1916/// Shared fields for per-`LogicalType` legacy conversion helpers.
1917struct LegacyConvertCtx {
1918    decoder: configs::DecoderType,
1919    quantization: Option<QuantTuple>,
1920    shape: Vec<usize>,
1921    dshape: Vec<(DimName, usize)>,
1922    normalized: Option<bool>,
1923    anchors: Option<Vec<[f32; 2]>>,
1924}
1925
1926fn boxes_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1927    ConfigOutput::Boxes(configs::Boxes {
1928        decoder: ctx.decoder,
1929        quantization: ctx.quantization,
1930        shape: ctx.shape.clone(),
1931        dshape: ctx.dshape.clone(),
1932        normalized: ctx.normalized,
1933    })
1934}
1935
1936fn scores_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1937    ConfigOutput::Scores(configs::Scores {
1938        decoder: ctx.decoder,
1939        quantization: ctx.quantization,
1940        shape: ctx.shape.clone(),
1941        dshape: ctx.dshape.clone(),
1942    })
1943}
1944
1945fn protos_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1946    ConfigOutput::Protos(configs::Protos {
1947        decoder: ctx.decoder,
1948        quantization: ctx.quantization,
1949        shape: ctx.shape.clone(),
1950        dshape: ctx.dshape.clone(),
1951    })
1952}
1953
1954fn mask_coefs_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1955    ConfigOutput::MaskCoefficients(configs::MaskCoefficients {
1956        decoder: ctx.decoder,
1957        quantization: ctx.quantization,
1958        shape: ctx.shape.clone(),
1959        dshape: ctx.dshape.clone(),
1960    })
1961}
1962
1963fn segmentation_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1964    ConfigOutput::Segmentation(configs::Segmentation {
1965        decoder: ctx.decoder,
1966        quantization: ctx.quantization,
1967        shape: ctx.shape.clone(),
1968        dshape: ctx.dshape.clone(),
1969    })
1970}
1971
1972fn masks_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1973    ConfigOutput::Mask(configs::Mask {
1974        decoder: ctx.decoder,
1975        quantization: ctx.quantization,
1976        shape: ctx.shape.clone(),
1977        dshape: ctx.dshape.clone(),
1978    })
1979}
1980
1981fn classes_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1982    ConfigOutput::Classes(configs::Classes {
1983        decoder: ctx.decoder,
1984        quantization: ctx.quantization,
1985        shape: ctx.shape.clone(),
1986        dshape: ctx.dshape.clone(),
1987    })
1988}
1989
1990fn detection_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1991    // Detection covers ModelPack anchor-grid and legacy YOLO combined.
1992    // Detections (plural) is end-to-end; maps to Detection with the
1993    // appropriate dimension layout.
1994    ConfigOutput::Detection(configs::Detection {
1995        anchors: ctx.anchors.clone(),
1996        decoder: ctx.decoder,
1997        quantization: ctx.quantization,
1998        shape: ctx.shape.clone(),
1999        dshape: ctx.dshape.clone(),
2000        normalized: ctx.normalized,
2001    })
2002}
2003
2004#[cfg(test)]
2005#[cfg_attr(coverage_nightly, coverage(off))]
2006mod tests {
2007    use super::*;
2008
2009    #[test]
2010    fn nms_multi_label_true_round_trips_through_json() {
2011        let json = r#"{
2012            "schema_version": 2,
2013            "nms": "class_aware",
2014            "nms_multi_label": true,
2015            "outputs": [{
2016                "name": "output0", "type": "detection",
2017                "shape": [1, 84, 8400],
2018                "dshape": [{"batch": 1}, {"num_features": 84}, {"num_boxes": 8400}],
2019                "dtype": "float32", "decoder": "ultralytics", "encoding": "direct",
2020                "normalized": true
2021            }]
2022        }"#;
2023        let schema = SchemaV2::parse_json(json).unwrap();
2024        assert_eq!(schema.nms_multi_label, Some(true));
2025
2026        let text = serde_json::to_string(&schema).unwrap();
2027        let value: serde_json::Value = serde_json::from_str(&text).unwrap();
2028        assert_eq!(value["nms_multi_label"], serde_json::json!(true));
2029        let again = SchemaV2::parse_json(&text).unwrap();
2030        assert_eq!(again, schema);
2031
2032        let legacy = schema.to_legacy_config_outputs().unwrap();
2033        assert_eq!(legacy.nms_multi_label, Some(true));
2034        let back = SchemaV2::from_v1(&legacy).unwrap();
2035        assert_eq!(back.nms_multi_label, Some(true));
2036    }
2037
2038    #[test]
2039    fn schema_default_is_v2() {
2040        let s = SchemaV2::default();
2041        assert_eq!(s.schema_version, 2);
2042        assert!(s.outputs.is_empty());
2043    }
2044
2045    #[test]
2046    fn fixtures_round_trip_through_serde() {
2047        let yolov8 =
2048            edgefirst_bench::testdata::read_to_string("per_scale/synthetic_yolov8n_schema.json");
2049        let _: super::SchemaV2 = serde_json::from_str(&yolov8).expect("yolov8n fixture must parse");
2050
2051        let yolo26 =
2052            edgefirst_bench::testdata::read_to_string("per_scale/synthetic_yolo26n_schema.json");
2053        let _: super::SchemaV2 = serde_json::from_str(&yolo26).expect("yolo26n fixture must parse");
2054
2055        let flat =
2056            edgefirst_bench::testdata::read_to_string("per_scale/synthetic_flat_schema.json");
2057        let _: super::SchemaV2 = serde_json::from_str(&flat).expect("flat fixture must parse");
2058    }
2059
2060    #[test]
2061    fn box_encoding_accepts_ltrb_alias_for_direct() {
2062        let dfl: BoxEncoding = serde_json::from_str("\"dfl\"").unwrap();
2063        assert_eq!(dfl, BoxEncoding::Dfl);
2064
2065        let direct: BoxEncoding = serde_json::from_str("\"direct\"").unwrap();
2066        assert_eq!(direct, BoxEncoding::Direct);
2067
2068        // yolo26 metadata uses "ltrb" — must deserialise to Direct.
2069        let ltrb: BoxEncoding = serde_json::from_str("\"ltrb\"").unwrap();
2070        assert_eq!(ltrb, BoxEncoding::Direct);
2071    }
2072
2073    #[test]
2074    fn dtype_roundtrip() {
2075        for &d in DType::all() {
2076            let j = serde_json::to_string(&d).unwrap();
2077            let back: DType = serde_json::from_str(&j).unwrap();
2078            assert_eq!(back, d);
2079        }
2080    }
2081
2082    #[test]
2083    fn dtype_is_numbered_and_named_by_the_tensor_dtype() {
2084        for &d in DType::all() {
2085            let t = d.to_tensor_dtype();
2086            assert_eq!(d.code(), t.code(), "{d:?}");
2087            assert_eq!(d.as_str(), t.as_str(), "{d:?}");
2088            assert_eq!(DType::from_code(t.code()), Some(d));
2089            assert_eq!(DType::from_tensor_dtype(t), Some(d));
2090            assert_eq!(
2091                serde_json::to_string(&d).unwrap(),
2092                format!("\"{}\"", t.as_str())
2093            );
2094        }
2095        // `u64` and `f64` are not model I/O dtypes.
2096        for t in [edgefirst_tensor::DType::U64, edgefirst_tensor::DType::F64] {
2097            assert_eq!(DType::from_tensor_dtype(t), None);
2098            assert_eq!(DType::from_code(t.code()), None);
2099            assert_eq!(DType::from_str_code(t.as_str()), None);
2100        }
2101        assert_eq!(DType::from_code(u32::MAX), None);
2102    }
2103
2104    #[test]
2105    fn dtype_accepts_every_legacy_name() {
2106        let legacy = [
2107            ("int8", DType::Int8),
2108            ("uint8", DType::Uint8),
2109            ("int16", DType::Int16),
2110            ("uint16", DType::Uint16),
2111            ("int32", DType::Int32),
2112            ("uint32", DType::Uint32),
2113            ("float16", DType::Float16),
2114            ("float32", DType::Float32),
2115            ("int64", DType::Int64),
2116        ];
2117        assert_eq!(legacy.len(), DType::all().len());
2118        for (name, d) in legacy {
2119            assert_eq!(DType::from_str_code(name), Some(d), "{name}");
2120            let back: DType = serde_json::from_str(&format!("\"{name}\"")).unwrap();
2121            assert_eq!(back, d, "{name}");
2122            // Accepted on input, never produced on output.
2123            assert_ne!(d.as_str(), name);
2124        }
2125        assert_eq!(DType::accepted_names().count(), 2 * DType::all().len());
2126        assert!(serde_json::from_str::<DType>("\"float64\"").is_err());
2127        assert!(serde_json::from_str::<DType>("\"Int8\"").is_err());
2128    }
2129
2130    #[test]
2131    fn schema_enums_take_their_codes_from_the_config_vocabularies() {
2132        for v in [
2133            DecoderVersion::Yolov5,
2134            DecoderVersion::Yolov8,
2135            DecoderVersion::Yolo11,
2136            DecoderVersion::Yolo26,
2137        ] {
2138            assert_eq!(v.code(), v.to_v1().code(), "{v:?}");
2139        }
2140        for m in [NmsMode::ClassAgnostic, NmsMode::ClassAware] {
2141            assert_eq!(m.code(), m.to_v1().code(), "{m:?}");
2142        }
2143        for &v in configs::DecoderType::all() {
2144            let k = DecoderKind::from_v1(v);
2145            assert_eq!(k.code(), v.code(), "{k:?}");
2146            assert_eq!(k.to_v1(), v, "{k:?}");
2147        }
2148    }
2149
2150    #[test]
2151    fn dtype_widths() {
2152        assert_eq!(DType::Int8.size_bytes(), 1);
2153        assert_eq!(DType::Float16.size_bytes(), 2);
2154        assert_eq!(DType::Float32.size_bytes(), 4);
2155    }
2156
2157    #[test]
2158    fn stride_accepts_scalar_or_pair() {
2159        let a: Stride = serde_json::from_str("8").unwrap();
2160        let b: Stride = serde_json::from_str("[8, 16]").unwrap();
2161        assert_eq!(a, Stride::Square(8));
2162        assert_eq!(b, Stride::Rect([8, 16]));
2163        assert_eq!(a.x(), 8);
2164        assert_eq!(a.y(), 8);
2165        assert_eq!(b.x(), 8);
2166        assert_eq!(b.y(), 16);
2167    }
2168
2169    #[test]
2170    fn quantization_scalar_scale() {
2171        let j = r#"{"scale": 0.00392, "zero_point": 0, "dtype": "int8"}"#;
2172        let q: Quantization = serde_json::from_str(j).unwrap();
2173        assert!(q.is_per_tensor());
2174        assert!(q.is_symmetric());
2175        assert_eq!(q.scale_at(0), 0.00392);
2176        assert_eq!(q.scale_at(5), 0.00392);
2177        assert_eq!(q.zero_point_at(0), 0);
2178    }
2179
2180    #[test]
2181    fn quantization_per_channel() {
2182        let j = r#"{"scale": [0.054, 0.089, 0.195], "axis": 0, "dtype": "int8"}"#;
2183        let q: Quantization = serde_json::from_str(j).unwrap();
2184        assert!(q.is_per_channel());
2185        assert!(q.is_symmetric());
2186        assert_eq!(q.axis, Some(0));
2187        assert_eq!(q.scale_at(0), 0.054);
2188        assert_eq!(q.scale_at(2), 0.195);
2189    }
2190
2191    #[test]
2192    fn quantization_asymmetric_per_tensor() {
2193        let j = r#"{"scale": 0.176, "zero_point": 198, "dtype": "uint8"}"#;
2194        let q: Quantization = serde_json::from_str(j).unwrap();
2195        assert!(!q.is_symmetric());
2196        assert_eq!(q.zero_point_at(0), 198);
2197        assert_eq!(q.zero_point_at(10), 198);
2198    }
2199
2200    #[test]
2201    fn quantization_symmetric_default_zero_point() {
2202        let j = r#"{"scale": 0.00392, "dtype": "int8"}"#;
2203        let q: Quantization = serde_json::from_str(j).unwrap();
2204        assert!(q.is_symmetric());
2205        assert_eq!(q.zero_point_at(0), 0);
2206    }
2207
2208    #[test]
2209    fn quantization_to_tensor_per_tensor_asymmetric() {
2210        let q = Quantization {
2211            scale: vec![0.1],
2212            zero_point: Some(vec![-5]),
2213            axis: None,
2214            dtype: Some(DType::Int8),
2215        };
2216        let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
2217        assert!(t.is_per_tensor());
2218        assert!(!t.is_symmetric());
2219        assert_eq!(t.scale(), &[0.1]);
2220        assert_eq!(t.zero_point(), Some(&[-5][..]));
2221    }
2222
2223    #[test]
2224    fn quantization_to_tensor_per_tensor_symmetric() {
2225        let q = Quantization {
2226            scale: vec![0.05],
2227            zero_point: None,
2228            axis: None,
2229            dtype: Some(DType::Int8),
2230        };
2231        let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
2232        assert!(t.is_per_tensor());
2233        assert!(t.is_symmetric());
2234    }
2235
2236    #[test]
2237    fn quantization_to_tensor_per_channel_asymmetric() {
2238        let q = Quantization {
2239            scale: vec![0.1, 0.2, 0.3],
2240            zero_point: Some(vec![-1, 0, 1]),
2241            axis: Some(2),
2242            dtype: Some(DType::Int8),
2243        };
2244        let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
2245        assert!(t.is_per_channel());
2246        assert_eq!(t.axis(), Some(2));
2247        assert_eq!(t.scale().len(), 3);
2248        assert_eq!(t.zero_point().map(|z| z.len()), Some(3));
2249    }
2250
2251    #[test]
2252    fn quantization_to_tensor_per_channel_symmetric() {
2253        let q = Quantization {
2254            scale: vec![0.054, 0.089, 0.195],
2255            zero_point: None,
2256            axis: Some(0),
2257            dtype: Some(DType::Int8),
2258        };
2259        let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
2260        assert!(t.is_per_channel());
2261        assert!(t.is_symmetric());
2262        assert_eq!(t.axis(), Some(0));
2263    }
2264
2265    #[test]
2266    fn quantization_to_tensor_per_channel_missing_axis_errors() {
2267        let q = Quantization {
2268            scale: vec![0.1, 0.2, 0.3],
2269            zero_point: None,
2270            axis: None,
2271            dtype: None,
2272        };
2273        let err = edgefirst_tensor::Quantization::try_from(&q).unwrap_err();
2274        // The field matters: the per-channel-without-axis arm is what should
2275        // reject this. Matching only on the variant also accepts the catch-all
2276        // "scale" arm, which would mean the arm above never ran.
2277        assert!(matches!(
2278            err,
2279            edgefirst_tensor::Error::QuantizationInvalid { field: "axis", .. }
2280        ));
2281    }
2282
2283    /// Both redundant-axis arms map to per-tensor. Losing either one drops the
2284    /// conversion through to the catch-all and turns a valid configuration
2285    /// into an error.
2286    #[test]
2287    fn quantization_to_tensor_per_tensor_ignores_a_redundant_axis() {
2288        let q = Quantization {
2289            scale: vec![0.5],
2290            zero_point: Some(vec![3]),
2291            axis: Some(0),
2292            dtype: Some(DType::Int8),
2293        };
2294        let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
2295        assert!(t.is_per_tensor());
2296        assert_eq!(t.scale(), &[0.5][..]);
2297        assert_eq!(t.zero_point(), Some(&[3][..]));
2298    }
2299
2300    #[test]
2301    fn quantization_to_tensor_symmetric_ignores_a_redundant_axis() {
2302        let q = Quantization {
2303            scale: vec![0.5],
2304            zero_point: None,
2305            axis: Some(0),
2306            dtype: Some(DType::Int8),
2307        };
2308        let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
2309        assert!(t.is_per_tensor());
2310        assert!(t.is_symmetric());
2311        assert_eq!(t.scale(), &[0.5][..]);
2312    }
2313
2314    /// An empty scale is neither per-tensor nor per-channel, so it must reach
2315    /// the catch-all and name `scale`. Each of these reaches it down a
2316    /// different path: no axis, an axis, and an axis with zero points.
2317    #[test]
2318    fn quantization_to_tensor_empty_scale_errors_on_scale() {
2319        for (zero_point, axis) in [
2320            (None, None),
2321            (None, Some(0)),
2322            (Some(vec![]), Some(0)),
2323            (Some(vec![]), None),
2324        ] {
2325            let q = Quantization {
2326                scale: vec![],
2327                zero_point,
2328                axis,
2329                dtype: Some(DType::Int8),
2330            };
2331            let err = edgefirst_tensor::Quantization::try_from(&q).unwrap_err();
2332            assert!(
2333                matches!(
2334                    err,
2335                    edgefirst_tensor::Error::QuantizationInvalid { field: "scale", .. }
2336                ),
2337                "axis={axis:?} gave {err:?}"
2338            );
2339        }
2340    }
2341
2342    /// One scale with two zero points is not per-tensor (the zero point is not
2343    /// a single element) and not per-channel (there is one scale), so it is
2344    /// the catch-all's job with or without an axis.
2345    #[test]
2346    fn quantization_to_tensor_single_scale_with_many_zero_points_errors_on_scale() {
2347        for axis in [None, Some(0)] {
2348            let q = Quantization {
2349                scale: vec![0.5],
2350                zero_point: Some(vec![1, 2]),
2351                axis,
2352                dtype: Some(DType::Int8),
2353            };
2354            let err = edgefirst_tensor::Quantization::try_from(&q).unwrap_err();
2355            assert!(
2356                matches!(
2357                    err,
2358                    edgefirst_tensor::Error::QuantizationInvalid { field: "scale", .. }
2359                ),
2360                "axis={axis:?} gave {err:?}"
2361            );
2362        }
2363    }
2364
2365    #[test]
2366    fn per_channel_quantization_is_not_per_tensor() {
2367        let q = Quantization {
2368            scale: vec![0.1, 0.2, 0.3],
2369            zero_point: None,
2370            axis: Some(0),
2371            dtype: Some(DType::Int8),
2372        };
2373        assert!(q.is_per_channel());
2374        assert!(!q.is_per_tensor());
2375
2376        let per_tensor = Quantization {
2377            scale: vec![0.1],
2378            zero_point: None,
2379            axis: None,
2380            dtype: Some(DType::Int8),
2381        };
2382        assert!(per_tensor.is_per_tensor());
2383        assert!(!per_tensor.is_per_channel());
2384    }
2385
2386    /// A single zero point applies to every channel; a per-channel list is
2387    /// indexed. Treating the list as if it had one element would return
2388    /// channel 0's zero point for every channel.
2389    #[test]
2390    fn zero_point_at_indexes_per_channel_zero_points() {
2391        let per_channel = Quantization {
2392            scale: vec![0.1, 0.2, 0.3],
2393            zero_point: Some(vec![10, 20, 30]),
2394            axis: Some(0),
2395            dtype: Some(DType::Int8),
2396        };
2397        assert_eq!(per_channel.zero_point_at(0), 10);
2398        assert_eq!(per_channel.zero_point_at(1), 20);
2399        assert_eq!(per_channel.zero_point_at(2), 30);
2400        // Out of range falls back to 0 rather than panicking.
2401        assert_eq!(per_channel.zero_point_at(3), 0);
2402
2403        let shared = Quantization {
2404            scale: vec![0.1, 0.2, 0.3],
2405            zero_point: Some(vec![7]),
2406            axis: Some(0),
2407            dtype: Some(DType::Int8),
2408        };
2409        assert_eq!(shared.zero_point_at(0), 7);
2410        assert_eq!(shared.zero_point_at(2), 7);
2411
2412        let symmetric = Quantization {
2413            scale: vec![0.1],
2414            zero_point: None,
2415            axis: None,
2416            dtype: Some(DType::Int8),
2417        };
2418        assert_eq!(symmetric.zero_point_at(0), 0);
2419    }
2420
2421    #[test]
2422    fn logical_output_flat_tflite_boxes() {
2423        // Example 3 from the spec: TFLite YOLOv8 detection, flat boxes
2424        let j = r#"{
2425          "name": "boxes", "type": "boxes",
2426          "shape": [1, 64, 8400],
2427          "dshape": [{"batch": 1}, {"num_features": 64}, {"num_boxes": 8400}],
2428          "dtype": "int8",
2429          "quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "int8"},
2430          "decoder": "ultralytics",
2431          "encoding": "dfl",
2432          "normalized": true
2433        }"#;
2434        let lo: LogicalOutput = serde_json::from_str(j).unwrap();
2435        assert_eq!(lo.type_, Some(LogicalType::Boxes));
2436        assert_eq!(lo.encoding, Some(BoxEncoding::Dfl));
2437        assert_eq!(lo.normalized, Some(true));
2438        assert!(!lo.is_split());
2439        assert_eq!(lo.dtype, Some(DType::Int8));
2440    }
2441
2442    #[test]
2443    fn logical_output_hailo_per_scale_split() {
2444        // Example 5 from the spec: Hailo YOLOv8 boxes, per-scale split
2445        let j = r#"{
2446          "name": "boxes", "type": "boxes",
2447          "shape": [1, 64, 8400],
2448          "encoding": "dfl", "decoder": "ultralytics", "normalized": true,
2449          "outputs": [
2450            {
2451              "name": "boxes_0", "type": "boxes",
2452              "stride": 8, "scale_index": 0,
2453              "shape": [1, 80, 80, 64],
2454              "dshape": [{"batch": 1}, {"height": 80}, {"width": 80}, {"num_features": 64}],
2455              "dtype": "uint8",
2456              "quantization": {"scale": 0.0234, "zero_point": 128, "dtype": "uint8"}
2457            }
2458          ]
2459        }"#;
2460        let lo: LogicalOutput = serde_json::from_str(j).unwrap();
2461        assert!(lo.is_split());
2462        assert_eq!(lo.outputs.len(), 1);
2463        let child = &lo.outputs[0];
2464        assert_eq!(child.name, "boxes_0");
2465        assert_eq!(child.type_, Some(PhysicalType::Boxes));
2466        assert_eq!(child.stride, Some(Stride::Square(8)));
2467        assert_eq!(child.scale_index, Some(0));
2468        assert_eq!(child.dtype, DType::Uint8);
2469    }
2470
2471    #[test]
2472    fn logical_output_ara2_xy_wh_channel_split() {
2473        // Example 4 from the spec: ARA-2 boxes split into xy/wh
2474        let j = r#"{
2475          "name": "boxes", "type": "boxes",
2476          "shape": [1, 4, 8400, 1],
2477          "encoding": "direct", "decoder": "ultralytics", "normalized": true,
2478          "outputs": [
2479            {
2480              "name": "_model_22_Div_1_output_0", "type": "boxes_xy",
2481              "shape": [1, 2, 8400, 1],
2482              "dshape": [{"batch": 1}, {"box_coords": 2}, {"num_boxes": 8400}, {"padding": 1}],
2483              "dtype": "int16",
2484              "quantization": {"scale": 3.129e-5, "zero_point": 0, "dtype": "int16"}
2485            },
2486            {
2487              "name": "_model_22_Sub_1_output_0", "type": "boxes_wh",
2488              "shape": [1, 2, 8400, 1],
2489              "dshape": [{"batch": 1}, {"box_coords": 2}, {"num_boxes": 8400}, {"padding": 1}],
2490              "dtype": "int16",
2491              "quantization": {"scale": 3.149e-5, "zero_point": 0, "dtype": "int16"}
2492            }
2493          ]
2494        }"#;
2495        let lo: LogicalOutput = serde_json::from_str(j).unwrap();
2496        assert_eq!(lo.encoding, Some(BoxEncoding::Direct));
2497        assert_eq!(lo.outputs.len(), 2);
2498        assert_eq!(lo.outputs[0].type_, Some(PhysicalType::BoxesXy));
2499        assert_eq!(lo.outputs[1].type_, Some(PhysicalType::BoxesWh));
2500        assert!(lo.outputs[0].stride.is_none());
2501        assert!(lo.outputs[1].stride.is_none());
2502    }
2503
2504    #[test]
2505    fn logical_output_hailo_scores_sigmoid_applied() {
2506        let j = r#"{
2507          "name": "scores", "type": "scores",
2508          "shape": [1, 80, 8400],
2509          "decoder": "ultralytics", "score_format": "per_class",
2510          "outputs": [
2511            {
2512              "name": "scores_0", "type": "scores",
2513              "stride": 8, "scale_index": 0,
2514              "shape": [1, 80, 80, 80],
2515              "dshape": [{"batch": 1}, {"height": 80}, {"width": 80}, {"num_classes": 80}],
2516              "dtype": "uint8",
2517              "quantization": {"scale": 0.003922, "dtype": "uint8"},
2518              "activation_applied": "sigmoid"
2519            }
2520          ]
2521        }"#;
2522        let lo: LogicalOutput = serde_json::from_str(j).unwrap();
2523        assert_eq!(lo.score_format, Some(ScoreFormat::PerClass));
2524        let child = &lo.outputs[0];
2525        assert_eq!(child.activation_applied, Some(Activation::Sigmoid));
2526        assert!(child.activation_required.is_none());
2527    }
2528
2529    #[test]
2530    fn yolo26_end_to_end_detections() {
2531        let j = r#"{
2532          "schema_version": 2,
2533          "decoder_version": "yolo26",
2534          "outputs": [{
2535            "name": "output0", "type": "detections",
2536            "shape": [1, 100, 6],
2537            "dshape": [{"batch": 1}, {"num_boxes": 100}, {"num_features": 6}],
2538            "dtype": "int8",
2539            "quantization": {"scale": 0.0078, "zero_point": 0, "dtype": "int8"},
2540            "normalized": false,
2541            "decoder": "ultralytics"
2542          }]
2543        }"#;
2544        let s: SchemaV2 = serde_json::from_str(j).unwrap();
2545        assert_eq!(s.decoder_version, Some(DecoderVersion::Yolo26));
2546        assert!(s.decoder_version.unwrap().is_end_to_end());
2547        assert_eq!(s.outputs[0].type_, Some(LogicalType::Detections));
2548        assert_eq!(s.outputs[0].normalized, Some(false));
2549        assert!(s.nms.is_none());
2550    }
2551
2552    #[test]
2553    fn modelpack_anchor_detection_with_rect_stride() {
2554        let j = r#"{
2555          "schema_version": 2,
2556          "outputs": [{
2557            "name": "output_0", "type": "detection",
2558            "shape": [1, 40, 40, 54],
2559            "dshape": [{"batch": 1}, {"height": 40}, {"width": 40}, {"num_anchors_x_features": 54}],
2560            "dtype": "uint8",
2561            "quantization": {"scale": 0.176, "zero_point": 198, "dtype": "uint8"},
2562            "decoder": "modelpack",
2563            "encoding": "anchor",
2564            "stride": [16, 16],
2565            "anchors": [[0.054, 0.065], [0.089, 0.139], [0.195, 0.196]]
2566          }]
2567        }"#;
2568        let s: SchemaV2 = serde_json::from_str(j).unwrap();
2569        let lo = &s.outputs[0];
2570        assert_eq!(lo.encoding, Some(BoxEncoding::Anchor));
2571        assert_eq!(lo.stride, Some(Stride::Rect([16, 16])));
2572        assert_eq!(lo.anchors.as_ref().map(|a| a.len()), Some(3));
2573    }
2574
2575    #[test]
2576    fn yolov5_obj_x_class_objectness_logical() {
2577        let j = r#"{
2578          "name": "objectness", "type": "objectness",
2579          "shape": [1, 3, 8400],
2580          "decoder": "ultralytics",
2581          "outputs": [{
2582            "name": "objectness_0", "type": "objectness",
2583            "stride": 8, "scale_index": 0,
2584            "shape": [1, 80, 80, 3],
2585            "dshape": [{"batch": 1}, {"height": 80}, {"width": 80}, {"num_features": 3}],
2586            "dtype": "uint8",
2587            "quantization": {"scale": 0.0039, "zero_point": 0, "dtype": "uint8"},
2588            "activation_applied": "sigmoid"
2589          }]
2590        }"#;
2591        let lo: LogicalOutput = serde_json::from_str(j).unwrap();
2592        assert_eq!(lo.type_, Some(LogicalType::Objectness));
2593        assert_eq!(lo.outputs[0].activation_applied, Some(Activation::Sigmoid));
2594    }
2595
2596    #[test]
2597    fn direct_protos_no_decoder() {
2598        // protos are consumed directly — no `decoder` field
2599        let j = r#"{
2600          "name": "protos", "type": "protos",
2601          "shape": [1, 32, 160, 160],
2602          "dshape": [{"batch": 1}, {"num_protos": 32}, {"height": 160}, {"width": 160}],
2603          "dtype": "uint8",
2604          "quantization": {"scale": 0.0203, "zero_point": 45, "dtype": "uint8"},
2605          "stride": 4
2606        }"#;
2607        let lo: LogicalOutput = serde_json::from_str(j).unwrap();
2608        assert_eq!(lo.type_, Some(LogicalType::Protos));
2609        assert!(lo.decoder.is_none());
2610        assert_eq!(lo.stride, Some(Stride::Square(4)));
2611    }
2612
2613    #[test]
2614    fn full_yolov8_tflite_flat_detection() {
2615        // Example 3: complete two-output YOLOv8 detection schema
2616        let j = r#"{
2617          "schema_version": 2,
2618          "decoder_version": "yolov8",
2619          "nms": "class_agnostic",
2620          "input": { "shape": [1, 640, 640, 3], "cameraadaptor": "rgb" },
2621          "outputs": [
2622            {
2623              "name": "boxes", "type": "boxes",
2624              "shape": [1, 64, 8400],
2625              "dshape": [{"batch": 1}, {"num_features": 64}, {"num_boxes": 8400}],
2626              "dtype": "int8",
2627              "quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "int8"},
2628              "decoder": "ultralytics",
2629              "encoding": "dfl",
2630              "normalized": true
2631            },
2632            {
2633              "name": "scores", "type": "scores",
2634              "shape": [1, 80, 8400],
2635              "dshape": [{"batch": 1}, {"num_classes": 80}, {"num_boxes": 8400}],
2636              "dtype": "int8",
2637              "quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "int8"},
2638              "decoder": "ultralytics",
2639              "score_format": "per_class"
2640            }
2641          ]
2642        }"#;
2643        let s: SchemaV2 = serde_json::from_str(j).unwrap();
2644        assert_eq!(s.schema_version, 2);
2645        assert_eq!(s.decoder_version, Some(DecoderVersion::Yolov8));
2646        assert_eq!(s.nms, Some(NmsMode::ClassAgnostic));
2647        assert_eq!(s.input.as_ref().unwrap().shape, vec![1, 640, 640, 3]);
2648        assert_eq!(s.outputs.len(), 2);
2649    }
2650
2651    #[test]
2652    fn schema_unknown_version_parses_without_validation() {
2653        // Parser accepts any u32; the decoder is responsible for rejecting
2654        // unsupported versions with a useful error.
2655        let j = r#"{"schema_version": 99, "outputs": []}"#;
2656        let s: SchemaV2 = serde_json::from_str(j).unwrap();
2657        assert_eq!(s.schema_version, 99);
2658    }
2659
2660    #[test]
2661    fn serde_roundtrip_preserves_fields() {
2662        let original = SchemaV2 {
2663            schema_version: 2,
2664            input: Some(InputSpec {
2665                shape: vec![1, 3, 640, 640],
2666                dshape: vec![],
2667                cameraadaptor: Some("rgb".into()),
2668                preprocessing: None,
2669            }),
2670            outputs: vec![LogicalOutput {
2671                name: Some("boxes".into()),
2672                type_: Some(LogicalType::Boxes),
2673                shape: vec![1, 4, 8400],
2674                dshape: vec![],
2675                decoder: Some(DecoderKind::Ultralytics),
2676                encoding: Some(BoxEncoding::Direct),
2677                score_format: None,
2678                normalized: Some(true),
2679                anchors: None,
2680                stride: None,
2681                dtype: Some(DType::Float32),
2682                quantization: None,
2683                outputs: vec![],
2684                activation_applied: None,
2685                activation_required: None,
2686                semantic: Default::default(),
2687            }],
2688            nms: Some(NmsMode::ClassAgnostic),
2689            nms_multi_label: None,
2690            decoder_version: Some(DecoderVersion::Yolov8),
2691        };
2692        let j = serde_json::to_string(&original).unwrap();
2693        let parsed: SchemaV2 = serde_json::from_str(&j).unwrap();
2694        assert_eq!(parsed, original);
2695    }
2696
2697    // ─── v1 → v2 shim tests ─────────────────────────────────
2698
2699    #[test]
2700    fn parse_v1_yaml_yolov8_seg_testdata() {
2701        let yaml = edgefirst_bench::testdata::read_to_string("yolov8_seg.yaml");
2702        let schema = SchemaV2::parse_yaml(&yaml).expect("parse v1 yaml");
2703        assert_eq!(schema.schema_version, 2);
2704        assert_eq!(schema.outputs.len(), 2);
2705        // First output: Detection [1, 116, 8400]
2706        let det = &schema.outputs[0];
2707        assert_eq!(det.type_, Some(LogicalType::Detection));
2708        assert_eq!(det.shape, vec![1, 116, 8400]);
2709        assert_eq!(det.decoder, Some(DecoderKind::Ultralytics));
2710        assert_eq!(det.encoding, Some(BoxEncoding::Direct));
2711        let q = det.quantization.as_ref().unwrap();
2712        assert_eq!(q.scale.len(), 1);
2713        assert!((q.scale[0] - 0.021_287_762).abs() < 1e-6);
2714        assert_eq!(q.zero_point, Some(vec![31]));
2715        // Second output: Protos [1, 160, 160, 32]
2716        let protos = &schema.outputs[1];
2717        assert_eq!(protos.type_, Some(LogicalType::Protos));
2718        assert_eq!(protos.shape, vec![1, 160, 160, 32]);
2719    }
2720
2721    #[test]
2722    fn parse_v1_json_modelpack_split_testdata() {
2723        let json = edgefirst_bench::testdata::read_to_string("modelpack_split.json");
2724        let schema = SchemaV2::parse_json(&json).expect("parse v1 json");
2725        assert_eq!(schema.schema_version, 2);
2726        assert_eq!(schema.outputs.len(), 2);
2727        // Both are ModelPack anchor detection with anchors
2728        for out in &schema.outputs {
2729            assert_eq!(out.type_, Some(LogicalType::Detection));
2730            assert_eq!(out.decoder, Some(DecoderKind::ModelPack));
2731            assert_eq!(out.encoding, Some(BoxEncoding::Anchor));
2732            assert_eq!(out.anchors.as_ref().map(|a| a.len()), Some(3));
2733        }
2734    }
2735
2736    #[test]
2737    fn parse_v2_json_direct_when_schema_version_present() {
2738        let j = r#"{
2739          "schema_version": 2,
2740          "outputs": [{
2741            "name": "boxes", "type": "boxes",
2742            "shape": [1, 4, 8400],
2743            "dshape": [{"batch": 1}, {"box_coords": 4}, {"num_boxes": 8400}],
2744            "dtype": "float32",
2745            "decoder": "ultralytics",
2746            "encoding": "direct",
2747            "normalized": true
2748          }]
2749        }"#;
2750        let schema = SchemaV2::parse_json(j).unwrap();
2751        assert_eq!(schema.schema_version, 2);
2752        assert_eq!(schema.outputs[0].type_, Some(LogicalType::Boxes));
2753    }
2754
2755    #[test]
2756    fn parse_rejects_future_schema_version() {
2757        let j = r#"{"schema_version": 99, "outputs": []}"#;
2758        let err = SchemaV2::parse_json(j).unwrap_err();
2759        matches!(err, DecoderError::NotSupported(_));
2760    }
2761
2762    #[test]
2763    fn parse_absent_schema_version_treats_as_v1() {
2764        // No schema_version field — classic v1 yolov8 split
2765        let j = r#"{
2766          "outputs": [
2767            {
2768              "type": "boxes", "decoder": "ultralytics",
2769              "shape": [1, 4, 8400],
2770              "quantization": [0.00392, 0]
2771            },
2772            {
2773              "type": "scores", "decoder": "ultralytics",
2774              "shape": [1, 80, 8400],
2775              "quantization": [0.00392, 0]
2776            }
2777          ]
2778        }"#;
2779        let schema = SchemaV2::parse_json(j).expect("v1 legacy parse");
2780        assert_eq!(schema.schema_version, 2); // converted
2781        assert_eq!(schema.outputs.len(), 2);
2782        assert_eq!(schema.outputs[0].type_, Some(LogicalType::Boxes));
2783        assert_eq!(schema.outputs[1].type_, Some(LogicalType::Scores));
2784        // default score_format assumed on v1→v2
2785        assert_eq!(schema.outputs[1].score_format, Some(ScoreFormat::PerClass));
2786    }
2787
2788    #[test]
2789    fn from_v1_preserves_nms_and_decoder_version() {
2790        let v1 = ConfigOutputs {
2791            outputs: vec![ConfigOutput::Boxes(crate::configs::Boxes {
2792                decoder: crate::configs::DecoderType::Ultralytics,
2793                quantization: Some(crate::configs::QuantTuple(0.01, 5)),
2794                shape: vec![1, 4, 8400],
2795                dshape: vec![],
2796                normalized: Some(true),
2797            })],
2798            nms: Some(crate::configs::Nms::ClassAware),
2799            nms_multi_label: None,
2800            decoder_version: Some(crate::configs::DecoderVersion::Yolo11),
2801        };
2802        let v2 = SchemaV2::from_v1(&v1).unwrap();
2803        assert_eq!(v2.nms, Some(NmsMode::ClassAware));
2804        assert_eq!(v2.decoder_version, Some(DecoderVersion::Yolo11));
2805        assert_eq!(v2.outputs[0].normalized, Some(true));
2806        let q = v2.outputs[0].quantization.as_ref().unwrap();
2807        assert_eq!(q.scale, vec![0.01]);
2808        assert_eq!(q.zero_point, Some(vec![5]));
2809        assert_eq!(q.dtype, None); // v1 did not carry dtype
2810    }
2811
2812    /// Outputs declared without a `type` field must parse successfully
2813    /// and round-trip to JSON without introducing a synthetic type.
2814    /// The metadata v2 spec lists `type` as required on both logical
2815    /// and physical levels, but the HAL tolerates typeless logical
2816    /// outputs as "additional" (auxiliary / diagnostic) tensors that
2817    /// do not participate in decoder dispatch.
2818    #[test]
2819    fn typeless_logical_output_parses_and_roundtrips() {
2820        let j = r#"{
2821            "schema_version": 2,
2822            "outputs": [
2823                {
2824                    "name": "extra_telemetry",
2825                    "shape": [1, 16]
2826                },
2827                {
2828                    "name": "boxes",
2829                    "type": "boxes",
2830                    "shape": [1, 4, 8400]
2831                }
2832            ]
2833        }"#;
2834        let schema: SchemaV2 = serde_json::from_str(j).unwrap();
2835        assert_eq!(schema.outputs.len(), 2);
2836        assert_eq!(schema.outputs[0].type_, None);
2837        assert_eq!(schema.outputs[0].name.as_deref(), Some("extra_telemetry"));
2838        assert_eq!(schema.outputs[1].type_, Some(LogicalType::Boxes));
2839
2840        // Typeless output must not serialize a `type` field.
2841        let round = serde_json::to_string(&schema).unwrap();
2842        let first_obj = round
2843            .split("\"outputs\":[")
2844            .nth(1)
2845            .and_then(|s| s.split("}").next())
2846            .expect("outputs array");
2847        assert!(
2848            !first_obj.contains("\"type\""),
2849            "typeless output must not serialize a `type` field, got: {first_obj}"
2850        );
2851    }
2852
2853    /// Typeless logical outputs are filtered out of the legacy
2854    /// ConfigOutputs — they carry no decoder role and the legacy
2855    /// builder can't represent them. A schema with typeless extras
2856    /// plus a recognised `boxes` role must lower to a legacy config
2857    /// containing only the `boxes` entry.
2858    #[test]
2859    fn typeless_outputs_filtered_from_legacy_config() {
2860        let schema = SchemaV2 {
2861            schema_version: 2,
2862            input: None,
2863            outputs: vec![
2864                LogicalOutput {
2865                    name: Some("diagnostic_histogram".into()),
2866                    type_: None,
2867                    shape: vec![1, 256],
2868                    dshape: vec![],
2869                    decoder: None,
2870                    encoding: None,
2871                    score_format: None,
2872                    normalized: None,
2873                    anchors: None,
2874                    stride: None,
2875                    dtype: None,
2876                    quantization: None,
2877                    outputs: vec![],
2878                    activation_applied: None,
2879                    activation_required: None,
2880                    semantic: Default::default(),
2881                },
2882                LogicalOutput {
2883                    name: Some("boxes".into()),
2884                    type_: Some(LogicalType::Boxes),
2885                    shape: vec![1, 4, 8400],
2886                    dshape: vec![],
2887                    decoder: Some(DecoderKind::Ultralytics),
2888                    encoding: Some(BoxEncoding::Direct),
2889                    score_format: None,
2890                    normalized: Some(true),
2891                    anchors: None,
2892                    stride: None,
2893                    dtype: None,
2894                    quantization: None,
2895                    outputs: vec![],
2896                    activation_applied: None,
2897                    activation_required: None,
2898                    semantic: Default::default(),
2899                },
2900            ],
2901            nms: None,
2902            nms_multi_label: None,
2903            decoder_version: None,
2904        };
2905        let legacy = schema.to_legacy_config_outputs().unwrap();
2906        assert_eq!(
2907            legacy.outputs.len(),
2908            1,
2909            "typeless output must be filtered from legacy config"
2910        );
2911        assert!(
2912            matches!(legacy.outputs[0], ConfigOutput::Boxes(_)),
2913            "only the typed `boxes` output should survive lowering"
2914        );
2915    }
2916
2917    /// A schema that contains no typed outputs (all typeless) lowers
2918    /// to an empty legacy config. The decoder builder then surfaces
2919    /// its own "No outputs found in config" error — meaningful,
2920    /// decoder-centric, not a serde-level "missing field type".
2921    #[test]
2922    fn all_typeless_schema_produces_empty_legacy_config() {
2923        let schema = SchemaV2 {
2924            schema_version: 2,
2925            input: None,
2926            outputs: vec![LogicalOutput {
2927                name: Some("aux".into()),
2928                type_: None,
2929                shape: vec![1, 8],
2930                dshape: vec![],
2931                decoder: None,
2932                encoding: None,
2933                score_format: None,
2934                normalized: None,
2935                anchors: None,
2936                stride: None,
2937                dtype: None,
2938                quantization: None,
2939                outputs: vec![],
2940                activation_applied: None,
2941                activation_required: None,
2942                semantic: Default::default(),
2943            }],
2944            nms: None,
2945            nms_multi_label: None,
2946            decoder_version: None,
2947        };
2948        let legacy = schema.to_legacy_config_outputs().unwrap();
2949        assert!(legacy.outputs.is_empty());
2950    }
2951
2952    /// Physical children may also omit `type`. The schema parses, the
2953    /// output round-trips without a synthetic `type` field, and the
2954    /// uniqueness check doesn't flag a typeless child sharing shape
2955    /// with a typed sibling (the type disambiguator is absent, but we
2956    /// don't bind typeless children by type anyway).
2957    #[test]
2958    fn typeless_physical_child_parses_and_skips_uniqueness() {
2959        let j = r#"{
2960            "name": "boxes",
2961            "type": "boxes",
2962            "shape": [1, 8400, 4],
2963            "outputs": [
2964                {
2965                    "name": "boxes_xy",
2966                    "type": "boxes_xy",
2967                    "shape": [1, 8400, 2],
2968                    "dtype": "float32"
2969                },
2970                {
2971                    "name": "aux_user_managed",
2972                    "shape": [1, 8400, 2],
2973                    "dtype": "float32"
2974                }
2975            ]
2976        }"#;
2977        let lo: LogicalOutput = serde_json::from_str(j).unwrap();
2978        assert_eq!(lo.outputs.len(), 2);
2979        assert_eq!(lo.outputs[0].type_, Some(PhysicalType::BoxesXy));
2980        assert_eq!(lo.outputs[1].type_, None);
2981
2982        // Wrap in a minimal schema so we can call validate().
2983        // BoxesXy and the typeless child share shape `[1, 8400, 2]`;
2984        // the uniqueness check must not treat this as a conflict.
2985        let schema = SchemaV2 {
2986            schema_version: 2,
2987            input: None,
2988            outputs: vec![lo],
2989            nms: None,
2990            nms_multi_label: None,
2991            decoder_version: None,
2992        };
2993        schema.validate().expect(
2994            "typed + typeless children with equal shape must not trigger \
2995             uniqueness error",
2996        );
2997
2998        // Serialization skips `type` on the typeless child.
2999        let s = serde_json::to_string(&schema).unwrap();
3000        assert!(
3001            s.contains("\"aux_user_managed\""),
3002            "typeless child must survive round-trip: {s}"
3003        );
3004        // Locate the typeless child's JSON object and confirm no `type` key.
3005        let aux_obj = s
3006            .split("\"aux_user_managed\"")
3007            .nth(1)
3008            .and_then(|s| s.split('}').next())
3009            .unwrap_or("");
3010        assert!(
3011            !aux_obj.contains("\"type\""),
3012            "typeless child must not serialize `type`, got: {aux_obj}"
3013        );
3014    }
3015
3016    #[test]
3017    fn from_v1_modelpack_anchor_detection_maps_encoding() {
3018        let v1 = ConfigOutputs {
3019            outputs: vec![ConfigOutput::Detection(crate::configs::Detection {
3020                anchors: Some(vec![[0.1, 0.2], [0.3, 0.4]]),
3021                decoder: crate::configs::DecoderType::ModelPack,
3022                quantization: Some(crate::configs::QuantTuple(0.176, 198)),
3023                shape: vec![1, 40, 40, 54],
3024                dshape: vec![],
3025                normalized: None,
3026            })],
3027            nms: None,
3028            nms_multi_label: None,
3029            decoder_version: None,
3030        };
3031        let v2 = SchemaV2::from_v1(&v1).unwrap();
3032        assert_eq!(v2.outputs[0].encoding, Some(BoxEncoding::Anchor));
3033        assert_eq!(v2.outputs[0].decoder, Some(DecoderKind::ModelPack));
3034        assert_eq!(v2.outputs[0].anchors.as_ref().map(|a| a.len()), Some(2));
3035    }
3036
3037    // ─── validate() tests ──────────────────────────────────
3038
3039    #[test]
3040    fn validate_accepts_flat_v2_yolov8_detection() {
3041        let j = r#"{
3042          "schema_version": 2,
3043          "outputs": [
3044            {"name":"boxes","type":"boxes","shape":[1,64,8400],
3045             "dtype":"int8","decoder":"ultralytics","encoding":"dfl"},
3046            {"name":"scores","type":"scores","shape":[1,80,8400],
3047             "dtype":"int8","decoder":"ultralytics","score_format":"per_class"}
3048          ]
3049        }"#;
3050        SchemaV2::parse_json(j).unwrap().validate().unwrap();
3051    }
3052
3053    #[test]
3054    fn validate_rejects_unnamed_physical_child() {
3055        let j = r#"{
3056          "schema_version": 2,
3057          "outputs": [{
3058            "name":"boxes","type":"boxes","shape":[1,64,8400],
3059            "encoding":"dfl","decoder":"ultralytics",
3060            "outputs": [{
3061              "name":"","type":"boxes","stride":8,
3062              "shape":[1,80,80,64],"dtype":"uint8"
3063            }]
3064          }]
3065        }"#;
3066        let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
3067        let msg = format!("{err}");
3068        assert!(msg.contains("missing `name`"), "got: {msg}");
3069    }
3070
3071    #[test]
3072    fn validate_rejects_duplicate_physical_shapes() {
3073        let j = r#"{
3074          "schema_version": 2,
3075          "outputs": [{
3076            "name":"boxes","type":"boxes","shape":[1,64,8400],
3077            "encoding":"dfl","decoder":"ultralytics",
3078            "outputs": [
3079              {"name":"a","type":"boxes","stride":8,"shape":[1,80,80,64],"dtype":"uint8"},
3080              {"name":"b","type":"boxes","stride":16,"shape":[1,80,80,64],"dtype":"uint8"}
3081            ]
3082          }]
3083        }"#;
3084        let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
3085        let msg = format!("{err}");
3086        assert!(msg.contains("share shape"), "got: {msg}");
3087    }
3088
3089    #[test]
3090    fn validate_rejects_mixed_decomposition() {
3091        // one child carries stride, the other does not — ill-defined merge
3092        let j = r#"{
3093          "schema_version": 2,
3094          "outputs": [{
3095            "name":"boxes","type":"boxes","shape":[1,4,8400,1],
3096            "encoding":"direct","decoder":"ultralytics",
3097            "outputs": [
3098              {"name":"xy","type":"boxes_xy","shape":[1,2,8400,1],"dtype":"int16"},
3099              {"name":"p0","type":"boxes","stride":8,"shape":[1,80,80,64],"dtype":"uint8"}
3100            ]
3101          }]
3102        }"#;
3103        let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
3104        let msg = format!("{err}");
3105        assert!(msg.contains("uniform"), "got: {msg}");
3106    }
3107
3108    #[test]
3109    fn validate_rejects_dfl_boxes_feature_not_divisible_by_4() {
3110        let j = r#"{
3111          "schema_version": 2,
3112          "outputs": [{
3113            "name":"boxes","type":"boxes","shape":[1,63,8400],
3114            "encoding":"dfl","decoder":"ultralytics",
3115            "outputs": [{
3116              "name":"b0","type":"boxes","stride":8,
3117              "shape":[1,80,80,63],
3118              "dshape":[{"batch":1},{"height":80},{"width":80},{"num_features":63}],
3119              "dtype":"uint8"
3120            }]
3121          }]
3122        }"#;
3123        let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
3124        let msg = format!("{err}");
3125        assert!(msg.contains("not"), "got: {msg}");
3126        assert!(msg.contains("divisible by 4"), "got: {msg}");
3127    }
3128
3129    #[test]
3130    fn validate_accepts_hailo_per_scale_yolov8() {
3131        let j = r#"{
3132          "schema_version": 2,
3133          "outputs": [{
3134            "name":"boxes","type":"boxes","shape":[1,64,8400],
3135            "encoding":"dfl","decoder":"ultralytics","normalized":true,
3136            "outputs": [
3137              {"name":"b0","type":"boxes","stride":8,
3138               "shape":[1,80,80,64],
3139               "dshape":[{"batch":1},{"height":80},{"width":80},{"num_features":64}],
3140               "dtype":"uint8",
3141               "quantization":{"scale":0.0234,"zero_point":128,"dtype":"uint8"}},
3142              {"name":"b1","type":"boxes","stride":16,
3143               "shape":[1,40,40,64],
3144               "dshape":[{"batch":1},{"height":40},{"width":40},{"num_features":64}],
3145               "dtype":"uint8",
3146               "quantization":{"scale":0.0198,"zero_point":130,"dtype":"uint8"}},
3147              {"name":"b2","type":"boxes","stride":32,
3148               "shape":[1,20,20,64],
3149               "dshape":[{"batch":1},{"height":20},{"width":20},{"num_features":64}],
3150               "dtype":"uint8",
3151               "quantization":{"scale":0.0312,"zero_point":125,"dtype":"uint8"}}
3152            ]
3153          }]
3154        }"#;
3155        let s = SchemaV2::parse_json(j).unwrap();
3156        s.validate().unwrap();
3157    }
3158
3159    #[test]
3160    fn validate_accepts_ara2_xy_wh() {
3161        let j = r#"{
3162          "schema_version": 2,
3163          "outputs": [{
3164            "name":"boxes","type":"boxes","shape":[1,4,8400,1],
3165            "encoding":"direct","decoder":"ultralytics","normalized":true,
3166            "outputs": [
3167              {"name":"xy","type":"boxes_xy","shape":[1,2,8400,1],
3168               "dshape":[{"batch":1},{"box_coords":2},{"num_boxes":8400},{"padding":1}],
3169               "dtype":"int16",
3170               "quantization":{"scale":3.1e-5,"zero_point":0,"dtype":"int16"}},
3171              {"name":"wh","type":"boxes_wh","shape":[1,2,8400,1],
3172               "dshape":[{"batch":1},{"box_coords":2},{"num_boxes":8400},{"padding":1}],
3173               "dtype":"int16",
3174               "quantization":{"scale":3.2e-5,"zero_point":0,"dtype":"int16"}}
3175            ]
3176          }]
3177        }"#;
3178        SchemaV2::parse_json(j).unwrap().validate().unwrap();
3179    }
3180
3181    #[test]
3182    fn parse_file_auto_detects_json() {
3183        let tmp = std::env::temp_dir().join(format!("schema_v2_test_{}.json", std::process::id()));
3184        std::fs::write(&tmp, r#"{"schema_version":2,"outputs":[]}"#).unwrap();
3185        let s = SchemaV2::parse_file(&tmp).unwrap();
3186        assert_eq!(s.schema_version, 2);
3187        let _ = std::fs::remove_file(&tmp);
3188    }
3189
3190    #[test]
3191    fn parse_file_auto_detects_yaml() {
3192        let tmp = std::env::temp_dir().join(format!("schema_v2_test_{}.yaml", std::process::id()));
3193        std::fs::write(&tmp, "schema_version: 2\noutputs: []\n").unwrap();
3194        let s = SchemaV2::parse_file(&tmp).unwrap();
3195        assert_eq!(s.schema_version, 2);
3196        let _ = std::fs::remove_file(&tmp);
3197    }
3198
3199    // ─── Real ARA-2 DVM fixtures ────────────────────────────
3200
3201    #[test]
3202    fn parse_real_ara2_int8_dvm_metadata() {
3203        let json = edgefirst_bench::testdata::read_to_string("ara2_int8_edgefirst.json");
3204        let schema = SchemaV2::parse_json(&json).expect("ARA-2 int8 parse");
3205        assert_eq!(schema.schema_version, 2);
3206        assert_eq!(schema.decoder_version, Some(DecoderVersion::Yolov8));
3207        assert_eq!(schema.nms, Some(NmsMode::ClassAgnostic));
3208        assert_eq!(schema.input.as_ref().unwrap().shape, vec![1, 3, 640, 640]);
3209
3210        // Four logical outputs: boxes (split xy/wh), scores, mask_coefs, protos.
3211        assert_eq!(schema.outputs.len(), 4);
3212        let boxes = &schema.outputs[0];
3213        assert_eq!(boxes.type_, Some(LogicalType::Boxes));
3214        assert_eq!(boxes.encoding, Some(BoxEncoding::Direct));
3215        assert_eq!(boxes.normalized, Some(true));
3216        assert_eq!(boxes.shape, vec![1, 4, 8400, 1]); // 4D with padding
3217        assert_eq!(boxes.outputs.len(), 2);
3218        assert_eq!(boxes.outputs[0].type_, Some(PhysicalType::BoxesXy));
3219        assert_eq!(boxes.outputs[1].type_, Some(PhysicalType::BoxesWh));
3220        // xy quant: scale 0.004177791997790337, zp -122, int8
3221        let q_xy = boxes.outputs[0].quantization.as_ref().unwrap();
3222        assert_eq!(q_xy.dtype, Some(DType::Int8));
3223        assert!((q_xy.scale[0] - 0.004_177_792).abs() < 1e-6);
3224        assert_eq!(q_xy.zero_point_at(0), -122);
3225
3226        let scores = &schema.outputs[1];
3227        assert_eq!(scores.type_, Some(LogicalType::Scores));
3228        assert_eq!(scores.score_format, Some(ScoreFormat::PerClass));
3229        assert_eq!(scores.shape, vec![1, 80, 8400, 1]);
3230
3231        let mask_coefs = &schema.outputs[2];
3232        assert_eq!(mask_coefs.type_, Some(LogicalType::MaskCoefs));
3233        assert_eq!(mask_coefs.shape, vec![1, 32, 8400, 1]);
3234
3235        let protos = &schema.outputs[3];
3236        assert_eq!(protos.type_, Some(LogicalType::Protos));
3237        assert_eq!(protos.shape, vec![1, 32, 160, 160]);
3238
3239        // Schema-level validation passes.
3240        schema.validate().expect("ARA-2 int8 validate");
3241    }
3242
3243    #[test]
3244    fn parse_real_ara2_int16_dvm_metadata() {
3245        let json = edgefirst_bench::testdata::read_to_string("ara2_int16_edgefirst.json");
3246        let schema = SchemaV2::parse_json(&json).expect("ARA-2 int16 parse");
3247        assert_eq!(schema.schema_version, 2);
3248        assert_eq!(schema.outputs.len(), 4);
3249        let boxes = &schema.outputs[0];
3250        assert_eq!(boxes.outputs.len(), 2);
3251        let q_xy = boxes.outputs[0].quantization.as_ref().unwrap();
3252        assert_eq!(q_xy.dtype, Some(DType::Int16));
3253        assert!((q_xy.scale[0] - 3.211_570_6e-5).abs() < 1e-10);
3254        assert_eq!(q_xy.zero_point_at(0), 0);
3255        // Mask coefs and protos too are INT16 in this build.
3256        let mc_q = schema.outputs[2].quantization.as_ref().unwrap();
3257        assert_eq!(mc_q.dtype, Some(DType::Int16));
3258        schema.validate().expect("ARA-2 int16 validate");
3259    }
3260
3261    #[test]
3262    fn parse_yaml_with_explicit_schema_version_2() {
3263        let yaml = r#"
3264schema_version: 2
3265outputs:
3266  - name: scores
3267    type: scores
3268    shape: [1, 80, 8400]
3269    dtype: int8
3270    quantization:
3271      scale: 0.00392
3272      dtype: int8
3273    decoder: ultralytics
3274    score_format: per_class
3275"#;
3276        let schema = SchemaV2::parse_yaml(yaml).unwrap();
3277        assert_eq!(schema.schema_version, 2);
3278        assert_eq!(schema.outputs[0].score_format, Some(ScoreFormat::PerClass));
3279    }
3280
3281    // ─── squeeze_padding_dims / to_legacy_config_outputs regressions ────
3282
3283    #[test]
3284    fn squeeze_padding_dims_preserves_shape_when_dshape_absent() {
3285        // Empty dshape must pass shape through untouched. The previous
3286        // `zip` implementation silently truncated to `[]`, which made
3287        // every v2 logical output without named dims arrive at the legacy
3288        // verifier with `shape: []` and fail rank checks.
3289        let (shape, dshape) = squeeze_padding_dims(vec![1, 4, 8400], vec![]);
3290        assert_eq!(shape, vec![1, 4, 8400]);
3291        assert!(dshape.is_empty());
3292    }
3293
3294    fn sample_logical(ty: LogicalType) -> LogicalOutput {
3295        LogicalOutput {
3296            name: Some("sample".into()),
3297            type_: Some(ty),
3298            shape: vec![1, 4, 100],
3299            dshape: vec![],
3300            decoder: None,
3301            encoding: None,
3302            score_format: None,
3303            normalized: Some(true),
3304            anchors: None,
3305            stride: None,
3306            dtype: None,
3307            quantization: None,
3308            outputs: vec![],
3309            activation_applied: None,
3310            activation_required: None,
3311            semantic: Default::default(),
3312        }
3313    }
3314
3315    #[test]
3316    fn logical_to_legacy_maps_each_supported_type() {
3317        let cases = [
3318            (LogicalType::Boxes, "Boxes"),
3319            (LogicalType::Scores, "Scores"),
3320            (LogicalType::Protos, "Protos"),
3321            (LogicalType::MaskCoefs, "MaskCoefficients"),
3322            (LogicalType::Segmentation, "Segmentation"),
3323            (LogicalType::Masks, "Mask"),
3324            (LogicalType::Classes, "Classes"),
3325            (LogicalType::Detection, "Detection"),
3326            (LogicalType::Detections, "Detection"),
3327        ];
3328        for (ty, expected) in cases {
3329            let out = logical_to_legacy_config_output(&sample_logical(ty)).unwrap();
3330            let name = match &out {
3331                ConfigOutput::Boxes(_) => "Boxes",
3332                ConfigOutput::Scores(_) => "Scores",
3333                ConfigOutput::Protos(_) => "Protos",
3334                ConfigOutput::MaskCoefficients(_) => "MaskCoefficients",
3335                ConfigOutput::Segmentation(_) => "Segmentation",
3336                ConfigOutput::Mask(_) => "Mask",
3337                ConfigOutput::Classes(_) => "Classes",
3338                ConfigOutput::Detection(_) => "Detection",
3339            };
3340            assert_eq!(name, expected, "type {ty:?}");
3341        }
3342    }
3343
3344    #[test]
3345    fn logical_to_legacy_rejects_objectness_and_landmarks() {
3346        for ty in [LogicalType::Objectness, LogicalType::Landmarks] {
3347            let err = logical_to_legacy_config_output(&sample_logical(ty)).unwrap_err();
3348            assert!(
3349                matches!(err, DecoderError::NotSupported(_)),
3350                "expected NotSupported for {ty:?}, got {err:?}"
3351            );
3352        }
3353    }
3354
3355    #[test]
3356    fn logical_to_legacy_rejects_typeless_output() {
3357        let mut logical = sample_logical(LogicalType::Boxes);
3358        logical.type_ = None;
3359        let err = logical_to_legacy_config_output(&logical).unwrap_err();
3360        assert!(matches!(err, DecoderError::InvalidConfig(_)));
3361    }
3362
3363    #[test]
3364    fn to_legacy_modelpack_boxes_preserves_padding_dim() {
3365        // Regression: ModelPack boxes with shape [1, N, 1, 4] (padding dim
3366        // present in dshape) were incorrectly squeezed to [1, N, 4] by
3367        // `logical_to_legacy_config_output`, triggering "Invalid ModelPack
3368        // Boxes shape [1, 1935, 4]". The ModelPack path must skip squeezing.
3369        let j = r#"{
3370          "schema_version": 2,
3371          "outputs": [
3372            {"name":"boxes","type":"boxes",
3373             "shape":[1,1935,1,4],
3374             "dshape":[{"batch":1},{"num_boxes":1935},{"padding":1},{"box_coords":4}],
3375             "decoder":"modelpack"}
3376          ]
3377        }"#;
3378        let schema = SchemaV2::parse_json(j).unwrap();
3379        let legacy = schema.to_legacy_config_outputs().expect("lowers cleanly");
3380        let boxes = match &legacy.outputs[0] {
3381            crate::ConfigOutput::Boxes(b) => b,
3382            other => panic!("expected Boxes, got {other:?}"),
3383        };
3384        // Must preserve the rank-4 shape — the padding dim must NOT be
3385        // squeezed for ModelPack outputs.
3386        assert_eq!(boxes.shape, vec![1, 1935, 1, 4]);
3387        assert_eq!(
3388            boxes.dshape,
3389            vec![
3390                (DimName::Batch, 1),
3391                (DimName::NumBoxes, 1935),
3392                (DimName::Padding, 1),
3393                (DimName::BoxCoords, 4),
3394            ]
3395        );
3396    }
3397
3398    #[test]
3399    fn to_legacy_preserves_shape_for_v2_split_boxes_without_dshape() {
3400        // Regression: `Decoder({...v2 split boxes, shape:[1,4,8400], no dshape...})`
3401        // used to fail with `Invalid Yolo Split Boxes shape []` because
3402        // `squeeze_padding_dims` truncated shape when dshape was empty.
3403        let j = r#"{
3404          "schema_version": 2,
3405          "outputs": [
3406            {"name":"boxes","type":"boxes","shape":[1,4,8400],
3407             "dtype":"float32","decoder":"ultralytics","encoding":"direct"},
3408            {"name":"scores","type":"scores","shape":[1,80,8400],
3409             "dtype":"float32","decoder":"ultralytics","score_format":"per_class"}
3410          ]
3411        }"#;
3412        let schema = SchemaV2::parse_json(j).unwrap();
3413        let legacy = schema.to_legacy_config_outputs().expect("lowers cleanly");
3414        let boxes = match &legacy.outputs[0] {
3415            crate::ConfigOutput::Boxes(b) => b,
3416            other => panic!("expected Boxes, got {other:?}"),
3417        };
3418        assert_eq!(boxes.shape, vec![1, 4, 8400]);
3419        let scores = match &legacy.outputs[1] {
3420            crate::ConfigOutput::Scores(s) => s,
3421            other => panic!("expected Scores, got {other:?}"),
3422        };
3423        assert_eq!(scores.shape, vec![1, 80, 8400]);
3424    }
3425
3426    const SEGFORMER_B1: &str = r#"{
3427      "schema_version": 2,
3428      "input": {
3429        "shape": [1, 3, 512, 672],
3430        "dshape": [{"batch": 1}, {"channels": 3}, {"height": 512}, {"width": 672}],
3431        "cameraadaptor": "rgb",
3432        "preprocessing": {
3433          "normalization": {"mean": [123.675, 116.28, 103.53], "std": [58.395, 57.12, 57.375]},
3434          "resize": "letterbox",
3435          "interpolation": "bilinear",
3436          "letterbox": {"placement": "top_left", "pad": [0.0, 0.0, 0.0], "pad_space": "normalized"}
3437        }
3438      },
3439      "outputs": [{
3440        "name": "logits", "type": "segmentation", "decoder": "semantic",
3441        "shape": [1, 150, 128, 168],
3442        "dshape": [{"batch": 1}, {"num_classes": 150}, {"height": 128}, {"width": 168}],
3443        "dtype": "float32", "stride": 4,
3444        "upsample": {"method": "bilinear", "align_corners": false},
3445        "decode_mode": "accurate"
3446      }]
3447    }"#;
3448
3449    #[test]
3450    fn semantic_segformer_parses_and_validates() {
3451        let schema = SchemaV2::parse_json(SEGFORMER_B1).unwrap();
3452        schema.validate().unwrap();
3453
3454        let pre = schema
3455            .input
3456            .as_ref()
3457            .unwrap()
3458            .preprocessing
3459            .as_ref()
3460            .unwrap();
3461        assert_eq!(pre.resize, Some(ResizeMode::Letterbox));
3462        assert_eq!(pre.interpolation, Some(Interpolation::Bilinear));
3463        let lb = pre.letterbox.as_ref().unwrap();
3464        assert_eq!(lb.placement, Some(LetterboxPlacement::TopLeft));
3465        assert_eq!(lb.pad_space, Some(PadSpace::Normalized));
3466        let (mean, std) = pre.normalization.as_ref().unwrap().mean_std(3).unwrap();
3467        assert_eq!(mean, IMAGENET_MEAN.to_vec());
3468        assert_eq!(std, IMAGENET_STD.to_vec());
3469
3470        let out = &schema.outputs[0];
3471        assert_eq!(out.decoder, Some(DecoderKind::Semantic));
3472        assert_eq!(out.stride, Some(Stride::Square(4)));
3473        assert_eq!(
3474            out.semantic.upsample,
3475            Some(Upsample {
3476                method: UpsampleMethod::Bilinear,
3477                align_corners: false
3478            })
3479        );
3480        assert_eq!(out.semantic.decode_mode, Some(DecodeMode::Accurate));
3481    }
3482
3483    #[test]
3484    fn semantic_round_trips_with_flat_fields() {
3485        let schema = SchemaV2::parse_json(SEGFORMER_B1).unwrap();
3486        let value = serde_json::to_value(&schema).unwrap();
3487        let out = &value["outputs"][0];
3488        assert_eq!(out["decode_mode"], "accurate");
3489        assert_eq!(out["upsample"]["method"], "bilinear");
3490        assert!(out.get("semantic").is_none(), "fields must serialize flat");
3491        assert!(out.get("threshold").is_none(), "unset fields are omitted");
3492        let back: SchemaV2 = serde_json::from_value(value).unwrap();
3493        assert_eq!(back, schema);
3494    }
3495
3496    #[test]
3497    fn semantic_decoder_is_not_lowered_to_legacy() {
3498        let schema = SchemaV2::parse_json(SEGFORMER_B1).unwrap();
3499        let err = schema.to_legacy_config_outputs().unwrap_err();
3500        assert!(matches!(err, DecoderError::NotSupported(_)), "{err:?}");
3501    }
3502
3503    #[test]
3504    fn modelpack_segmentation_still_lowers_to_legacy() {
3505        let j = r#"{
3506          "schema_version": 2,
3507          "outputs": [{
3508            "name": "segmentation_output", "type": "segmentation",
3509            "shape": [1, 480, 640, 5],
3510            "dshape": [{"batch": 1}, {"height": 480}, {"width": 640}, {"num_classes": 5}],
3511            "dtype": "uint8",
3512            "quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "uint8"},
3513            "decoder": "modelpack"
3514          }]
3515        }"#;
3516        let schema = SchemaV2::parse_json(j).unwrap();
3517        assert_eq!(schema.outputs[0].semantic, SemanticParams::default());
3518        let legacy = schema.to_legacy_config_outputs().unwrap();
3519        assert!(matches!(
3520            legacy.outputs[0],
3521            crate::ConfigOutput::Segmentation(_)
3522        ));
3523    }
3524
3525    #[test]
3526    fn normalization_presets_resolve() {
3527        let cases = [
3528            (NormalizationPreset::Unsigned, 0.0, 255.0),
3529            (NormalizationPreset::Signed, 127.5, 127.5),
3530            (NormalizationPreset::Raw, 0.0, 1.0),
3531        ];
3532        for (preset, m, s) in cases {
3533            let (mean, std) = Normalization::Preset(preset).mean_std(4).unwrap();
3534            assert_eq!(mean, vec![m; 4], "{preset:?}");
3535            assert_eq!(std, vec![s; 4], "{preset:?}");
3536        }
3537        let imagenet = Normalization::Preset(NormalizationPreset::Imagenet);
3538        assert!(imagenet.mean_std(3).is_ok());
3539        assert!(imagenet.mean_std(1).is_err());
3540    }
3541
3542    #[test]
3543    fn normalization_preset_parses_from_string() {
3544        for (text, preset) in [
3545            ("unsigned", NormalizationPreset::Unsigned),
3546            ("signed", NormalizationPreset::Signed),
3547            ("raw", NormalizationPreset::Raw),
3548            ("imagenet", NormalizationPreset::Imagenet),
3549        ] {
3550            let norm: Normalization = serde_json::from_value(serde_json::json!(text)).unwrap();
3551            assert_eq!(norm, Normalization::Preset(preset));
3552        }
3553        assert!(serde_json::from_value::<Normalization>(serde_json::json!("zscore")).is_err());
3554    }
3555
3556    #[test]
3557    fn mean_std_broadcasts_and_rejects_bad_input() {
3558        let one = Normalization::MeanStd {
3559            mean: vec![127.5],
3560            std: vec![127.5],
3561        };
3562        assert_eq!(one.mean_std(3).unwrap().0, vec![127.5; 3]);
3563
3564        let mismatched = Normalization::MeanStd {
3565            mean: vec![0.0, 0.0],
3566            std: vec![1.0; 3],
3567        };
3568        assert!(mismatched.mean_std(3).is_err());
3569
3570        let zero_std = Normalization::MeanStd {
3571            mean: vec![0.0; 3],
3572            std: vec![1.0, 0.0, 1.0],
3573        };
3574        assert!(zero_std.mean_std(3).is_err());
3575    }
3576
3577    #[test]
3578    fn validate_rejects_bad_preprocessing() {
3579        let bad_pad = r#"{
3580          "schema_version": 2,
3581          "input": {"shape": [1, 3, 8, 8], "preprocessing": {
3582            "normalization": {"mean": [0, 0, 0], "std": [1, 1, 1]},
3583            "letterbox": {"pad": [0, 0]}
3584          }}
3585        }"#;
3586        assert!(SchemaV2::parse_json(bad_pad).unwrap().validate().is_err());
3587
3588        let zero_std = r#"{
3589          "schema_version": 2,
3590          "input": {"shape": [1, 3, 8, 8], "preprocessing": {
3591            "normalization": {"mean": [0], "std": [0]}
3592          }}
3593        }"#;
3594        assert!(SchemaV2::parse_json(zero_std).unwrap().validate().is_err());
3595    }
3596
3597    #[test]
3598    fn non_finite_mean_is_rejected() {
3599        for bad in [f32::NAN, f32::INFINITY] {
3600            let norm = Normalization::MeanStd {
3601                mean: vec![bad],
3602                std: vec![1.0],
3603            };
3604            assert!(norm.mean_std(3).is_err(), "{bad}");
3605
3606            let mut schema =
3607                SchemaV2::parse_json(r#"{"schema_version": 2, "input": {"shape": [1, 3, 8, 8]}}"#)
3608                    .unwrap();
3609            schema.input.as_mut().unwrap().preprocessing = Some(Preprocessing {
3610                normalization: Some(norm),
3611                ..Default::default()
3612            });
3613            assert!(schema.validate().is_err(), "{bad}");
3614        }
3615    }
3616
3617    fn validate_input(shape: &str, preprocessing: &str) -> DecoderResult<()> {
3618        let j = format!(
3619            r#"{{"schema_version": 2, "input": {{{shape}, "preprocessing": {preprocessing}}}}}"#
3620        );
3621        SchemaV2::parse_json(&j).unwrap().validate()
3622    }
3623
3624    #[test]
3625    fn scalar_coefficients_broadcast_with_per_channel_pad() {
3626        let pre = r#"{"normalization": {"mean": [0], "std": [255]},
3627                      "letterbox": {"pad": [114, 114, 114]}}"#;
3628        validate_input(r#""shape": [1, 3, 512, 512]"#, pre).unwrap();
3629        validate_input(r#""shape": [1, 512, 512, 3]"#, pre).unwrap();
3630        // Unknown layout: the per-channel pad sets the channel count.
3631        validate_input(r#""shape": [1, 3, 4, 3]"#, pre).unwrap();
3632    }
3633
3634    #[test]
3635    fn coefficients_must_match_input_channels() {
3636        let two = r#"{"normalization": {"mean": [0, 0], "std": [1, 1]}}"#;
3637        let nchw = r#""shape": [1, 3, 512, 512]"#;
3638        let named = r#""shape": [1, 512, 512, 3],
3639            "dshape": [{"batch": 1}, {"height": 512}, {"width": 512}, {"channels": 3}]"#;
3640        assert!(validate_input(nchw, two).is_err());
3641        assert!(validate_input(named, two).is_err());
3642
3643        let pad_two = r#"{"normalization": "unsigned", "letterbox": {"pad": [0, 0]}}"#;
3644        assert!(validate_input(nchw, pad_two).is_err());
3645
3646        let imagenet = r#"{"normalization": "imagenet"}"#;
3647        assert!(validate_input(r#""shape": [1, 1, 64, 64]"#, imagenet).is_err());
3648        validate_input(nchw, imagenet).unwrap();
3649
3650        // Unknown layout: non-scalar vectors must agree with each other.
3651        let mixed = r#"{"normalization": {"mean": [0, 0, 0], "std": [1, 1]}}"#;
3652        assert!(validate_input(r#""shape": [1, 3, 4, 3]"#, mixed).is_err());
3653    }
3654
3655    #[test]
3656    fn input_channels_resolve_from_dshape_or_shape() {
3657        let spec = |j: &str| serde_json::from_str::<InputSpec>(j).unwrap().channels();
3658        assert_eq!(spec(r#"{"shape": [1, 3, 512, 512]}"#), Some(3));
3659        assert_eq!(spec(r#"{"shape": [1, 512, 512, 4]}"#), Some(4));
3660        assert_eq!(spec(r#"{"shape": [1, 3, 4, 3]}"#), None);
3661        assert_eq!(spec(r#"{"shape": [1, 512, 512]}"#), None);
3662        assert_eq!(
3663            spec(
3664                r#"{"shape": [1, 3, 4, 3],
3665                    "dshape": [{"batch": 1}, {"channels": 3}, {"height": 4}, {"width": 3}]}"#
3666            ),
3667            Some(3)
3668        );
3669    }
3670
3671    #[test]
3672    fn preprocessing_parses_from_yaml() {
3673        let y = r#"
3674schema_version: 2
3675input:
3676  shape: [1, 256, 256, 3]
3677  preprocessing:
3678    normalization: unsigned
3679    resize: stretch
3680"#;
3681        let schema = SchemaV2::parse_yaml(y).unwrap();
3682        let pre = schema.input.unwrap().preprocessing.unwrap();
3683        assert_eq!(
3684            pre.normalization,
3685            Some(Normalization::Preset(NormalizationPreset::Unsigned))
3686        );
3687        assert_eq!(pre.resize, Some(ResizeMode::Stretch));
3688        assert!(pre.letterbox.is_none());
3689    }
3690
3691    #[test]
3692    fn class_map_masks_accept_int64() {
3693        let j = r#"{
3694          "schema_version": 2,
3695          "outputs": [{
3696            "name": "ArgMax", "type": "masks", "decoder": "semantic",
3697            "shape": [1, 513, 513],
3698            "dshape": [{"batch": 1}, {"height": 513}, {"width": 513}],
3699            "dtype": "int64"
3700          }]
3701        }"#;
3702        let schema = SchemaV2::parse_json(j).unwrap();
3703        schema.validate().unwrap();
3704        assert_eq!(schema.outputs[0].dtype, Some(DType::Int64));
3705        assert_eq!(DType::Int64.size_bytes(), 8);
3706        assert!(DType::Int64.is_integer());
3707    }
3708
3709    #[test]
3710    fn single_channel_threshold_and_background_parse() {
3711        let j = r#"{
3712          "schema_version": 2,
3713          "outputs": [{
3714            "name": "segment", "type": "segmentation", "decoder": "semantic",
3715            "shape": [1, 256, 256, 1],
3716            "dshape": [{"batch": 1}, {"height": 256}, {"width": 256}, {"num_classes": 1}],
3717            "dtype": "float32", "activation_applied": "sigmoid",
3718            "threshold": 0.5, "background_index": 0
3719          }]
3720        }"#;
3721        let schema = SchemaV2::parse_json(j).unwrap();
3722        let out = &schema.outputs[0];
3723        assert_eq!(out.activation_applied, Some(Activation::Sigmoid));
3724        assert_eq!(out.semantic.threshold, Some(0.5));
3725        assert_eq!(out.semantic.background_index, Some(0));
3726    }
3727
3728    #[test]
3729    fn reserved_mask_classification_parses_but_does_not_lower() {
3730        let j = r#"{
3731          "schema_version": 2,
3732          "outputs": [
3733            {"name": "class_queries_logits", "type": "class_logits",
3734             "shape": [1, 100, 151],
3735             "dshape": [{"batch": 1}, {"num_queries": 100}, {"num_classes": 151}],
3736             "dtype": "float32", "no_object_index": 150},
3737            {"name": "masks_queries_logits", "type": "mask_logits",
3738             "shape": [1, 100, 128, 128],
3739             "dshape": [{"batch": 1}, {"num_queries": 100}, {"height": 128}, {"width": 128}],
3740             "dtype": "float32", "stride": 4, "mask_activation": "sigmoid"}
3741          ]
3742        }"#;
3743        let schema = SchemaV2::parse_json(j).unwrap();
3744        schema.validate().unwrap();
3745        assert_eq!(schema.outputs[0].type_, Some(LogicalType::ClassLogits));
3746        assert_eq!(schema.outputs[0].dshape[1].0, DimName::NumQueries);
3747        assert_eq!(schema.outputs[0].semantic.no_object_index, Some(150));
3748        assert_eq!(
3749            schema.outputs[1].semantic.mask_activation,
3750            Some(MaskActivation::Sigmoid)
3751        );
3752        assert!(matches!(
3753            schema.to_legacy_config_outputs(),
3754            Err(DecoderError::NotSupported(_))
3755        ));
3756    }
3757
3758    #[test]
3759    fn semantic_decoder_kind_shares_the_v1_code() {
3760        assert_eq!(
3761            DecoderKind::Semantic.to_v1(),
3762            configs::DecoderType::Semantic
3763        );
3764        let v: configs::DecoderType = serde_json::from_str("\"semantic\"").unwrap();
3765        assert_eq!(v, configs::DecoderType::Semantic);
3766    }
3767}