Skip to main content

edgefirst_decoder/
schema.rs

1// SPDX-FileCopyrightText: Copyright 2025-2026 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4//! Schema v2 metadata types for EdgeFirst model configuration.
5//!
6//! Schema v2 introduces a two-layer output model that separates the logical
7//! contract (what the model produces semantically) from the physical
8//! realization (what tensors the converter emitted). Each entry in the
9//! top-level [`SchemaV2::outputs`] array is a [`LogicalOutput`]. A logical
10//! output either IS a physical tensor (when the converter did not split it
11//! further) or contains a `outputs` array of [`PhysicalOutput`] children
12//! that realize it.
13//!
14//! # Example — YOLOv8 detection, flat (TFLite)
15//!
16//! ```
17//! use edgefirst_decoder::schema::SchemaV2;
18//!
19//! let json = r#"{
20//!   "schema_version": 2,
21//!   "outputs": [
22//!     {
23//!       "name": "boxes", "type": "boxes",
24//!       "shape": [1, 64, 8400],
25//!       "dshape": [{"batch": 1}, {"num_features": 64}, {"num_boxes": 8400}],
26//!       "encoding": "dfl", "decoder": "ultralytics", "normalized": true,
27//!       "dtype": "int8",
28//!       "quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "int8"}
29//!     },
30//!     {
31//!       "name": "scores", "type": "scores",
32//!       "shape": [1, 80, 8400],
33//!       "dshape": [{"batch": 1}, {"num_classes": 80}, {"num_boxes": 8400}],
34//!       "decoder": "ultralytics", "score_format": "per_class",
35//!       "dtype": "int8",
36//!       "quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "int8"}
37//!     }
38//!   ]
39//! }"#;
40//!
41//! let schema: SchemaV2 = serde_json::from_str(json).unwrap();
42//! assert_eq!(schema.schema_version, 2);
43//! assert_eq!(schema.outputs.len(), 2);
44//! ```
45
46use crate::configs::{self, deserialize_dshape, DimName, QuantTuple};
47use crate::{ConfigOutput, ConfigOutputs, DecoderError, DecoderResult};
48use serde::{Deserialize, Serialize};
49
50/// Highest `schema_version` this parser accepts. Files with a higher
51/// version are rejected rather than silently parsed against the wrong
52/// grammar.
53pub const MAX_SUPPORTED_SCHEMA_VERSION: u32 = 2;
54
55/// Root of the edgefirst.json schema v2 metadata.
56///
57/// All fields except [`SchemaV2::schema_version`] are optional, so
58/// third-party integrations can include only the sections relevant to
59/// their use case.
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61pub struct SchemaV2 {
62    /// Schema version. Always 2 for v2 metadata.
63    pub schema_version: u32,
64
65    /// Input tensor specification (shape, named dims, camera adaptor).
66    ///
67    /// Required for decoders that need to know the model input resolution:
68    /// DFL dist2bbox scaling, box normalization against input dimensions,
69    /// and per-scale anchor grid generation.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub input: Option<InputSpec>,
72
73    /// Logical outputs describing the model's output tensors and their
74    /// semantic roles.
75    #[serde(default, skip_serializing_if = "Vec::is_empty")]
76    pub outputs: Vec<LogicalOutput>,
77
78    /// HAL NMS mode. Omitted for end-to-end models with embedded NMS.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub nms: Option<NmsMode>,
81
82    /// YOLO architecture version for Ultralytics decoders.
83    ///
84    /// Values: `yolov5`, `yolov8`, `yolo11`, `yolo26`. `yolo26` is
85    /// end-to-end (embedded NMS).
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub decoder_version: Option<DecoderVersion>,
88}
89
90impl Default for SchemaV2 {
91    fn default() -> Self {
92        Self {
93            schema_version: 2,
94            input: None,
95            outputs: Vec::new(),
96            nms: None,
97            decoder_version: None,
98        }
99    }
100}
101
102/// Input tensor specification.
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
104pub struct InputSpec {
105    /// Input tensor shape in the model's native layout (NCHW or NHWC).
106    pub shape: Vec<usize>,
107
108    /// Named dimensions ordered to match `shape`. Empty means the layout
109    /// is unspecified and consumers must infer from the format.
110    #[serde(
111        default,
112        deserialize_with = "deserialize_dshape",
113        skip_serializing_if = "Vec::is_empty"
114    )]
115    pub dshape: Vec<(DimName, usize)>,
116
117    /// Camera adaptor input format (`rgb`, `rgba`, `bgr`, `bgra`, `grey`,
118    /// `yuyv`). Free-form string rather than an enum because new adaptor
119    /// formats can appear without breaking parsing.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub cameraadaptor: Option<String>,
122}
123
124/// Logical output: the semantic contract the model exposes.
125///
126/// When `outputs` is empty, the logical output IS the physical tensor
127/// (`dtype` and `quantization` carry the tensor-level fields directly).
128/// When `outputs` contains one or more [`PhysicalOutput`] entries, those
129/// children are the real physical tensors and the logical `shape` is
130/// the reconstructed shape produced by the fallback merge path.
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132pub struct LogicalOutput {
133    /// Logical output name (optional at the logical level).
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub name: Option<String>,
136
137    /// Semantic type. `None` marks the output as "additional" — carried in
138    /// the schema for completeness (e.g. diagnostic or auxiliary tensors)
139    /// but not participating in decoder dispatch. See
140    /// [`SchemaV2::to_legacy_config_outputs`] for how typeless outputs are
141    /// filtered out of the legacy config, and the module docs for when to
142    /// use this vs. a recognised [`LogicalType`] variant.
143    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
144    pub type_: Option<LogicalType>,
145
146    /// Reconstructed logical shape (what the fallback dequant+merge path
147    /// produces).
148    pub shape: Vec<usize>,
149
150    /// Named dimensions ordered to match `shape`.
151    #[serde(
152        default,
153        deserialize_with = "deserialize_dshape",
154        skip_serializing_if = "Vec::is_empty"
155    )]
156    pub dshape: Vec<(DimName, usize)>,
157
158    /// Decoder to use for post-processing. Omitted for outputs consumed
159    /// directly (e.g. `protos`) where no decode step is required.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub decoder: Option<DecoderKind>,
162
163    /// Box encoding. Required on `boxes` logical outputs in v2.
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub encoding: Option<BoxEncoding>,
166
167    /// Score format. Scores only.
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub score_format: Option<ScoreFormat>,
170
171    /// Coordinate format. `true` means `[0, 1]` normalized; `false` means
172    /// pixel coordinates relative to the letterboxed model input. `None`
173    /// means unspecified (decoder must infer). `boxes` and `detections`
174    /// only.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub normalized: Option<bool>,
177
178    /// Anchor boxes for anchor-encoded logical outputs. Required when
179    /// `encoding: anchor`.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub anchors: Option<Vec<[f32; 2]>>,
182
183    /// Spatial stride. For non-split logical outputs this is a spatial
184    /// hint (e.g. `protos` at stride 4). For per-scale splits each child
185    /// carries its own `stride` instead.
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub stride: Option<Stride>,
188
189    /// Tensor dtype. Present when `outputs` is empty (this logical IS the
190    /// physical tensor).
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub dtype: Option<DType>,
193
194    /// Quantization parameters. Present when `outputs` is empty. `None`
195    /// means the tensor is not quantized (float model).
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub quantization: Option<Quantization>,
198
199    /// Physical children that realize this logical output. Empty when the
200    /// logical IS the physical tensor. At most one level of nesting is
201    /// permitted.
202    #[serde(default, skip_serializing_if = "Vec::is_empty")]
203    pub outputs: Vec<PhysicalOutput>,
204
205    /// Activation already applied by the model graph or runtime. The
206    /// HAL must NOT re-apply an activation declared here.
207    ///
208    /// On per-scale models the converter writes this on the logical
209    /// parent (e.g. `scores.activation_applied = sigmoid`) when the
210    /// model graph already includes the activation; the per-physical
211    /// children inherit it.
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub activation_applied: Option<Activation>,
214
215    /// Activation NOT yet applied. The HAL MUST apply the declared
216    /// activation before consuming the tensor.
217    ///
218    /// On per-scale models the converter writes this on the logical
219    /// parent (e.g. `scores.activation_required = sigmoid`) when the
220    /// score-activation step was stripped from the model graph and
221    /// must be re-applied by the runtime; per-physical children
222    /// inherit the parent's declaration.
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub activation_required: Option<Activation>,
225}
226
227impl LogicalOutput {
228    /// Returns `true` if this logical output has been split into physical
229    /// children by the converter.
230    pub fn is_split(&self) -> bool {
231        !self.outputs.is_empty()
232    }
233}
234
235/// Physical output: a concrete tensor produced by the converter.
236///
237/// Physical outputs carry only tensor-level fields (`dtype`,
238/// `quantization`, `stride`, `activation_applied`/`activation_required`).
239/// Semantic fields live on the [`LogicalOutput`] parent.
240#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
241pub struct PhysicalOutput {
242    /// Physical tensor name as produced by the converter. This name is
243    /// used to bind the metadata to the tensor returned by the inference
244    /// runtime.
245    pub name: String,
246
247    /// Semantic type. Matches the parent's type or declares a sub-split
248    /// such as `boxes_xy` or `boxes_wh`. `None` marks the child as
249    /// type-opaque: it still binds to a physical tensor by `name`/`shape`
250    /// during merging, but is not used to disambiguate against typed
251    /// siblings. Useful when a converter emits extra per-scale tensors
252    /// the HAL has no semantic for but the user manages downstream.
253    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
254    pub type_: Option<PhysicalType>,
255
256    /// Physical tensor shape.
257    pub shape: Vec<usize>,
258
259    /// Named dimensions ordered to match `shape`. Disambiguates NHWC vs
260    /// NCHW per-child rather than assuming a model-wide layout.
261    #[serde(
262        default,
263        deserialize_with = "deserialize_dshape",
264        skip_serializing_if = "Vec::is_empty"
265    )]
266    pub dshape: Vec<(DimName, usize)>,
267
268    /// Quantized data type.
269    pub dtype: DType,
270
271    /// Quantization parameters. Always present in v2; `null` means float
272    /// (no quantization).
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub quantization: Option<Quantization>,
275
276    /// FPN stride. Present on per-scale splits; absent on channel
277    /// sub-splits (e.g. `boxes_xy`/`boxes_wh`).
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub stride: Option<Stride>,
280
281    /// Zero-based index into the parent's strides array. Used for
282    /// parallel iteration with precomputed per-scale state.
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub scale_index: Option<usize>,
285
286    /// Activation already applied by the NPU. The HAL must NOT re-apply
287    /// an activation declared here (e.g. Hailo applies sigmoid to score
288    /// tensors on-chip).
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub activation_applied: Option<Activation>,
291
292    /// Activation NOT yet applied. The HAL MUST apply the declared
293    /// activation before consuming the tensor.
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub activation_required: Option<Activation>,
296}
297
298/// Quantization parameters for a quantized tensor.
299///
300/// Supports per-tensor (scalar `scale`) and per-channel (array `scale`)
301/// quantization. Symmetric quantization is indicated by an absent or
302/// all-zero `zero_point`.
303#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
304pub struct Quantization {
305    /// Scale factor(s). One element for per-tensor quantization, or one
306    /// element per slice for per-channel quantization.
307    #[serde(deserialize_with = "deserialize_scalar_or_vec_f32")]
308    pub scale: Vec<f32>,
309
310    /// Zero point offset(s). Omit or set to all-zero for symmetric
311    /// quantization. For per-channel quantization, length must match
312    /// `scale` length.
313    #[serde(
314        default,
315        deserialize_with = "deserialize_opt_scalar_or_vec_i32",
316        skip_serializing_if = "Option::is_none"
317    )]
318    pub zero_point: Option<Vec<i32>>,
319
320    /// Tensor dimension index that `scale`/`zero_point` arrays correspond
321    /// to. Required when per-channel; ignored otherwise.
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    pub axis: Option<usize>,
324
325    /// Quantized data type. Required on v2 metadata files; may be absent
326    /// on programmatically-constructed configurations where the dtype is
327    /// resolved at decode time from the actual tensor.
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    pub dtype: Option<DType>,
330}
331
332impl Quantization {
333    /// Returns `true` when per-tensor (scalar scale).
334    pub fn is_per_tensor(&self) -> bool {
335        self.scale.len() == 1
336    }
337
338    /// Returns `true` when per-channel (array scale of length > 1).
339    pub fn is_per_channel(&self) -> bool {
340        self.scale.len() > 1
341    }
342
343    /// Returns `true` when all zero points are 0 (or absent).
344    pub fn is_symmetric(&self) -> bool {
345        match &self.zero_point {
346            None => true,
347            Some(zps) => zps.iter().all(|&z| z == 0),
348        }
349    }
350
351    /// Returns the zero point for the given channel index, or 0 when the
352    /// quantization is symmetric.
353    pub fn zero_point_at(&self, channel: usize) -> i32 {
354        match &self.zero_point {
355            None => 0,
356            Some(zps) if zps.len() == 1 => zps[0],
357            Some(zps) => zps.get(channel).copied().unwrap_or(0),
358        }
359    }
360
361    /// Returns the scale for the given channel index.
362    pub fn scale_at(&self, channel: usize) -> f32 {
363        if self.scale.len() == 1 {
364            self.scale[0]
365        } else {
366            self.scale.get(channel).copied().unwrap_or(0.0)
367        }
368    }
369}
370
371/// Convert a schema-level `Quantization` (which also carries the quantized
372/// `dtype`) into the tensor-crate `edgefirst_tensor::Quantization` attached
373/// to a `Tensor<T>` at runtime. The `dtype` field is dropped — the tensor's
374/// `T` supplies the storage element type directly.
375///
376/// Returns `Err` on any length-mismatch / axis-out-of-range condition; the
377/// schema's `Quantization::validate_shape` has looser rules (accepts absent
378/// axis on per-channel) that the tensor crate does not. Callers should prefer
379/// the v2 parse path which normalizes.
380impl TryFrom<&Quantization> for edgefirst_tensor::Quantization {
381    type Error = edgefirst_tensor::Error;
382
383    fn try_from(q: &Quantization) -> Result<Self, Self::Error> {
384        match (q.scale.as_slice(), q.zero_point.as_deref(), q.axis) {
385            // Per-tensor symmetric: single scale, no zp, no axis.
386            ([scale], None, None) => Ok(Self::per_tensor_symmetric(*scale)),
387            // Per-tensor asymmetric: single scale, single zp.
388            ([scale], Some([zp]), None) => Ok(Self::per_tensor(*scale, *zp)),
389            // Per-tensor asymmetric with redundant axis — treat as per-tensor.
390            ([scale], Some([zp]), Some(_)) => Ok(Self::per_tensor(*scale, *zp)),
391            ([scale], None, Some(_)) => Ok(Self::per_tensor_symmetric(*scale)),
392            // Per-channel — axis required.
393            (scales, None, Some(axis)) if scales.len() > 1 => {
394                Self::per_channel_symmetric(scales.to_vec(), axis)
395            }
396            (scales, Some(zps), Some(axis)) if scales.len() > 1 => {
397                Self::per_channel(scales.to_vec(), zps.to_vec(), axis)
398            }
399            // Per-channel without axis — invalid.
400            (scales, _, None) if scales.len() > 1 => {
401                Err(edgefirst_tensor::Error::QuantizationInvalid {
402                    field: "axis",
403                    expected: "Some(axis) for per-channel".into(),
404                    got: "None".into(),
405                })
406            }
407            _ => Err(edgefirst_tensor::Error::QuantizationInvalid {
408                field: "scale",
409                expected: "non-empty".into(),
410                got: format!("len={}", q.scale.len()),
411            }),
412        }
413    }
414}
415
416/// Accept a scalar or a JSON array when deserializing a `Vec<f32>`.
417fn deserialize_scalar_or_vec_f32<'de, D>(de: D) -> Result<Vec<f32>, D::Error>
418where
419    D: serde::Deserializer<'de>,
420{
421    #[derive(Deserialize)]
422    #[serde(untagged)]
423    enum OneOrMany {
424        One(f32),
425        Many(Vec<f32>),
426    }
427    match OneOrMany::deserialize(de)? {
428        OneOrMany::One(v) => Ok(vec![v]),
429        OneOrMany::Many(vs) => Ok(vs),
430    }
431}
432
433/// Accept a scalar or a JSON array when deserializing an `Option<Vec<i32>>`.
434fn deserialize_opt_scalar_or_vec_i32<'de, D>(de: D) -> Result<Option<Vec<i32>>, D::Error>
435where
436    D: serde::Deserializer<'de>,
437{
438    #[derive(Deserialize)]
439    #[serde(untagged)]
440    enum OneOrMany {
441        One(i32),
442        Many(Vec<i32>),
443    }
444    match Option::<OneOrMany>::deserialize(de)? {
445        None => Ok(None),
446        Some(OneOrMany::One(v)) => Ok(Some(vec![v])),
447        Some(OneOrMany::Many(vs)) => Ok(Some(vs)),
448    }
449}
450
451/// FPN stride. `Square(s)` means `(s, s)`; `Rect(sx, sy)` supports
452/// non-square inputs.
453#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
454#[serde(untagged)]
455pub enum Stride {
456    Square(u32),
457    Rect([u32; 2]),
458}
459
460impl Stride {
461    /// Horizontal stride.
462    pub fn x(self) -> u32 {
463        match self {
464            Stride::Square(s) => s,
465            Stride::Rect([sx, _]) => sx,
466        }
467    }
468
469    /// Vertical stride.
470    pub fn y(self) -> u32 {
471        match self {
472            Stride::Square(s) => s,
473            Stride::Rect([_, sy]) => sy,
474        }
475    }
476}
477
478/// Semantic type of a logical output.
479#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
480#[serde(rename_all = "snake_case")]
481pub enum LogicalType {
482    /// Bounding box coordinates.
483    Boxes,
484    /// Per-class or class-aggregate scores.
485    Scores,
486    /// Objectness scores (YOLOv5-style `obj_x_class`).
487    Objectness,
488    /// End-to-end class indices.
489    Classes,
490    /// Mask coefficients for instance segmentation.
491    MaskCoefs,
492    /// Instance segmentation prototypes.
493    Protos,
494    /// Facial / keypoint landmarks.
495    Landmarks,
496    /// Fully decoded post-NMS detections (end-to-end models).
497    Detections,
498    /// Semantic segmentation output (ModelPack).
499    Segmentation,
500    /// Semantic segmentation masks (ModelPack).
501    Masks,
502    /// ModelPack anchor-grid raw output requiring anchor decode.
503    Detection,
504}
505
506/// Semantic type of a physical output.
507///
508/// Physical outputs either share their parent's type (per-scale splits
509/// carry the parent's name) or declare a channel sub-split such as
510/// `boxes_xy` / `boxes_wh`.
511#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
512#[serde(rename_all = "snake_case")]
513pub enum PhysicalType {
514    Boxes,
515    Scores,
516    Objectness,
517    Classes,
518    MaskCoefs,
519    Protos,
520    Landmarks,
521    Detections,
522    Segmentation,
523    Masks,
524    Detection,
525    /// ARA-2 xy channel sub-split.
526    BoxesXy,
527    /// ARA-2 wh channel sub-split.
528    BoxesWh,
529}
530
531/// Box encoding for `boxes` logical outputs.
532#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
533#[serde(rename_all = "snake_case")]
534pub enum BoxEncoding {
535    /// Distribution Focal Loss: `reg_max × 4` channels, softmax +
536    /// weighted sum recovers 4 coordinates (YOLOv8, YOLO11).
537    Dfl,
538    /// Direct 4-channel coordinates, already decoded (YOLO26,
539    /// ARA-2 post-split).
540    #[serde(alias = "ltrb")]
541    Direct,
542    /// Anchor-based grid offsets with sigmoid + anchor-scale transform
543    /// per cell (YOLOv5, SSD MobileNet, ModelPack).
544    Anchor,
545}
546
547/// Score format for `scores` logical outputs.
548#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
549#[serde(rename_all = "snake_case")]
550pub enum ScoreFormat {
551    /// Each anchor outputs `[nc]` class probabilities directly
552    /// (YOLOv8, YOLO11, YOLO26).
553    PerClass,
554    /// Each anchor outputs `[nc]` class probabilities; final confidence
555    /// is `objectness × class_score` via a separate `objectness` logical
556    /// output (YOLOv5).
557    ObjXClass,
558}
559
560/// Activation function applied to or required by a physical tensor.
561#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
562#[serde(rename_all = "snake_case")]
563pub enum Activation {
564    Sigmoid,
565    Softmax,
566    Tanh,
567}
568
569/// Decoder framework for a logical output.
570#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
571pub enum DecoderKind {
572    /// Au-Zone ModelPack anchor-based YOLO decoder.
573    #[serde(rename = "modelpack")]
574    ModelPack,
575    /// Ultralytics anchor-free DFL decoder (YOLOv5/v8/v11/v26).
576    #[serde(rename = "ultralytics")]
577    Ultralytics,
578}
579
580/// YOLO architecture version for Ultralytics decoders.
581#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
582#[serde(rename_all = "snake_case")]
583pub enum DecoderVersion {
584    Yolov5,
585    Yolov8,
586    Yolo11,
587    Yolo26,
588}
589
590impl DecoderVersion {
591    /// Returns `true` for architectures with embedded NMS (YOLO26).
592    pub fn is_end_to_end(self) -> bool {
593        matches!(self, DecoderVersion::Yolo26)
594    }
595}
596
597/// HAL NMS mode.
598#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
599#[serde(rename_all = "snake_case")]
600pub enum NmsMode {
601    /// Suppress overlapping boxes regardless of class label.
602    ClassAgnostic,
603    /// Only suppress boxes sharing a class label and overlapping above
604    /// the IoU threshold.
605    ClassAware,
606}
607
608/// Quantized or floating-point data type.
609#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
610#[serde(rename_all = "snake_case")]
611pub enum DType {
612    Int8,
613    Uint8,
614    Int16,
615    Uint16,
616    Int32,
617    Uint32,
618    Float16,
619    Float32,
620}
621
622impl DType {
623    /// Returns the tensor's byte width per element.
624    pub fn size_bytes(self) -> usize {
625        match self {
626            DType::Int8 | DType::Uint8 => 1,
627            DType::Int16 | DType::Uint16 | DType::Float16 => 2,
628            DType::Int32 | DType::Uint32 | DType::Float32 => 4,
629        }
630    }
631
632    /// Returns `true` for integer dtypes (quantized tensors).
633    pub fn is_integer(self) -> bool {
634        matches!(
635            self,
636            DType::Int8
637                | DType::Uint8
638                | DType::Int16
639                | DType::Uint16
640                | DType::Int32
641                | DType::Uint32
642        )
643    }
644
645    /// Returns `true` for floating-point dtypes.
646    pub fn is_float(self) -> bool {
647        matches!(self, DType::Float16 | DType::Float32)
648    }
649}
650
651// =============================================================================
652// Parsing entry points + legacy v1 compatibility shim.
653// =============================================================================
654
655impl SchemaV2 {
656    /// Parse schema metadata from a JSON string.
657    ///
658    /// Auto-detects the schema version from the `schema_version` field.
659    /// Absent or `1` → legacy v1 metadata converted to v2 in memory.
660    /// `2` → parsed as v2 directly. Any version higher than
661    /// [`MAX_SUPPORTED_SCHEMA_VERSION`] is rejected with
662    /// [`DecoderError::NotSupported`].
663    pub fn parse_json(s: &str) -> DecoderResult<Self> {
664        let value: serde_json::Value = serde_json::from_str(s)?;
665        Self::from_json_value(value)
666    }
667
668    /// Parse schema metadata from a YAML string.
669    ///
670    /// Same version-detection logic as [`SchemaV2::parse_json`].
671    pub fn parse_yaml(s: &str) -> DecoderResult<Self> {
672        let value: serde_yaml::Value = serde_yaml::from_str(s)?;
673        let json = serde_json::to_value(value)
674            .map_err(|e| DecoderError::InvalidConfig(format!("yaml→json bridge failed: {e}")))?;
675        Self::from_json_value(json)
676    }
677
678    /// Parse schema metadata from a file, auto-detecting JSON vs YAML
679    /// from the file extension. Unknown extensions are parsed as JSON
680    /// first then as YAML as a fallback.
681    pub fn parse_file(path: impl AsRef<std::path::Path>) -> DecoderResult<Self> {
682        let path = path.as_ref();
683        let content = std::fs::read_to_string(path)
684            .map_err(|e| DecoderError::InvalidConfig(format!("read {}: {e}", path.display())))?;
685        let ext = path
686            .extension()
687            .and_then(|e| e.to_str())
688            .map(str::to_ascii_lowercase);
689        match ext.as_deref() {
690            Some("json") => Self::parse_json(&content),
691            Some("yaml") | Some("yml") => Self::parse_yaml(&content),
692            _ => Self::parse_json(&content).or_else(|_| Self::parse_yaml(&content)),
693        }
694    }
695
696    /// Parse from an already-deserialized `serde_json::Value`. Useful for
697    /// callers that have already done the initial deserialization step.
698    pub fn from_json_value(value: serde_json::Value) -> DecoderResult<Self> {
699        let version = value
700            .get("schema_version")
701            .and_then(|v| v.as_u64())
702            .map(|v| v as u32)
703            .unwrap_or(1);
704
705        if version > MAX_SUPPORTED_SCHEMA_VERSION {
706            return Err(DecoderError::NotSupported(format!(
707                "schema_version {version} is not supported by this HAL \
708                 (maximum supported version is {MAX_SUPPORTED_SCHEMA_VERSION}); \
709                 upgrade the HAL or downgrade the metadata"
710            )));
711        }
712
713        if version >= 2 {
714            serde_json::from_value(value).map_err(DecoderError::Json)
715        } else {
716            let v1: ConfigOutputs = serde_json::from_value(value).map_err(DecoderError::Json)?;
717            Self::from_v1(&v1)
718        }
719    }
720
721    /// Convert a legacy v1 [`ConfigOutputs`] to an equivalent v2
722    /// [`SchemaV2`] in memory.
723    ///
724    /// The conversion preserves:
725    /// - Output order and types (mapped to their v2 [`LogicalType`]).
726    /// - Quantization (v1 `QuantTuple(scale, zp)` → v2 [`Quantization`] with
727    ///   a single scalar scale/zero_point and unspecified dtype).
728    /// - `dshape`, `shape`, `anchors`, `normalized` fields.
729    /// - Root-level `nms` and `decoder_version`.
730    ///
731    /// Fields v1 does not carry (tensor `dtype`, per-channel quant, box
732    /// encoding, score format, activation metadata, stride on physical
733    /// children) are left as `None`. The v2 decoder is expected to infer
734    /// these from the runtime tensor type and the legacy decoder
735    /// dispatch rules.
736    pub fn from_v1(v1: &ConfigOutputs) -> DecoderResult<Self> {
737        let outputs = v1
738            .outputs
739            .iter()
740            .map(logical_from_v1)
741            .collect::<DecoderResult<Vec<_>>>()?;
742        Ok(SchemaV2 {
743            schema_version: 2,
744            input: None,
745            outputs,
746            nms: v1.nms.as_ref().map(NmsMode::from_v1),
747            decoder_version: v1.decoder_version.as_ref().map(DecoderVersion::from_v1),
748        })
749    }
750}
751
752impl SchemaV2 {
753    /// Downconvert a v2 schema to a legacy [`ConfigOutputs`] for the
754    /// v1 decoder dispatch path.
755    ///
756    /// Each [`LogicalOutput`] maps to one [`ConfigOutput`] variant
757    /// selected by [`LogicalType`]; per-tensor scalar quantization
758    /// becomes a `QuantTuple(scale, zp)`; and `decoder`, `anchors`,
759    /// `normalized` are copied verbatim.
760    ///
761    /// This conversion does **not** reject logical outputs that also
762    /// declare physical children (per-scale FPN splits, or channel
763    /// sub-splits such as ARA-2 `boxes_xy` / `boxes_wh`). The returned
764    /// legacy config captures the logical-level metadata, while the
765    /// physical-to-logical merge is handled separately by the
766    /// [`DecodeProgram`](crate::decoder::merge::DecodeProgram) that
767    /// [`DecoderBuilder::build`](crate::decoder::builder::DecoderBuilder::build)
768    /// compiles alongside this legacy config.
769    ///
770    /// Returns [`DecoderError::NotSupported`] when the schema uses
771    /// features the v1 decoder cannot express at the logical level:
772    /// - Per-channel quantization arrays on a logical output.
773    /// - `encoding: dfl` on a **flat** logical output (no physical
774    ///   children). DFL combined with per-scale children is handled by
775    ///   the merge path (see [`crate::decoder::merge::DecodeProgram`])
776    ///   which decodes the distribution before producing the merged
777    ///   post-decode `(1, 4, total_anchors)` tensor the legacy decoder
778    ///   consumes.
779    pub fn to_legacy_config_outputs(&self) -> DecoderResult<ConfigOutputs> {
780        let mut outputs = Vec::with_capacity(self.outputs.len());
781        for logical in &self.outputs {
782            // Typeless logical outputs are carried for round-tripping
783            // but are not required for decoding. Skip them — the decoder
784            // builder will surface its own "missing required output"
785            // error if a decode-critical role (e.g. `boxes`) is absent,
786            // which is more actionable than a serde "missing type" error.
787            if logical.type_.is_none() {
788                continue;
789            }
790            // Flat DFL (no children) remains unsupported — the HAL has
791            // no path that applies softmax + dist2bbox to a single
792            // `(1, 4·reg_max, anchors)` tensor yet. DFL with per-scale
793            // children is decoded by the merge path, so we let it
794            // through here and rely on the merged logical shape (post-
795            // decode 4 channels) being valid for the legacy dispatch.
796            if logical.type_ == Some(LogicalType::Boxes)
797                && logical.encoding == Some(BoxEncoding::Dfl)
798                && logical.outputs.is_empty()
799            {
800                return Err(DecoderError::NotSupported(format!(
801                    "`boxes` output `{}` has `encoding: dfl` on a flat \
802                     logical (no per-scale children); the HAL's DFL \
803                     decode kernel only runs inside the per-scale merge \
804                     path. Split the boxes output into per-FPN-level \
805                     children (Hailo convention) or pre-decode to 4 \
806                     channels in the model graph (TFLite convention).",
807                    logical.name.as_deref().unwrap_or("<anonymous>"),
808                )));
809            }
810            if let Some(q) = &logical.quantization {
811                if q.is_per_channel() {
812                    return Err(DecoderError::NotSupported(format!(
813                        "logical `{}` uses per-channel quantization \
814                         (axis {:?}, {} scales); the v1 decoder only \
815                         supports per-tensor quantization",
816                        logical.name.as_deref().unwrap_or("<anonymous>"),
817                        q.axis,
818                        q.scale.len(),
819                    )));
820                }
821            }
822            outputs.push(logical_to_legacy_config_output(logical)?);
823        }
824        Ok(ConfigOutputs {
825            outputs,
826            nms: self.nms.map(NmsMode::to_v1),
827            decoder_version: self.decoder_version.map(|v| v.to_v1()),
828        })
829    }
830
831    /// Validate the schema against the rules the HAL enforces.
832    ///
833    /// Rules checked:
834    /// - `schema_version` in `[1, MAX_SUPPORTED_SCHEMA_VERSION]`.
835    /// - Physical children, when present, carry non-empty `name` fields
836    ///   so tensor binding by name is unambiguous.
837    /// - All physical children of a given logical output have pairwise
838    ///   distinct shapes (shape-based binding safety, per HailoRT spec).
839    /// - For a `boxes` logical output with `encoding: dfl`, every
840    ///   physical child shape has a `num_features` (or last) dimension
841    ///   divisible by 4 (the box-coordinate count).
842    /// - Per-scale splits carry `stride` on every child.
843    /// - Mixed per-scale + channel-sub-split decompositions are
844    ///   rejected (the spec permits only one merge strategy per logical).
845    /// - `end2end` models (decoder_version=yolo26 with `detections`
846    ///   output) do not also declare per-scale split children on that
847    ///   output.
848    pub fn validate(&self) -> DecoderResult<()> {
849        if self.schema_version == 0 || self.schema_version > MAX_SUPPORTED_SCHEMA_VERSION {
850            return Err(DecoderError::InvalidConfig(format!(
851                "schema_version {} outside supported range [1, {MAX_SUPPORTED_SCHEMA_VERSION}]",
852                self.schema_version
853            )));
854        }
855
856        for logical in &self.outputs {
857            validate_logical(logical)?;
858        }
859
860        Ok(())
861    }
862}
863
864fn validate_logical(logical: &LogicalOutput) -> DecoderResult<()> {
865    if logical.outputs.is_empty() {
866        return Ok(());
867    }
868
869    // All children must carry a name for unambiguous tensor binding.
870    for child in &logical.outputs {
871        if child.name.is_empty() {
872            return Err(DecoderError::InvalidConfig(format!(
873                "physical child of logical `{}` is missing `name`; name is \
874                 required for tensor binding",
875                logical.name.as_deref().unwrap_or("<anonymous>")
876            )));
877        }
878    }
879
880    // Uniqueness of physical child shapes *within the same type* — two
881    // children with distinct types (e.g. ARA-2 `boxes_xy` + `boxes_wh`)
882    // may legitimately share shape, since type disambiguates the binding.
883    //
884    // Typeless children are excluded from this check: shape uniqueness
885    // only matters when we need to bind a tensor by type+shape, and
886    // typeless children are treated as opaque passthrough — name-based
887    // binding still disambiguates them from each other.
888    for (i, a) in logical.outputs.iter().enumerate() {
889        for b in &logical.outputs[i + 1..] {
890            let (Some(ta), Some(tb)) = (a.type_, b.type_) else {
891                continue;
892            };
893            if a.shape == b.shape && ta == tb {
894                return Err(DecoderError::InvalidConfig(format!(
895                    "physical children `{}` and `{}` share shape {:?} and \
896                     type; tensor binding cannot be resolved",
897                    a.name, b.name, a.shape
898                )));
899            }
900        }
901    }
902
903    // Merge strategy must be uniform: either all children carry `stride`
904    // (per-scale split) or none (channel sub-split). Mixed decompositions
905    // are ill-defined.
906    let strided: Vec<_> = logical.outputs.iter().map(|c| c.stride.is_some()).collect();
907    let all_strided = strided.iter().all(|&b| b);
908    let none_strided = strided.iter().all(|&b| !b);
909    if !(all_strided || none_strided) {
910        return Err(DecoderError::InvalidConfig(format!(
911            "logical `{}` mixes per-scale children (with stride) and \
912             channel sub-split children (without stride); decomposition \
913             must be uniform",
914            logical.name.as_deref().unwrap_or("<anonymous>")
915        )));
916    }
917
918    // DFL boxes: every child's feature axis must be divisible by 4
919    // (otherwise `reg_max` is not an integer).
920    if logical.type_ == Some(LogicalType::Boxes) && logical.encoding == Some(BoxEncoding::Dfl) {
921        for child in &logical.outputs {
922            if let Some(feat) = last_feature_axis(child) {
923                if feat % 4 != 0 {
924                    return Err(DecoderError::InvalidConfig(format!(
925                        "DFL boxes child `{}` feature axis {feat} is not \
926                         divisible by 4 (reg_max×4)",
927                        child.name
928                    )));
929                }
930            }
931        }
932    }
933
934    Ok(())
935}
936
937/// Resolve the channel / feature count from a physical child's dshape
938/// when present, otherwise from the last dimension of its shape.
939pub(crate) fn last_feature_axis(child: &PhysicalOutput) -> Option<usize> {
940    // Prefer explicit named dimensions: NumFeatures, NumClasses,
941    // NumProtos, BoxCoords, NumAnchorsXFeatures.
942    for (name, size) in &child.dshape {
943        if matches!(
944            name,
945            DimName::NumFeatures
946                | DimName::NumClasses
947                | DimName::NumProtos
948                | DimName::BoxCoords
949                | DimName::NumAnchorsXFeatures
950        ) {
951            return Some(*size);
952        }
953    }
954    child.shape.last().copied()
955}
956
957fn quantization_from_v1(q: Option<QuantTuple>) -> Option<Quantization> {
958    q.map(|QuantTuple(scale, zp)| Quantization {
959        scale: vec![scale],
960        zero_point: Some(vec![zp]),
961        axis: None,
962        dtype: None,
963    })
964}
965
966fn logical_from_v1(v1: &ConfigOutput) -> DecoderResult<LogicalOutput> {
967    match v1 {
968        ConfigOutput::Detection(d) => {
969            // v1 Detection covers two semantic cases that v2 separates:
970            //   - ModelPack anchor-grid (decoder=modelpack, anchors present)
971            //   - YOLO legacy combined (decoder=ultralytics, no anchors)
972            // Both map to v2 LogicalType::Detection; the decoder dispatch
973            // still differentiates via the `decoder` + `anchors` fields.
974            let encoding = match (d.decoder, d.anchors.is_some()) {
975                (configs::DecoderType::ModelPack, true) => Some(BoxEncoding::Anchor),
976                (configs::DecoderType::Ultralytics, _) => Some(BoxEncoding::Direct),
977                // ModelPack without anchors — keep encoding unset; the
978                // decoder may not need it.
979                (configs::DecoderType::ModelPack, false) => None,
980            };
981            Ok(LogicalOutput {
982                name: None,
983                type_: Some(LogicalType::Detection),
984                shape: d.shape.clone(),
985                dshape: d.dshape.clone(),
986                decoder: Some(DecoderKind::from_v1(d.decoder)),
987                encoding,
988                score_format: None,
989                normalized: d.normalized,
990                anchors: d.anchors.clone(),
991                stride: None,
992                dtype: None,
993                quantization: quantization_from_v1(d.quantization),
994                outputs: Vec::new(),
995                activation_applied: None,
996                activation_required: None,
997            })
998        }
999        ConfigOutput::Boxes(b) => Ok(LogicalOutput {
1000            name: None,
1001            type_: Some(LogicalType::Boxes),
1002            shape: b.shape.clone(),
1003            dshape: b.dshape.clone(),
1004            decoder: Some(DecoderKind::from_v1(b.decoder)),
1005            // v1 boxes are always pre-decoded 4-channel (the legacy
1006            // convention). Explicitly declare Direct so the v2 dispatch
1007            // doesn't try DFL decoding on them.
1008            encoding: Some(BoxEncoding::Direct),
1009            score_format: None,
1010            normalized: b.normalized,
1011            anchors: None,
1012            stride: None,
1013            dtype: None,
1014            quantization: quantization_from_v1(b.quantization),
1015            outputs: Vec::new(),
1016            activation_applied: None,
1017            activation_required: None,
1018        }),
1019        ConfigOutput::Scores(s) => Ok(LogicalOutput {
1020            name: None,
1021            type_: Some(LogicalType::Scores),
1022            shape: s.shape.clone(),
1023            dshape: s.dshape.clone(),
1024            decoder: Some(DecoderKind::from_v1(s.decoder)),
1025            encoding: None,
1026            // v1 does not declare score format explicitly; assume per_class
1027            // (YOLOv8-style). YOLOv5 users must migrate to v2 to get
1028            // obj_x_class behaviour.
1029            score_format: Some(ScoreFormat::PerClass),
1030            normalized: None,
1031            anchors: None,
1032            stride: None,
1033            dtype: None,
1034            quantization: quantization_from_v1(s.quantization),
1035            outputs: Vec::new(),
1036            activation_applied: None,
1037            activation_required: None,
1038        }),
1039        ConfigOutput::Protos(p) => Ok(LogicalOutput {
1040            name: None,
1041            type_: Some(LogicalType::Protos),
1042            shape: p.shape.clone(),
1043            dshape: p.dshape.clone(),
1044            // protos are consumed directly; decoder field is informational.
1045            decoder: Some(DecoderKind::from_v1(p.decoder)),
1046            encoding: None,
1047            score_format: None,
1048            normalized: None,
1049            anchors: None,
1050            stride: None,
1051            dtype: None,
1052            quantization: quantization_from_v1(p.quantization),
1053            outputs: Vec::new(),
1054            activation_applied: None,
1055            activation_required: None,
1056        }),
1057        ConfigOutput::MaskCoefficients(m) => Ok(LogicalOutput {
1058            name: None,
1059            type_: Some(LogicalType::MaskCoefs),
1060            shape: m.shape.clone(),
1061            dshape: m.dshape.clone(),
1062            decoder: Some(DecoderKind::from_v1(m.decoder)),
1063            encoding: None,
1064            score_format: None,
1065            normalized: None,
1066            anchors: None,
1067            stride: None,
1068            dtype: None,
1069            quantization: quantization_from_v1(m.quantization),
1070            outputs: Vec::new(),
1071            activation_applied: None,
1072            activation_required: None,
1073        }),
1074        ConfigOutput::Segmentation(seg) => Ok(LogicalOutput {
1075            name: None,
1076            type_: Some(LogicalType::Segmentation),
1077            shape: seg.shape.clone(),
1078            dshape: seg.dshape.clone(),
1079            decoder: Some(DecoderKind::from_v1(seg.decoder)),
1080            encoding: None,
1081            score_format: None,
1082            normalized: None,
1083            anchors: None,
1084            stride: None,
1085            dtype: None,
1086            quantization: quantization_from_v1(seg.quantization),
1087            outputs: Vec::new(),
1088            activation_applied: None,
1089            activation_required: None,
1090        }),
1091        ConfigOutput::Mask(m) => Ok(LogicalOutput {
1092            name: None,
1093            type_: Some(LogicalType::Masks),
1094            shape: m.shape.clone(),
1095            dshape: m.dshape.clone(),
1096            decoder: Some(DecoderKind::from_v1(m.decoder)),
1097            encoding: None,
1098            score_format: None,
1099            normalized: None,
1100            anchors: None,
1101            stride: None,
1102            dtype: None,
1103            quantization: quantization_from_v1(m.quantization),
1104            outputs: Vec::new(),
1105            activation_applied: None,
1106            activation_required: None,
1107        }),
1108        ConfigOutput::Classes(c) => Ok(LogicalOutput {
1109            name: None,
1110            type_: Some(LogicalType::Classes),
1111            shape: c.shape.clone(),
1112            dshape: c.dshape.clone(),
1113            decoder: Some(DecoderKind::from_v1(c.decoder)),
1114            encoding: None,
1115            score_format: None,
1116            normalized: None,
1117            anchors: None,
1118            stride: None,
1119            dtype: None,
1120            quantization: quantization_from_v1(c.quantization),
1121            outputs: Vec::new(),
1122            activation_applied: None,
1123            activation_required: None,
1124        }),
1125    }
1126}
1127
1128impl DecoderKind {
1129    /// Convert a legacy v1 [`configs::DecoderType`] to a v2 [`DecoderKind`].
1130    pub fn from_v1(v: configs::DecoderType) -> Self {
1131        match v {
1132            configs::DecoderType::ModelPack => DecoderKind::ModelPack,
1133            configs::DecoderType::Ultralytics => DecoderKind::Ultralytics,
1134        }
1135    }
1136
1137    /// Convert back to the legacy v1 [`configs::DecoderType`].
1138    pub fn to_v1(self) -> configs::DecoderType {
1139        match self {
1140            DecoderKind::ModelPack => configs::DecoderType::ModelPack,
1141            DecoderKind::Ultralytics => configs::DecoderType::Ultralytics,
1142        }
1143    }
1144}
1145
1146impl DecoderVersion {
1147    /// Convert a legacy v1 [`configs::DecoderVersion`] to a v2 [`DecoderVersion`].
1148    pub fn from_v1(v: &configs::DecoderVersion) -> Self {
1149        match v {
1150            configs::DecoderVersion::Yolov5 => DecoderVersion::Yolov5,
1151            configs::DecoderVersion::Yolov8 => DecoderVersion::Yolov8,
1152            configs::DecoderVersion::Yolo11 => DecoderVersion::Yolo11,
1153            configs::DecoderVersion::Yolo26 => DecoderVersion::Yolo26,
1154        }
1155    }
1156
1157    /// Convert back to the legacy v1 [`configs::DecoderVersion`].
1158    pub fn to_v1(self) -> configs::DecoderVersion {
1159        match self {
1160            DecoderVersion::Yolov5 => configs::DecoderVersion::Yolov5,
1161            DecoderVersion::Yolov8 => configs::DecoderVersion::Yolov8,
1162            DecoderVersion::Yolo11 => configs::DecoderVersion::Yolo11,
1163            DecoderVersion::Yolo26 => configs::DecoderVersion::Yolo26,
1164        }
1165    }
1166}
1167
1168impl NmsMode {
1169    /// Convert a legacy v1 [`configs::Nms`] to a v2 [`NmsMode`].
1170    pub fn from_v1(v: &configs::Nms) -> Self {
1171        match v {
1172            configs::Nms::Auto | configs::Nms::ClassAgnostic => NmsMode::ClassAgnostic,
1173            configs::Nms::ClassAware => NmsMode::ClassAware,
1174        }
1175    }
1176
1177    /// Convert back to the legacy v1 [`configs::Nms`].
1178    pub fn to_v1(self) -> configs::Nms {
1179        match self {
1180            NmsMode::ClassAgnostic => configs::Nms::ClassAgnostic,
1181            NmsMode::ClassAware => configs::Nms::ClassAware,
1182        }
1183    }
1184}
1185
1186/// Convert a quantized v2 [`Quantization`] to a v1 [`QuantTuple`]. Only
1187/// valid for per-tensor scalar quantization.
1188fn quantization_to_legacy(q: &Quantization) -> DecoderResult<QuantTuple> {
1189    if q.is_per_channel() {
1190        return Err(DecoderError::NotSupported(
1191            "per-channel quantization cannot be expressed as a v1 QuantTuple".into(),
1192        ));
1193    }
1194    let scale = *q.scale.first().unwrap_or(&0.0);
1195    let zp = q.zero_point_at(0);
1196    Ok(QuantTuple(scale, zp))
1197}
1198
1199/// Drop axes named `padding` (always size 1 per spec) from the given
1200/// shape/dshape pair. ARA-2 emits logical shapes like
1201/// `[1, 4, 8400, 1]` with a trailing `padding=1` dim to satisfy the
1202/// converter's rank requirements — the decoder only cares about the
1203/// semantic axes, so squeezing is safe.
1204pub(crate) fn squeeze_padding_dims(
1205    shape: Vec<usize>,
1206    dshape: Vec<(DimName, usize)>,
1207) -> (Vec<usize>, Vec<(DimName, usize)>) {
1208    // dshape is `#[serde(default)]`; a logical output without named dims
1209    // arrives here with an empty dshape. `zip` would stop at the shorter
1210    // iterator and silently truncate shape to `[]`, so short-circuit.
1211    if dshape.is_empty() {
1212        return (shape, dshape);
1213    }
1214    let keep: Vec<bool> = dshape
1215        .iter()
1216        .map(|(n, _)| !matches!(n, DimName::Padding))
1217        .collect();
1218    let shape = shape
1219        .into_iter()
1220        .zip(keep.iter())
1221        .filter_map(|(s, &k)| k.then_some(s))
1222        .collect();
1223    let dshape = dshape
1224        .into_iter()
1225        .zip(keep.iter())
1226        .filter_map(|(d, &k)| k.then_some(d))
1227        .collect();
1228    (shape, dshape)
1229}
1230
1231/// Return the list of axis indices in `dshape` that carry the
1232/// `padding` dim name. Indices are returned in descending order so
1233/// that `remove_axis` calls can be applied directly without tracking
1234/// index shifts.
1235pub(crate) fn padding_axes(dshape: &[(DimName, usize)]) -> Vec<usize> {
1236    let mut v: Vec<usize> = dshape
1237        .iter()
1238        .enumerate()
1239        .filter_map(|(i, (n, _))| matches!(n, DimName::Padding).then_some(i))
1240        .collect();
1241    v.sort_by(|a, b| b.cmp(a));
1242    v
1243}
1244
1245fn logical_to_legacy_config_output(logical: &LogicalOutput) -> DecoderResult<ConfigOutput> {
1246    let decoder = logical
1247        .decoder
1248        .map(|d| d.to_v1())
1249        .unwrap_or(configs::DecoderType::Ultralytics);
1250    let quantization = logical
1251        .quantization
1252        .as_ref()
1253        .map(quantization_to_legacy)
1254        .transpose()?;
1255    // Squeeze explicit `padding` dims before handing to the legacy
1256    // dispatch: the v1 decoder's `verify_yolo_*` helpers require rank-3
1257    // shapes, but v2 metadata often carries an explicit `padding: 1`
1258    // axis (ARA-2).
1259    let (shape, dshape) = squeeze_padding_dims(logical.shape.clone(), logical.dshape.clone());
1260
1261    let ty = logical.type_.ok_or_else(|| {
1262        // Defense-in-depth: `to_legacy_config_outputs` already filters
1263        // typeless outputs, so reaching here means a new caller added a
1264        // bypass. Surface it as an internal error rather than panicking.
1265        DecoderError::InvalidConfig(format!(
1266            "logical output `{}` has no type; typeless outputs should be \
1267             filtered before legacy conversion",
1268            logical.name.as_deref().unwrap_or("<anonymous>")
1269        ))
1270    })?;
1271
1272    Ok(match ty {
1273        LogicalType::Boxes => ConfigOutput::Boxes(configs::Boxes {
1274            decoder,
1275            quantization,
1276            shape,
1277            dshape,
1278            normalized: logical.normalized,
1279        }),
1280        LogicalType::Scores => ConfigOutput::Scores(configs::Scores {
1281            decoder,
1282            quantization,
1283            shape,
1284            dshape,
1285        }),
1286        LogicalType::Protos => ConfigOutput::Protos(configs::Protos {
1287            decoder,
1288            quantization,
1289            shape,
1290            dshape,
1291        }),
1292        LogicalType::MaskCoefs => ConfigOutput::MaskCoefficients(configs::MaskCoefficients {
1293            decoder,
1294            quantization,
1295            shape,
1296            dshape,
1297        }),
1298        LogicalType::Segmentation => ConfigOutput::Segmentation(configs::Segmentation {
1299            decoder,
1300            quantization,
1301            shape,
1302            dshape,
1303        }),
1304        LogicalType::Masks => ConfigOutput::Mask(configs::Mask {
1305            decoder,
1306            quantization,
1307            shape,
1308            dshape,
1309        }),
1310        LogicalType::Classes => ConfigOutput::Classes(configs::Classes {
1311            decoder,
1312            quantization,
1313            shape,
1314            dshape,
1315        }),
1316        // Detection covers ModelPack anchor-grid and legacy YOLO combined.
1317        // Detections (plural) is end-to-end; maps to Detection with the
1318        // appropriate dimension layout.
1319        LogicalType::Detection | LogicalType::Detections => {
1320            ConfigOutput::Detection(configs::Detection {
1321                anchors: logical.anchors.clone(),
1322                decoder,
1323                quantization,
1324                shape,
1325                dshape,
1326                normalized: logical.normalized,
1327            })
1328        }
1329        // Objectness / Landmarks have no direct v1 equivalent; the v1
1330        // YOLOv5 path embedded objectness in the combined Detection.
1331        LogicalType::Objectness | LogicalType::Landmarks => {
1332            return Err(DecoderError::NotSupported(format!(
1333                "logical type {:?} has no legacy v1 equivalent; use the \
1334                 native v2 decoder path",
1335                ty
1336            )));
1337        }
1338    })
1339}
1340
1341#[cfg(test)]
1342#[cfg_attr(coverage_nightly, coverage(off))]
1343mod tests {
1344    use super::*;
1345
1346    #[test]
1347    fn schema_default_is_v2() {
1348        let s = SchemaV2::default();
1349        assert_eq!(s.schema_version, 2);
1350        assert!(s.outputs.is_empty());
1351    }
1352
1353    #[test]
1354    fn fixtures_round_trip_through_serde() {
1355        let yolov8 = include_str!("../../../testdata/per_scale/synthetic_yolov8n_schema.json");
1356        let _: super::SchemaV2 = serde_json::from_str(yolov8).expect("yolov8n fixture must parse");
1357
1358        let yolo26 = include_str!("../../../testdata/per_scale/synthetic_yolo26n_schema.json");
1359        let _: super::SchemaV2 = serde_json::from_str(yolo26).expect("yolo26n fixture must parse");
1360
1361        let flat = include_str!("../../../testdata/per_scale/synthetic_flat_schema.json");
1362        let _: super::SchemaV2 = serde_json::from_str(flat).expect("flat fixture must parse");
1363    }
1364
1365    #[test]
1366    fn box_encoding_accepts_ltrb_alias_for_direct() {
1367        let dfl: BoxEncoding = serde_json::from_str("\"dfl\"").unwrap();
1368        assert_eq!(dfl, BoxEncoding::Dfl);
1369
1370        let direct: BoxEncoding = serde_json::from_str("\"direct\"").unwrap();
1371        assert_eq!(direct, BoxEncoding::Direct);
1372
1373        // yolo26 metadata uses "ltrb" — must deserialise to Direct.
1374        let ltrb: BoxEncoding = serde_json::from_str("\"ltrb\"").unwrap();
1375        assert_eq!(ltrb, BoxEncoding::Direct);
1376    }
1377
1378    #[test]
1379    fn dtype_roundtrip() {
1380        for d in [
1381            DType::Int8,
1382            DType::Uint8,
1383            DType::Int16,
1384            DType::Uint16,
1385            DType::Float16,
1386            DType::Float32,
1387        ] {
1388            let j = serde_json::to_string(&d).unwrap();
1389            let back: DType = serde_json::from_str(&j).unwrap();
1390            assert_eq!(back, d);
1391        }
1392    }
1393
1394    #[test]
1395    fn dtype_widths() {
1396        assert_eq!(DType::Int8.size_bytes(), 1);
1397        assert_eq!(DType::Float16.size_bytes(), 2);
1398        assert_eq!(DType::Float32.size_bytes(), 4);
1399    }
1400
1401    #[test]
1402    fn stride_accepts_scalar_or_pair() {
1403        let a: Stride = serde_json::from_str("8").unwrap();
1404        let b: Stride = serde_json::from_str("[8, 16]").unwrap();
1405        assert_eq!(a, Stride::Square(8));
1406        assert_eq!(b, Stride::Rect([8, 16]));
1407        assert_eq!(a.x(), 8);
1408        assert_eq!(a.y(), 8);
1409        assert_eq!(b.x(), 8);
1410        assert_eq!(b.y(), 16);
1411    }
1412
1413    #[test]
1414    fn quantization_scalar_scale() {
1415        let j = r#"{"scale": 0.00392, "zero_point": 0, "dtype": "int8"}"#;
1416        let q: Quantization = serde_json::from_str(j).unwrap();
1417        assert!(q.is_per_tensor());
1418        assert!(q.is_symmetric());
1419        assert_eq!(q.scale_at(0), 0.00392);
1420        assert_eq!(q.scale_at(5), 0.00392);
1421        assert_eq!(q.zero_point_at(0), 0);
1422    }
1423
1424    #[test]
1425    fn quantization_per_channel() {
1426        let j = r#"{"scale": [0.054, 0.089, 0.195], "axis": 0, "dtype": "int8"}"#;
1427        let q: Quantization = serde_json::from_str(j).unwrap();
1428        assert!(q.is_per_channel());
1429        assert!(q.is_symmetric());
1430        assert_eq!(q.axis, Some(0));
1431        assert_eq!(q.scale_at(0), 0.054);
1432        assert_eq!(q.scale_at(2), 0.195);
1433    }
1434
1435    #[test]
1436    fn quantization_asymmetric_per_tensor() {
1437        let j = r#"{"scale": 0.176, "zero_point": 198, "dtype": "uint8"}"#;
1438        let q: Quantization = serde_json::from_str(j).unwrap();
1439        assert!(!q.is_symmetric());
1440        assert_eq!(q.zero_point_at(0), 198);
1441        assert_eq!(q.zero_point_at(10), 198);
1442    }
1443
1444    #[test]
1445    fn quantization_symmetric_default_zero_point() {
1446        let j = r#"{"scale": 0.00392, "dtype": "int8"}"#;
1447        let q: Quantization = serde_json::from_str(j).unwrap();
1448        assert!(q.is_symmetric());
1449        assert_eq!(q.zero_point_at(0), 0);
1450    }
1451
1452    #[test]
1453    fn quantization_to_tensor_per_tensor_asymmetric() {
1454        let q = Quantization {
1455            scale: vec![0.1],
1456            zero_point: Some(vec![-5]),
1457            axis: None,
1458            dtype: Some(DType::Int8),
1459        };
1460        let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
1461        assert!(t.is_per_tensor());
1462        assert!(!t.is_symmetric());
1463        assert_eq!(t.scale(), &[0.1]);
1464        assert_eq!(t.zero_point(), Some(&[-5][..]));
1465    }
1466
1467    #[test]
1468    fn quantization_to_tensor_per_tensor_symmetric() {
1469        let q = Quantization {
1470            scale: vec![0.05],
1471            zero_point: None,
1472            axis: None,
1473            dtype: Some(DType::Int8),
1474        };
1475        let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
1476        assert!(t.is_per_tensor());
1477        assert!(t.is_symmetric());
1478    }
1479
1480    #[test]
1481    fn quantization_to_tensor_per_channel_asymmetric() {
1482        let q = Quantization {
1483            scale: vec![0.1, 0.2, 0.3],
1484            zero_point: Some(vec![-1, 0, 1]),
1485            axis: Some(2),
1486            dtype: Some(DType::Int8),
1487        };
1488        let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
1489        assert!(t.is_per_channel());
1490        assert_eq!(t.axis(), Some(2));
1491        assert_eq!(t.scale().len(), 3);
1492        assert_eq!(t.zero_point().map(|z| z.len()), Some(3));
1493    }
1494
1495    #[test]
1496    fn quantization_to_tensor_per_channel_symmetric() {
1497        let q = Quantization {
1498            scale: vec![0.054, 0.089, 0.195],
1499            zero_point: None,
1500            axis: Some(0),
1501            dtype: Some(DType::Int8),
1502        };
1503        let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
1504        assert!(t.is_per_channel());
1505        assert!(t.is_symmetric());
1506        assert_eq!(t.axis(), Some(0));
1507    }
1508
1509    #[test]
1510    fn quantization_to_tensor_per_channel_missing_axis_errors() {
1511        let q = Quantization {
1512            scale: vec![0.1, 0.2, 0.3],
1513            zero_point: None,
1514            axis: None,
1515            dtype: None,
1516        };
1517        let err = edgefirst_tensor::Quantization::try_from(&q).unwrap_err();
1518        assert!(matches!(
1519            err,
1520            edgefirst_tensor::Error::QuantizationInvalid { .. }
1521        ));
1522    }
1523
1524    #[test]
1525    fn logical_output_flat_tflite_boxes() {
1526        // Example 3 from the spec: TFLite YOLOv8 detection, flat boxes
1527        let j = r#"{
1528          "name": "boxes", "type": "boxes",
1529          "shape": [1, 64, 8400],
1530          "dshape": [{"batch": 1}, {"num_features": 64}, {"num_boxes": 8400}],
1531          "dtype": "int8",
1532          "quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "int8"},
1533          "decoder": "ultralytics",
1534          "encoding": "dfl",
1535          "normalized": true
1536        }"#;
1537        let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1538        assert_eq!(lo.type_, Some(LogicalType::Boxes));
1539        assert_eq!(lo.encoding, Some(BoxEncoding::Dfl));
1540        assert_eq!(lo.normalized, Some(true));
1541        assert!(!lo.is_split());
1542        assert_eq!(lo.dtype, Some(DType::Int8));
1543    }
1544
1545    #[test]
1546    fn logical_output_hailo_per_scale_split() {
1547        // Example 5 from the spec: Hailo YOLOv8 boxes, per-scale split
1548        let j = r#"{
1549          "name": "boxes", "type": "boxes",
1550          "shape": [1, 64, 8400],
1551          "encoding": "dfl", "decoder": "ultralytics", "normalized": true,
1552          "outputs": [
1553            {
1554              "name": "boxes_0", "type": "boxes",
1555              "stride": 8, "scale_index": 0,
1556              "shape": [1, 80, 80, 64],
1557              "dshape": [{"batch": 1}, {"height": 80}, {"width": 80}, {"num_features": 64}],
1558              "dtype": "uint8",
1559              "quantization": {"scale": 0.0234, "zero_point": 128, "dtype": "uint8"}
1560            }
1561          ]
1562        }"#;
1563        let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1564        assert!(lo.is_split());
1565        assert_eq!(lo.outputs.len(), 1);
1566        let child = &lo.outputs[0];
1567        assert_eq!(child.name, "boxes_0");
1568        assert_eq!(child.type_, Some(PhysicalType::Boxes));
1569        assert_eq!(child.stride, Some(Stride::Square(8)));
1570        assert_eq!(child.scale_index, Some(0));
1571        assert_eq!(child.dtype, DType::Uint8);
1572    }
1573
1574    #[test]
1575    fn logical_output_ara2_xy_wh_channel_split() {
1576        // Example 4 from the spec: ARA-2 boxes split into xy/wh
1577        let j = r#"{
1578          "name": "boxes", "type": "boxes",
1579          "shape": [1, 4, 8400, 1],
1580          "encoding": "direct", "decoder": "ultralytics", "normalized": true,
1581          "outputs": [
1582            {
1583              "name": "_model_22_Div_1_output_0", "type": "boxes_xy",
1584              "shape": [1, 2, 8400, 1],
1585              "dshape": [{"batch": 1}, {"box_coords": 2}, {"num_boxes": 8400}, {"padding": 1}],
1586              "dtype": "int16",
1587              "quantization": {"scale": 3.129e-5, "zero_point": 0, "dtype": "int16"}
1588            },
1589            {
1590              "name": "_model_22_Sub_1_output_0", "type": "boxes_wh",
1591              "shape": [1, 2, 8400, 1],
1592              "dshape": [{"batch": 1}, {"box_coords": 2}, {"num_boxes": 8400}, {"padding": 1}],
1593              "dtype": "int16",
1594              "quantization": {"scale": 3.149e-5, "zero_point": 0, "dtype": "int16"}
1595            }
1596          ]
1597        }"#;
1598        let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1599        assert_eq!(lo.encoding, Some(BoxEncoding::Direct));
1600        assert_eq!(lo.outputs.len(), 2);
1601        assert_eq!(lo.outputs[0].type_, Some(PhysicalType::BoxesXy));
1602        assert_eq!(lo.outputs[1].type_, Some(PhysicalType::BoxesWh));
1603        assert!(lo.outputs[0].stride.is_none());
1604        assert!(lo.outputs[1].stride.is_none());
1605    }
1606
1607    #[test]
1608    fn logical_output_hailo_scores_sigmoid_applied() {
1609        let j = r#"{
1610          "name": "scores", "type": "scores",
1611          "shape": [1, 80, 8400],
1612          "decoder": "ultralytics", "score_format": "per_class",
1613          "outputs": [
1614            {
1615              "name": "scores_0", "type": "scores",
1616              "stride": 8, "scale_index": 0,
1617              "shape": [1, 80, 80, 80],
1618              "dshape": [{"batch": 1}, {"height": 80}, {"width": 80}, {"num_classes": 80}],
1619              "dtype": "uint8",
1620              "quantization": {"scale": 0.003922, "dtype": "uint8"},
1621              "activation_applied": "sigmoid"
1622            }
1623          ]
1624        }"#;
1625        let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1626        assert_eq!(lo.score_format, Some(ScoreFormat::PerClass));
1627        let child = &lo.outputs[0];
1628        assert_eq!(child.activation_applied, Some(Activation::Sigmoid));
1629        assert!(child.activation_required.is_none());
1630    }
1631
1632    #[test]
1633    fn yolo26_end_to_end_detections() {
1634        let j = r#"{
1635          "schema_version": 2,
1636          "decoder_version": "yolo26",
1637          "outputs": [{
1638            "name": "output0", "type": "detections",
1639            "shape": [1, 100, 6],
1640            "dshape": [{"batch": 1}, {"num_boxes": 100}, {"num_features": 6}],
1641            "dtype": "int8",
1642            "quantization": {"scale": 0.0078, "zero_point": 0, "dtype": "int8"},
1643            "normalized": false,
1644            "decoder": "ultralytics"
1645          }]
1646        }"#;
1647        let s: SchemaV2 = serde_json::from_str(j).unwrap();
1648        assert_eq!(s.decoder_version, Some(DecoderVersion::Yolo26));
1649        assert!(s.decoder_version.unwrap().is_end_to_end());
1650        assert_eq!(s.outputs[0].type_, Some(LogicalType::Detections));
1651        assert_eq!(s.outputs[0].normalized, Some(false));
1652        assert!(s.nms.is_none());
1653    }
1654
1655    #[test]
1656    fn modelpack_anchor_detection_with_rect_stride() {
1657        let j = r#"{
1658          "schema_version": 2,
1659          "outputs": [{
1660            "name": "output_0", "type": "detection",
1661            "shape": [1, 40, 40, 54],
1662            "dshape": [{"batch": 1}, {"height": 40}, {"width": 40}, {"num_anchors_x_features": 54}],
1663            "dtype": "uint8",
1664            "quantization": {"scale": 0.176, "zero_point": 198, "dtype": "uint8"},
1665            "decoder": "modelpack",
1666            "encoding": "anchor",
1667            "stride": [16, 16],
1668            "anchors": [[0.054, 0.065], [0.089, 0.139], [0.195, 0.196]]
1669          }]
1670        }"#;
1671        let s: SchemaV2 = serde_json::from_str(j).unwrap();
1672        let lo = &s.outputs[0];
1673        assert_eq!(lo.encoding, Some(BoxEncoding::Anchor));
1674        assert_eq!(lo.stride, Some(Stride::Rect([16, 16])));
1675        assert_eq!(lo.anchors.as_ref().map(|a| a.len()), Some(3));
1676    }
1677
1678    #[test]
1679    fn yolov5_obj_x_class_objectness_logical() {
1680        let j = r#"{
1681          "name": "objectness", "type": "objectness",
1682          "shape": [1, 3, 8400],
1683          "decoder": "ultralytics",
1684          "outputs": [{
1685            "name": "objectness_0", "type": "objectness",
1686            "stride": 8, "scale_index": 0,
1687            "shape": [1, 80, 80, 3],
1688            "dshape": [{"batch": 1}, {"height": 80}, {"width": 80}, {"num_features": 3}],
1689            "dtype": "uint8",
1690            "quantization": {"scale": 0.0039, "zero_point": 0, "dtype": "uint8"},
1691            "activation_applied": "sigmoid"
1692          }]
1693        }"#;
1694        let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1695        assert_eq!(lo.type_, Some(LogicalType::Objectness));
1696        assert_eq!(lo.outputs[0].activation_applied, Some(Activation::Sigmoid));
1697    }
1698
1699    #[test]
1700    fn direct_protos_no_decoder() {
1701        // protos are consumed directly — no `decoder` field
1702        let j = r#"{
1703          "name": "protos", "type": "protos",
1704          "shape": [1, 32, 160, 160],
1705          "dshape": [{"batch": 1}, {"num_protos": 32}, {"height": 160}, {"width": 160}],
1706          "dtype": "uint8",
1707          "quantization": {"scale": 0.0203, "zero_point": 45, "dtype": "uint8"},
1708          "stride": 4
1709        }"#;
1710        let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1711        assert_eq!(lo.type_, Some(LogicalType::Protos));
1712        assert!(lo.decoder.is_none());
1713        assert_eq!(lo.stride, Some(Stride::Square(4)));
1714    }
1715
1716    #[test]
1717    fn full_yolov8_tflite_flat_detection() {
1718        // Example 3: complete two-output YOLOv8 detection schema
1719        let j = r#"{
1720          "schema_version": 2,
1721          "decoder_version": "yolov8",
1722          "nms": "class_agnostic",
1723          "input": { "shape": [1, 640, 640, 3], "cameraadaptor": "rgb" },
1724          "outputs": [
1725            {
1726              "name": "boxes", "type": "boxes",
1727              "shape": [1, 64, 8400],
1728              "dshape": [{"batch": 1}, {"num_features": 64}, {"num_boxes": 8400}],
1729              "dtype": "int8",
1730              "quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "int8"},
1731              "decoder": "ultralytics",
1732              "encoding": "dfl",
1733              "normalized": true
1734            },
1735            {
1736              "name": "scores", "type": "scores",
1737              "shape": [1, 80, 8400],
1738              "dshape": [{"batch": 1}, {"num_classes": 80}, {"num_boxes": 8400}],
1739              "dtype": "int8",
1740              "quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "int8"},
1741              "decoder": "ultralytics",
1742              "score_format": "per_class"
1743            }
1744          ]
1745        }"#;
1746        let s: SchemaV2 = serde_json::from_str(j).unwrap();
1747        assert_eq!(s.schema_version, 2);
1748        assert_eq!(s.decoder_version, Some(DecoderVersion::Yolov8));
1749        assert_eq!(s.nms, Some(NmsMode::ClassAgnostic));
1750        assert_eq!(s.input.as_ref().unwrap().shape, vec![1, 640, 640, 3]);
1751        assert_eq!(s.outputs.len(), 2);
1752    }
1753
1754    #[test]
1755    fn schema_unknown_version_parses_without_validation() {
1756        // Parser accepts any u32; the decoder is responsible for rejecting
1757        // unsupported versions with a useful error.
1758        let j = r#"{"schema_version": 99, "outputs": []}"#;
1759        let s: SchemaV2 = serde_json::from_str(j).unwrap();
1760        assert_eq!(s.schema_version, 99);
1761    }
1762
1763    #[test]
1764    fn serde_roundtrip_preserves_fields() {
1765        let original = SchemaV2 {
1766            schema_version: 2,
1767            input: Some(InputSpec {
1768                shape: vec![1, 3, 640, 640],
1769                dshape: vec![],
1770                cameraadaptor: Some("rgb".into()),
1771            }),
1772            outputs: vec![LogicalOutput {
1773                name: Some("boxes".into()),
1774                type_: Some(LogicalType::Boxes),
1775                shape: vec![1, 4, 8400],
1776                dshape: vec![],
1777                decoder: Some(DecoderKind::Ultralytics),
1778                encoding: Some(BoxEncoding::Direct),
1779                score_format: None,
1780                normalized: Some(true),
1781                anchors: None,
1782                stride: None,
1783                dtype: Some(DType::Float32),
1784                quantization: None,
1785                outputs: vec![],
1786                activation_applied: None,
1787                activation_required: None,
1788            }],
1789            nms: Some(NmsMode::ClassAgnostic),
1790            decoder_version: Some(DecoderVersion::Yolov8),
1791        };
1792        let j = serde_json::to_string(&original).unwrap();
1793        let parsed: SchemaV2 = serde_json::from_str(&j).unwrap();
1794        assert_eq!(parsed, original);
1795    }
1796
1797    // ─── v1 → v2 shim tests ─────────────────────────────────
1798
1799    #[test]
1800    fn parse_v1_yaml_yolov8_seg_testdata() {
1801        let yaml = include_str!(concat!(
1802            env!("CARGO_MANIFEST_DIR"),
1803            "/../../testdata/yolov8_seg.yaml"
1804        ));
1805        let schema = SchemaV2::parse_yaml(yaml).expect("parse v1 yaml");
1806        assert_eq!(schema.schema_version, 2);
1807        assert_eq!(schema.outputs.len(), 2);
1808        // First output: Detection [1, 116, 8400]
1809        let det = &schema.outputs[0];
1810        assert_eq!(det.type_, Some(LogicalType::Detection));
1811        assert_eq!(det.shape, vec![1, 116, 8400]);
1812        assert_eq!(det.decoder, Some(DecoderKind::Ultralytics));
1813        assert_eq!(det.encoding, Some(BoxEncoding::Direct));
1814        let q = det.quantization.as_ref().unwrap();
1815        assert_eq!(q.scale.len(), 1);
1816        assert!((q.scale[0] - 0.021_287_762).abs() < 1e-6);
1817        assert_eq!(q.zero_point, Some(vec![31]));
1818        // Second output: Protos [1, 160, 160, 32]
1819        let protos = &schema.outputs[1];
1820        assert_eq!(protos.type_, Some(LogicalType::Protos));
1821        assert_eq!(protos.shape, vec![1, 160, 160, 32]);
1822    }
1823
1824    #[test]
1825    fn parse_v1_json_modelpack_split_testdata() {
1826        let json = include_str!(concat!(
1827            env!("CARGO_MANIFEST_DIR"),
1828            "/../../testdata/modelpack_split.json"
1829        ));
1830        let schema = SchemaV2::parse_json(json).expect("parse v1 json");
1831        assert_eq!(schema.schema_version, 2);
1832        assert_eq!(schema.outputs.len(), 2);
1833        // Both are ModelPack anchor detection with anchors
1834        for out in &schema.outputs {
1835            assert_eq!(out.type_, Some(LogicalType::Detection));
1836            assert_eq!(out.decoder, Some(DecoderKind::ModelPack));
1837            assert_eq!(out.encoding, Some(BoxEncoding::Anchor));
1838            assert_eq!(out.anchors.as_ref().map(|a| a.len()), Some(3));
1839        }
1840    }
1841
1842    #[test]
1843    fn parse_v2_json_direct_when_schema_version_present() {
1844        let j = r#"{
1845          "schema_version": 2,
1846          "outputs": [{
1847            "name": "boxes", "type": "boxes",
1848            "shape": [1, 4, 8400],
1849            "dshape": [{"batch": 1}, {"box_coords": 4}, {"num_boxes": 8400}],
1850            "dtype": "float32",
1851            "decoder": "ultralytics",
1852            "encoding": "direct",
1853            "normalized": true
1854          }]
1855        }"#;
1856        let schema = SchemaV2::parse_json(j).unwrap();
1857        assert_eq!(schema.schema_version, 2);
1858        assert_eq!(schema.outputs[0].type_, Some(LogicalType::Boxes));
1859    }
1860
1861    #[test]
1862    fn parse_rejects_future_schema_version() {
1863        let j = r#"{"schema_version": 99, "outputs": []}"#;
1864        let err = SchemaV2::parse_json(j).unwrap_err();
1865        matches!(err, DecoderError::NotSupported(_));
1866    }
1867
1868    #[test]
1869    fn parse_absent_schema_version_treats_as_v1() {
1870        // No schema_version field — classic v1 yolov8 split
1871        let j = r#"{
1872          "outputs": [
1873            {
1874              "type": "boxes", "decoder": "ultralytics",
1875              "shape": [1, 4, 8400],
1876              "quantization": [0.00392, 0]
1877            },
1878            {
1879              "type": "scores", "decoder": "ultralytics",
1880              "shape": [1, 80, 8400],
1881              "quantization": [0.00392, 0]
1882            }
1883          ]
1884        }"#;
1885        let schema = SchemaV2::parse_json(j).expect("v1 legacy parse");
1886        assert_eq!(schema.schema_version, 2); // converted
1887        assert_eq!(schema.outputs.len(), 2);
1888        assert_eq!(schema.outputs[0].type_, Some(LogicalType::Boxes));
1889        assert_eq!(schema.outputs[1].type_, Some(LogicalType::Scores));
1890        // default score_format assumed on v1→v2
1891        assert_eq!(schema.outputs[1].score_format, Some(ScoreFormat::PerClass));
1892    }
1893
1894    #[test]
1895    fn from_v1_preserves_nms_and_decoder_version() {
1896        let v1 = ConfigOutputs {
1897            outputs: vec![ConfigOutput::Boxes(crate::configs::Boxes {
1898                decoder: crate::configs::DecoderType::Ultralytics,
1899                quantization: Some(crate::configs::QuantTuple(0.01, 5)),
1900                shape: vec![1, 4, 8400],
1901                dshape: vec![],
1902                normalized: Some(true),
1903            })],
1904            nms: Some(crate::configs::Nms::ClassAware),
1905            decoder_version: Some(crate::configs::DecoderVersion::Yolo11),
1906        };
1907        let v2 = SchemaV2::from_v1(&v1).unwrap();
1908        assert_eq!(v2.nms, Some(NmsMode::ClassAware));
1909        assert_eq!(v2.decoder_version, Some(DecoderVersion::Yolo11));
1910        assert_eq!(v2.outputs[0].normalized, Some(true));
1911        let q = v2.outputs[0].quantization.as_ref().unwrap();
1912        assert_eq!(q.scale, vec![0.01]);
1913        assert_eq!(q.zero_point, Some(vec![5]));
1914        assert_eq!(q.dtype, None); // v1 did not carry dtype
1915    }
1916
1917    /// Outputs declared without a `type` field must parse successfully
1918    /// and round-trip to JSON without introducing a synthetic type.
1919    /// The metadata v2 spec lists `type` as required on both logical
1920    /// and physical levels, but the HAL tolerates typeless logical
1921    /// outputs as "additional" (auxiliary / diagnostic) tensors that
1922    /// do not participate in decoder dispatch.
1923    #[test]
1924    fn typeless_logical_output_parses_and_roundtrips() {
1925        let j = r#"{
1926            "schema_version": 2,
1927            "outputs": [
1928                {
1929                    "name": "extra_telemetry",
1930                    "shape": [1, 16]
1931                },
1932                {
1933                    "name": "boxes",
1934                    "type": "boxes",
1935                    "shape": [1, 4, 8400]
1936                }
1937            ]
1938        }"#;
1939        let schema: SchemaV2 = serde_json::from_str(j).unwrap();
1940        assert_eq!(schema.outputs.len(), 2);
1941        assert_eq!(schema.outputs[0].type_, None);
1942        assert_eq!(schema.outputs[0].name.as_deref(), Some("extra_telemetry"));
1943        assert_eq!(schema.outputs[1].type_, Some(LogicalType::Boxes));
1944
1945        // Typeless output must not serialize a `type` field.
1946        let round = serde_json::to_string(&schema).unwrap();
1947        let first_obj = round
1948            .split("\"outputs\":[")
1949            .nth(1)
1950            .and_then(|s| s.split("}").next())
1951            .expect("outputs array");
1952        assert!(
1953            !first_obj.contains("\"type\""),
1954            "typeless output must not serialize a `type` field, got: {first_obj}"
1955        );
1956    }
1957
1958    /// Typeless logical outputs are filtered out of the legacy
1959    /// ConfigOutputs — they carry no decoder role and the legacy
1960    /// builder can't represent them. A schema with typeless extras
1961    /// plus a recognised `boxes` role must lower to a legacy config
1962    /// containing only the `boxes` entry.
1963    #[test]
1964    fn typeless_outputs_filtered_from_legacy_config() {
1965        let schema = SchemaV2 {
1966            schema_version: 2,
1967            input: None,
1968            outputs: vec![
1969                LogicalOutput {
1970                    name: Some("diagnostic_histogram".into()),
1971                    type_: None,
1972                    shape: vec![1, 256],
1973                    dshape: vec![],
1974                    decoder: None,
1975                    encoding: None,
1976                    score_format: None,
1977                    normalized: None,
1978                    anchors: None,
1979                    stride: None,
1980                    dtype: None,
1981                    quantization: None,
1982                    outputs: vec![],
1983                    activation_applied: None,
1984                    activation_required: None,
1985                },
1986                LogicalOutput {
1987                    name: Some("boxes".into()),
1988                    type_: Some(LogicalType::Boxes),
1989                    shape: vec![1, 4, 8400],
1990                    dshape: vec![],
1991                    decoder: Some(DecoderKind::Ultralytics),
1992                    encoding: Some(BoxEncoding::Direct),
1993                    score_format: None,
1994                    normalized: Some(true),
1995                    anchors: None,
1996                    stride: None,
1997                    dtype: None,
1998                    quantization: None,
1999                    outputs: vec![],
2000                    activation_applied: None,
2001                    activation_required: None,
2002                },
2003            ],
2004            nms: None,
2005            decoder_version: None,
2006        };
2007        let legacy = schema.to_legacy_config_outputs().unwrap();
2008        assert_eq!(
2009            legacy.outputs.len(),
2010            1,
2011            "typeless output must be filtered from legacy config"
2012        );
2013        assert!(
2014            matches!(legacy.outputs[0], ConfigOutput::Boxes(_)),
2015            "only the typed `boxes` output should survive lowering"
2016        );
2017    }
2018
2019    /// A schema that contains no typed outputs (all typeless) lowers
2020    /// to an empty legacy config. The decoder builder then surfaces
2021    /// its own "No outputs found in config" error — meaningful,
2022    /// decoder-centric, not a serde-level "missing field type".
2023    #[test]
2024    fn all_typeless_schema_produces_empty_legacy_config() {
2025        let schema = SchemaV2 {
2026            schema_version: 2,
2027            input: None,
2028            outputs: vec![LogicalOutput {
2029                name: Some("aux".into()),
2030                type_: None,
2031                shape: vec![1, 8],
2032                dshape: vec![],
2033                decoder: None,
2034                encoding: None,
2035                score_format: None,
2036                normalized: None,
2037                anchors: None,
2038                stride: None,
2039                dtype: None,
2040                quantization: None,
2041                outputs: vec![],
2042                activation_applied: None,
2043                activation_required: None,
2044            }],
2045            nms: None,
2046            decoder_version: None,
2047        };
2048        let legacy = schema.to_legacy_config_outputs().unwrap();
2049        assert!(legacy.outputs.is_empty());
2050    }
2051
2052    /// Physical children may also omit `type`. The schema parses, the
2053    /// output round-trips without a synthetic `type` field, and the
2054    /// uniqueness check doesn't flag a typeless child sharing shape
2055    /// with a typed sibling (the type disambiguator is absent, but we
2056    /// don't bind typeless children by type anyway).
2057    #[test]
2058    fn typeless_physical_child_parses_and_skips_uniqueness() {
2059        let j = r#"{
2060            "name": "boxes",
2061            "type": "boxes",
2062            "shape": [1, 8400, 4],
2063            "outputs": [
2064                {
2065                    "name": "boxes_xy",
2066                    "type": "boxes_xy",
2067                    "shape": [1, 8400, 2],
2068                    "dtype": "float32"
2069                },
2070                {
2071                    "name": "aux_user_managed",
2072                    "shape": [1, 8400, 2],
2073                    "dtype": "float32"
2074                }
2075            ]
2076        }"#;
2077        let lo: LogicalOutput = serde_json::from_str(j).unwrap();
2078        assert_eq!(lo.outputs.len(), 2);
2079        assert_eq!(lo.outputs[0].type_, Some(PhysicalType::BoxesXy));
2080        assert_eq!(lo.outputs[1].type_, None);
2081
2082        // Wrap in a minimal schema so we can call validate().
2083        // BoxesXy and the typeless child share shape `[1, 8400, 2]`;
2084        // the uniqueness check must not treat this as a conflict.
2085        let schema = SchemaV2 {
2086            schema_version: 2,
2087            input: None,
2088            outputs: vec![lo],
2089            nms: None,
2090            decoder_version: None,
2091        };
2092        schema.validate().expect(
2093            "typed + typeless children with equal shape must not trigger \
2094             uniqueness error",
2095        );
2096
2097        // Serialization skips `type` on the typeless child.
2098        let s = serde_json::to_string(&schema).unwrap();
2099        assert!(
2100            s.contains("\"aux_user_managed\""),
2101            "typeless child must survive round-trip: {s}"
2102        );
2103        // Locate the typeless child's JSON object and confirm no `type` key.
2104        let aux_obj = s
2105            .split("\"aux_user_managed\"")
2106            .nth(1)
2107            .and_then(|s| s.split('}').next())
2108            .unwrap_or("");
2109        assert!(
2110            !aux_obj.contains("\"type\""),
2111            "typeless child must not serialize `type`, got: {aux_obj}"
2112        );
2113    }
2114
2115    #[test]
2116    fn from_v1_modelpack_anchor_detection_maps_encoding() {
2117        let v1 = ConfigOutputs {
2118            outputs: vec![ConfigOutput::Detection(crate::configs::Detection {
2119                anchors: Some(vec![[0.1, 0.2], [0.3, 0.4]]),
2120                decoder: crate::configs::DecoderType::ModelPack,
2121                quantization: Some(crate::configs::QuantTuple(0.176, 198)),
2122                shape: vec![1, 40, 40, 54],
2123                dshape: vec![],
2124                normalized: None,
2125            })],
2126            nms: None,
2127            decoder_version: None,
2128        };
2129        let v2 = SchemaV2::from_v1(&v1).unwrap();
2130        assert_eq!(v2.outputs[0].encoding, Some(BoxEncoding::Anchor));
2131        assert_eq!(v2.outputs[0].decoder, Some(DecoderKind::ModelPack));
2132        assert_eq!(v2.outputs[0].anchors.as_ref().map(|a| a.len()), Some(2));
2133    }
2134
2135    // ─── validate() tests ──────────────────────────────────
2136
2137    #[test]
2138    fn validate_accepts_flat_v2_yolov8_detection() {
2139        let j = r#"{
2140          "schema_version": 2,
2141          "outputs": [
2142            {"name":"boxes","type":"boxes","shape":[1,64,8400],
2143             "dtype":"int8","decoder":"ultralytics","encoding":"dfl"},
2144            {"name":"scores","type":"scores","shape":[1,80,8400],
2145             "dtype":"int8","decoder":"ultralytics","score_format":"per_class"}
2146          ]
2147        }"#;
2148        SchemaV2::parse_json(j).unwrap().validate().unwrap();
2149    }
2150
2151    #[test]
2152    fn validate_rejects_unnamed_physical_child() {
2153        let j = r#"{
2154          "schema_version": 2,
2155          "outputs": [{
2156            "name":"boxes","type":"boxes","shape":[1,64,8400],
2157            "encoding":"dfl","decoder":"ultralytics",
2158            "outputs": [{
2159              "name":"","type":"boxes","stride":8,
2160              "shape":[1,80,80,64],"dtype":"uint8"
2161            }]
2162          }]
2163        }"#;
2164        let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
2165        let msg = format!("{err}");
2166        assert!(msg.contains("missing `name`"), "got: {msg}");
2167    }
2168
2169    #[test]
2170    fn validate_rejects_duplicate_physical_shapes() {
2171        let j = r#"{
2172          "schema_version": 2,
2173          "outputs": [{
2174            "name":"boxes","type":"boxes","shape":[1,64,8400],
2175            "encoding":"dfl","decoder":"ultralytics",
2176            "outputs": [
2177              {"name":"a","type":"boxes","stride":8,"shape":[1,80,80,64],"dtype":"uint8"},
2178              {"name":"b","type":"boxes","stride":16,"shape":[1,80,80,64],"dtype":"uint8"}
2179            ]
2180          }]
2181        }"#;
2182        let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
2183        let msg = format!("{err}");
2184        assert!(msg.contains("share shape"), "got: {msg}");
2185    }
2186
2187    #[test]
2188    fn validate_rejects_mixed_decomposition() {
2189        // one child carries stride, the other does not — ill-defined merge
2190        let j = r#"{
2191          "schema_version": 2,
2192          "outputs": [{
2193            "name":"boxes","type":"boxes","shape":[1,4,8400,1],
2194            "encoding":"direct","decoder":"ultralytics",
2195            "outputs": [
2196              {"name":"xy","type":"boxes_xy","shape":[1,2,8400,1],"dtype":"int16"},
2197              {"name":"p0","type":"boxes","stride":8,"shape":[1,80,80,64],"dtype":"uint8"}
2198            ]
2199          }]
2200        }"#;
2201        let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
2202        let msg = format!("{err}");
2203        assert!(msg.contains("uniform"), "got: {msg}");
2204    }
2205
2206    #[test]
2207    fn validate_rejects_dfl_boxes_feature_not_divisible_by_4() {
2208        let j = r#"{
2209          "schema_version": 2,
2210          "outputs": [{
2211            "name":"boxes","type":"boxes","shape":[1,63,8400],
2212            "encoding":"dfl","decoder":"ultralytics",
2213            "outputs": [{
2214              "name":"b0","type":"boxes","stride":8,
2215              "shape":[1,80,80,63],
2216              "dshape":[{"batch":1},{"height":80},{"width":80},{"num_features":63}],
2217              "dtype":"uint8"
2218            }]
2219          }]
2220        }"#;
2221        let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
2222        let msg = format!("{err}");
2223        assert!(msg.contains("not"), "got: {msg}");
2224        assert!(msg.contains("divisible by 4"), "got: {msg}");
2225    }
2226
2227    #[test]
2228    fn validate_accepts_hailo_per_scale_yolov8() {
2229        let j = r#"{
2230          "schema_version": 2,
2231          "outputs": [{
2232            "name":"boxes","type":"boxes","shape":[1,64,8400],
2233            "encoding":"dfl","decoder":"ultralytics","normalized":true,
2234            "outputs": [
2235              {"name":"b0","type":"boxes","stride":8,
2236               "shape":[1,80,80,64],
2237               "dshape":[{"batch":1},{"height":80},{"width":80},{"num_features":64}],
2238               "dtype":"uint8",
2239               "quantization":{"scale":0.0234,"zero_point":128,"dtype":"uint8"}},
2240              {"name":"b1","type":"boxes","stride":16,
2241               "shape":[1,40,40,64],
2242               "dshape":[{"batch":1},{"height":40},{"width":40},{"num_features":64}],
2243               "dtype":"uint8",
2244               "quantization":{"scale":0.0198,"zero_point":130,"dtype":"uint8"}},
2245              {"name":"b2","type":"boxes","stride":32,
2246               "shape":[1,20,20,64],
2247               "dshape":[{"batch":1},{"height":20},{"width":20},{"num_features":64}],
2248               "dtype":"uint8",
2249               "quantization":{"scale":0.0312,"zero_point":125,"dtype":"uint8"}}
2250            ]
2251          }]
2252        }"#;
2253        let s = SchemaV2::parse_json(j).unwrap();
2254        s.validate().unwrap();
2255    }
2256
2257    #[test]
2258    fn validate_accepts_ara2_xy_wh() {
2259        let j = r#"{
2260          "schema_version": 2,
2261          "outputs": [{
2262            "name":"boxes","type":"boxes","shape":[1,4,8400,1],
2263            "encoding":"direct","decoder":"ultralytics","normalized":true,
2264            "outputs": [
2265              {"name":"xy","type":"boxes_xy","shape":[1,2,8400,1],
2266               "dshape":[{"batch":1},{"box_coords":2},{"num_boxes":8400},{"padding":1}],
2267               "dtype":"int16",
2268               "quantization":{"scale":3.1e-5,"zero_point":0,"dtype":"int16"}},
2269              {"name":"wh","type":"boxes_wh","shape":[1,2,8400,1],
2270               "dshape":[{"batch":1},{"box_coords":2},{"num_boxes":8400},{"padding":1}],
2271               "dtype":"int16",
2272               "quantization":{"scale":3.2e-5,"zero_point":0,"dtype":"int16"}}
2273            ]
2274          }]
2275        }"#;
2276        SchemaV2::parse_json(j).unwrap().validate().unwrap();
2277    }
2278
2279    #[test]
2280    fn parse_file_auto_detects_json() {
2281        let tmp = std::env::temp_dir().join(format!("schema_v2_test_{}.json", std::process::id()));
2282        std::fs::write(&tmp, r#"{"schema_version":2,"outputs":[]}"#).unwrap();
2283        let s = SchemaV2::parse_file(&tmp).unwrap();
2284        assert_eq!(s.schema_version, 2);
2285        let _ = std::fs::remove_file(&tmp);
2286    }
2287
2288    #[test]
2289    fn parse_file_auto_detects_yaml() {
2290        let tmp = std::env::temp_dir().join(format!("schema_v2_test_{}.yaml", std::process::id()));
2291        std::fs::write(&tmp, "schema_version: 2\noutputs: []\n").unwrap();
2292        let s = SchemaV2::parse_file(&tmp).unwrap();
2293        assert_eq!(s.schema_version, 2);
2294        let _ = std::fs::remove_file(&tmp);
2295    }
2296
2297    // ─── Real ARA-2 DVM fixtures ────────────────────────────
2298
2299    #[test]
2300    fn parse_real_ara2_int8_dvm_metadata() {
2301        let json = include_str!(concat!(
2302            env!("CARGO_MANIFEST_DIR"),
2303            "/../../testdata/ara2_int8_edgefirst.json"
2304        ));
2305        let schema = SchemaV2::parse_json(json).expect("ARA-2 int8 parse");
2306        assert_eq!(schema.schema_version, 2);
2307        assert_eq!(schema.decoder_version, Some(DecoderVersion::Yolov8));
2308        assert_eq!(schema.nms, Some(NmsMode::ClassAgnostic));
2309        assert_eq!(schema.input.as_ref().unwrap().shape, vec![1, 3, 640, 640]);
2310
2311        // Four logical outputs: boxes (split xy/wh), scores, mask_coefs, protos.
2312        assert_eq!(schema.outputs.len(), 4);
2313        let boxes = &schema.outputs[0];
2314        assert_eq!(boxes.type_, Some(LogicalType::Boxes));
2315        assert_eq!(boxes.encoding, Some(BoxEncoding::Direct));
2316        assert_eq!(boxes.normalized, Some(true));
2317        assert_eq!(boxes.shape, vec![1, 4, 8400, 1]); // 4D with padding
2318        assert_eq!(boxes.outputs.len(), 2);
2319        assert_eq!(boxes.outputs[0].type_, Some(PhysicalType::BoxesXy));
2320        assert_eq!(boxes.outputs[1].type_, Some(PhysicalType::BoxesWh));
2321        // xy quant: scale 0.004177791997790337, zp -122, int8
2322        let q_xy = boxes.outputs[0].quantization.as_ref().unwrap();
2323        assert_eq!(q_xy.dtype, Some(DType::Int8));
2324        assert!((q_xy.scale[0] - 0.004_177_792).abs() < 1e-6);
2325        assert_eq!(q_xy.zero_point_at(0), -122);
2326
2327        let scores = &schema.outputs[1];
2328        assert_eq!(scores.type_, Some(LogicalType::Scores));
2329        assert_eq!(scores.score_format, Some(ScoreFormat::PerClass));
2330        assert_eq!(scores.shape, vec![1, 80, 8400, 1]);
2331
2332        let mask_coefs = &schema.outputs[2];
2333        assert_eq!(mask_coefs.type_, Some(LogicalType::MaskCoefs));
2334        assert_eq!(mask_coefs.shape, vec![1, 32, 8400, 1]);
2335
2336        let protos = &schema.outputs[3];
2337        assert_eq!(protos.type_, Some(LogicalType::Protos));
2338        assert_eq!(protos.shape, vec![1, 32, 160, 160]);
2339
2340        // Schema-level validation passes.
2341        schema.validate().expect("ARA-2 int8 validate");
2342    }
2343
2344    #[test]
2345    fn parse_real_ara2_int16_dvm_metadata() {
2346        let json = include_str!(concat!(
2347            env!("CARGO_MANIFEST_DIR"),
2348            "/../../testdata/ara2_int16_edgefirst.json"
2349        ));
2350        let schema = SchemaV2::parse_json(json).expect("ARA-2 int16 parse");
2351        assert_eq!(schema.schema_version, 2);
2352        assert_eq!(schema.outputs.len(), 4);
2353        let boxes = &schema.outputs[0];
2354        assert_eq!(boxes.outputs.len(), 2);
2355        let q_xy = boxes.outputs[0].quantization.as_ref().unwrap();
2356        assert_eq!(q_xy.dtype, Some(DType::Int16));
2357        assert!((q_xy.scale[0] - 3.211_570_6e-5).abs() < 1e-10);
2358        assert_eq!(q_xy.zero_point_at(0), 0);
2359        // Mask coefs and protos too are INT16 in this build.
2360        let mc_q = schema.outputs[2].quantization.as_ref().unwrap();
2361        assert_eq!(mc_q.dtype, Some(DType::Int16));
2362        schema.validate().expect("ARA-2 int16 validate");
2363    }
2364
2365    #[test]
2366    fn parse_yaml_with_explicit_schema_version_2() {
2367        let yaml = r#"
2368schema_version: 2
2369outputs:
2370  - name: scores
2371    type: scores
2372    shape: [1, 80, 8400]
2373    dtype: int8
2374    quantization:
2375      scale: 0.00392
2376      dtype: int8
2377    decoder: ultralytics
2378    score_format: per_class
2379"#;
2380        let schema = SchemaV2::parse_yaml(yaml).unwrap();
2381        assert_eq!(schema.schema_version, 2);
2382        assert_eq!(schema.outputs[0].score_format, Some(ScoreFormat::PerClass));
2383    }
2384
2385    // ─── squeeze_padding_dims / to_legacy_config_outputs regressions ────
2386
2387    #[test]
2388    fn squeeze_padding_dims_preserves_shape_when_dshape_absent() {
2389        // Empty dshape must pass shape through untouched. The previous
2390        // `zip` implementation silently truncated to `[]`, which made
2391        // every v2 logical output without named dims arrive at the legacy
2392        // verifier with `shape: []` and fail rank checks.
2393        let (shape, dshape) = squeeze_padding_dims(vec![1, 4, 8400], vec![]);
2394        assert_eq!(shape, vec![1, 4, 8400]);
2395        assert!(dshape.is_empty());
2396    }
2397
2398    #[test]
2399    fn to_legacy_preserves_shape_for_v2_split_boxes_without_dshape() {
2400        // Regression: `Decoder({...v2 split boxes, shape:[1,4,8400], no dshape...})`
2401        // used to fail with `Invalid Yolo Split Boxes shape []` because
2402        // `squeeze_padding_dims` truncated shape when dshape was empty.
2403        let j = r#"{
2404          "schema_version": 2,
2405          "outputs": [
2406            {"name":"boxes","type":"boxes","shape":[1,4,8400],
2407             "dtype":"float32","decoder":"ultralytics","encoding":"direct"},
2408            {"name":"scores","type":"scores","shape":[1,80,8400],
2409             "dtype":"float32","decoder":"ultralytics","score_format":"per_class"}
2410          ]
2411        }"#;
2412        let schema = SchemaV2::parse_json(j).unwrap();
2413        let legacy = schema.to_legacy_config_outputs().expect("lowers cleanly");
2414        let boxes = match &legacy.outputs[0] {
2415            crate::ConfigOutput::Boxes(b) => b,
2416            other => panic!("expected Boxes, got {other:?}"),
2417        };
2418        assert_eq!(boxes.shape, vec![1, 4, 8400]);
2419        let scores = match &legacy.outputs[1] {
2420            crate::ConfigOutput::Scores(s) => s,
2421            other => panic!("expected Scores, got {other:?}"),
2422        };
2423        assert_eq!(scores.shape, vec![1, 80, 8400]);
2424    }
2425}