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