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` that
767    /// [`DecoderBuilder::build`](crate::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 `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). ModelPack boxes are validated as 4D, so keep
1259    // padding dims for ModelPack outputs.
1260    let (shape, dshape) = match logical.decoder {
1261        Some(DecoderKind::ModelPack) => (logical.shape.clone(), logical.dshape.clone()),
1262        _ => squeeze_padding_dims(logical.shape.clone(), logical.dshape.clone()),
1263    };
1264
1265    let ty = logical.type_.ok_or_else(|| {
1266        // Defense-in-depth: `to_legacy_config_outputs` already filters
1267        // typeless outputs, so reaching here means a new caller added a
1268        // bypass. Surface it as an internal error rather than panicking.
1269        DecoderError::InvalidConfig(format!(
1270            "logical output `{}` has no type; typeless outputs should be \
1271             filtered before legacy conversion",
1272            logical.name.as_deref().unwrap_or("<anonymous>")
1273        ))
1274    })?;
1275
1276    let ctx = LegacyConvertCtx {
1277        decoder,
1278        quantization,
1279        shape,
1280        dshape,
1281        normalized: logical.normalized,
1282        anchors: logical.anchors.clone(),
1283    };
1284
1285    match ty {
1286        LogicalType::Boxes => Ok(boxes_to_legacy(&ctx)),
1287        LogicalType::Scores => Ok(scores_to_legacy(&ctx)),
1288        LogicalType::Protos => Ok(protos_to_legacy(&ctx)),
1289        LogicalType::MaskCoefs => Ok(mask_coefs_to_legacy(&ctx)),
1290        LogicalType::Segmentation => Ok(segmentation_to_legacy(&ctx)),
1291        LogicalType::Masks => Ok(masks_to_legacy(&ctx)),
1292        LogicalType::Classes => Ok(classes_to_legacy(&ctx)),
1293        LogicalType::Detection | LogicalType::Detections => Ok(detection_to_legacy(&ctx)),
1294        // Objectness / Landmarks have no direct v1 equivalent; the v1
1295        // YOLOv5 path embedded objectness in the combined Detection.
1296        LogicalType::Objectness | LogicalType::Landmarks => {
1297            Err(DecoderError::NotSupported(format!(
1298                "logical type {:?} has no legacy v1 equivalent; use the \
1299                 native v2 decoder path",
1300                ty
1301            )))
1302        }
1303    }
1304}
1305
1306/// Shared fields for per-`LogicalType` legacy conversion helpers.
1307struct LegacyConvertCtx {
1308    decoder: configs::DecoderType,
1309    quantization: Option<QuantTuple>,
1310    shape: Vec<usize>,
1311    dshape: Vec<(DimName, usize)>,
1312    normalized: Option<bool>,
1313    anchors: Option<Vec<[f32; 2]>>,
1314}
1315
1316fn boxes_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1317    ConfigOutput::Boxes(configs::Boxes {
1318        decoder: ctx.decoder,
1319        quantization: ctx.quantization,
1320        shape: ctx.shape.clone(),
1321        dshape: ctx.dshape.clone(),
1322        normalized: ctx.normalized,
1323    })
1324}
1325
1326fn scores_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1327    ConfigOutput::Scores(configs::Scores {
1328        decoder: ctx.decoder,
1329        quantization: ctx.quantization,
1330        shape: ctx.shape.clone(),
1331        dshape: ctx.dshape.clone(),
1332    })
1333}
1334
1335fn protos_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1336    ConfigOutput::Protos(configs::Protos {
1337        decoder: ctx.decoder,
1338        quantization: ctx.quantization,
1339        shape: ctx.shape.clone(),
1340        dshape: ctx.dshape.clone(),
1341    })
1342}
1343
1344fn mask_coefs_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1345    ConfigOutput::MaskCoefficients(configs::MaskCoefficients {
1346        decoder: ctx.decoder,
1347        quantization: ctx.quantization,
1348        shape: ctx.shape.clone(),
1349        dshape: ctx.dshape.clone(),
1350    })
1351}
1352
1353fn segmentation_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1354    ConfigOutput::Segmentation(configs::Segmentation {
1355        decoder: ctx.decoder,
1356        quantization: ctx.quantization,
1357        shape: ctx.shape.clone(),
1358        dshape: ctx.dshape.clone(),
1359    })
1360}
1361
1362fn masks_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1363    ConfigOutput::Mask(configs::Mask {
1364        decoder: ctx.decoder,
1365        quantization: ctx.quantization,
1366        shape: ctx.shape.clone(),
1367        dshape: ctx.dshape.clone(),
1368    })
1369}
1370
1371fn classes_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1372    ConfigOutput::Classes(configs::Classes {
1373        decoder: ctx.decoder,
1374        quantization: ctx.quantization,
1375        shape: ctx.shape.clone(),
1376        dshape: ctx.dshape.clone(),
1377    })
1378}
1379
1380fn detection_to_legacy(ctx: &LegacyConvertCtx) -> ConfigOutput {
1381    // Detection covers ModelPack anchor-grid and legacy YOLO combined.
1382    // Detections (plural) is end-to-end; maps to Detection with the
1383    // appropriate dimension layout.
1384    ConfigOutput::Detection(configs::Detection {
1385        anchors: ctx.anchors.clone(),
1386        decoder: ctx.decoder,
1387        quantization: ctx.quantization,
1388        shape: ctx.shape.clone(),
1389        dshape: ctx.dshape.clone(),
1390        normalized: ctx.normalized,
1391    })
1392}
1393
1394#[cfg(test)]
1395#[cfg_attr(coverage_nightly, coverage(off))]
1396mod tests {
1397    use super::*;
1398
1399    #[test]
1400    fn schema_default_is_v2() {
1401        let s = SchemaV2::default();
1402        assert_eq!(s.schema_version, 2);
1403        assert!(s.outputs.is_empty());
1404    }
1405
1406    #[test]
1407    fn fixtures_round_trip_through_serde() {
1408        let yolov8 =
1409            edgefirst_bench::testdata::read_to_string("per_scale/synthetic_yolov8n_schema.json");
1410        let _: super::SchemaV2 = serde_json::from_str(&yolov8).expect("yolov8n fixture must parse");
1411
1412        let yolo26 =
1413            edgefirst_bench::testdata::read_to_string("per_scale/synthetic_yolo26n_schema.json");
1414        let _: super::SchemaV2 = serde_json::from_str(&yolo26).expect("yolo26n fixture must parse");
1415
1416        let flat =
1417            edgefirst_bench::testdata::read_to_string("per_scale/synthetic_flat_schema.json");
1418        let _: super::SchemaV2 = serde_json::from_str(&flat).expect("flat fixture must parse");
1419    }
1420
1421    #[test]
1422    fn box_encoding_accepts_ltrb_alias_for_direct() {
1423        let dfl: BoxEncoding = serde_json::from_str("\"dfl\"").unwrap();
1424        assert_eq!(dfl, BoxEncoding::Dfl);
1425
1426        let direct: BoxEncoding = serde_json::from_str("\"direct\"").unwrap();
1427        assert_eq!(direct, BoxEncoding::Direct);
1428
1429        // yolo26 metadata uses "ltrb" — must deserialise to Direct.
1430        let ltrb: BoxEncoding = serde_json::from_str("\"ltrb\"").unwrap();
1431        assert_eq!(ltrb, BoxEncoding::Direct);
1432    }
1433
1434    #[test]
1435    fn dtype_roundtrip() {
1436        for d in [
1437            DType::Int8,
1438            DType::Uint8,
1439            DType::Int16,
1440            DType::Uint16,
1441            DType::Float16,
1442            DType::Float32,
1443        ] {
1444            let j = serde_json::to_string(&d).unwrap();
1445            let back: DType = serde_json::from_str(&j).unwrap();
1446            assert_eq!(back, d);
1447        }
1448    }
1449
1450    #[test]
1451    fn dtype_widths() {
1452        assert_eq!(DType::Int8.size_bytes(), 1);
1453        assert_eq!(DType::Float16.size_bytes(), 2);
1454        assert_eq!(DType::Float32.size_bytes(), 4);
1455    }
1456
1457    #[test]
1458    fn stride_accepts_scalar_or_pair() {
1459        let a: Stride = serde_json::from_str("8").unwrap();
1460        let b: Stride = serde_json::from_str("[8, 16]").unwrap();
1461        assert_eq!(a, Stride::Square(8));
1462        assert_eq!(b, Stride::Rect([8, 16]));
1463        assert_eq!(a.x(), 8);
1464        assert_eq!(a.y(), 8);
1465        assert_eq!(b.x(), 8);
1466        assert_eq!(b.y(), 16);
1467    }
1468
1469    #[test]
1470    fn quantization_scalar_scale() {
1471        let j = r#"{"scale": 0.00392, "zero_point": 0, "dtype": "int8"}"#;
1472        let q: Quantization = serde_json::from_str(j).unwrap();
1473        assert!(q.is_per_tensor());
1474        assert!(q.is_symmetric());
1475        assert_eq!(q.scale_at(0), 0.00392);
1476        assert_eq!(q.scale_at(5), 0.00392);
1477        assert_eq!(q.zero_point_at(0), 0);
1478    }
1479
1480    #[test]
1481    fn quantization_per_channel() {
1482        let j = r#"{"scale": [0.054, 0.089, 0.195], "axis": 0, "dtype": "int8"}"#;
1483        let q: Quantization = serde_json::from_str(j).unwrap();
1484        assert!(q.is_per_channel());
1485        assert!(q.is_symmetric());
1486        assert_eq!(q.axis, Some(0));
1487        assert_eq!(q.scale_at(0), 0.054);
1488        assert_eq!(q.scale_at(2), 0.195);
1489    }
1490
1491    #[test]
1492    fn quantization_asymmetric_per_tensor() {
1493        let j = r#"{"scale": 0.176, "zero_point": 198, "dtype": "uint8"}"#;
1494        let q: Quantization = serde_json::from_str(j).unwrap();
1495        assert!(!q.is_symmetric());
1496        assert_eq!(q.zero_point_at(0), 198);
1497        assert_eq!(q.zero_point_at(10), 198);
1498    }
1499
1500    #[test]
1501    fn quantization_symmetric_default_zero_point() {
1502        let j = r#"{"scale": 0.00392, "dtype": "int8"}"#;
1503        let q: Quantization = serde_json::from_str(j).unwrap();
1504        assert!(q.is_symmetric());
1505        assert_eq!(q.zero_point_at(0), 0);
1506    }
1507
1508    #[test]
1509    fn quantization_to_tensor_per_tensor_asymmetric() {
1510        let q = Quantization {
1511            scale: vec![0.1],
1512            zero_point: Some(vec![-5]),
1513            axis: None,
1514            dtype: Some(DType::Int8),
1515        };
1516        let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
1517        assert!(t.is_per_tensor());
1518        assert!(!t.is_symmetric());
1519        assert_eq!(t.scale(), &[0.1]);
1520        assert_eq!(t.zero_point(), Some(&[-5][..]));
1521    }
1522
1523    #[test]
1524    fn quantization_to_tensor_per_tensor_symmetric() {
1525        let q = Quantization {
1526            scale: vec![0.05],
1527            zero_point: None,
1528            axis: None,
1529            dtype: Some(DType::Int8),
1530        };
1531        let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
1532        assert!(t.is_per_tensor());
1533        assert!(t.is_symmetric());
1534    }
1535
1536    #[test]
1537    fn quantization_to_tensor_per_channel_asymmetric() {
1538        let q = Quantization {
1539            scale: vec![0.1, 0.2, 0.3],
1540            zero_point: Some(vec![-1, 0, 1]),
1541            axis: Some(2),
1542            dtype: Some(DType::Int8),
1543        };
1544        let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
1545        assert!(t.is_per_channel());
1546        assert_eq!(t.axis(), Some(2));
1547        assert_eq!(t.scale().len(), 3);
1548        assert_eq!(t.zero_point().map(|z| z.len()), Some(3));
1549    }
1550
1551    #[test]
1552    fn quantization_to_tensor_per_channel_symmetric() {
1553        let q = Quantization {
1554            scale: vec![0.054, 0.089, 0.195],
1555            zero_point: None,
1556            axis: Some(0),
1557            dtype: Some(DType::Int8),
1558        };
1559        let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
1560        assert!(t.is_per_channel());
1561        assert!(t.is_symmetric());
1562        assert_eq!(t.axis(), Some(0));
1563    }
1564
1565    #[test]
1566    fn quantization_to_tensor_per_channel_missing_axis_errors() {
1567        let q = Quantization {
1568            scale: vec![0.1, 0.2, 0.3],
1569            zero_point: None,
1570            axis: None,
1571            dtype: None,
1572        };
1573        let err = edgefirst_tensor::Quantization::try_from(&q).unwrap_err();
1574        // The field matters: the per-channel-without-axis arm is what should
1575        // reject this. Matching only on the variant also accepts the catch-all
1576        // "scale" arm, which would mean the arm above never ran.
1577        assert!(matches!(
1578            err,
1579            edgefirst_tensor::Error::QuantizationInvalid { field: "axis", .. }
1580        ));
1581    }
1582
1583    /// Both redundant-axis arms map to per-tensor. Losing either one drops the
1584    /// conversion through to the catch-all and turns a valid configuration
1585    /// into an error.
1586    #[test]
1587    fn quantization_to_tensor_per_tensor_ignores_a_redundant_axis() {
1588        let q = Quantization {
1589            scale: vec![0.5],
1590            zero_point: Some(vec![3]),
1591            axis: Some(0),
1592            dtype: Some(DType::Int8),
1593        };
1594        let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
1595        assert!(t.is_per_tensor());
1596        assert_eq!(t.scale(), &[0.5][..]);
1597        assert_eq!(t.zero_point(), Some(&[3][..]));
1598    }
1599
1600    #[test]
1601    fn quantization_to_tensor_symmetric_ignores_a_redundant_axis() {
1602        let q = Quantization {
1603            scale: vec![0.5],
1604            zero_point: None,
1605            axis: Some(0),
1606            dtype: Some(DType::Int8),
1607        };
1608        let t: edgefirst_tensor::Quantization = (&q).try_into().unwrap();
1609        assert!(t.is_per_tensor());
1610        assert!(t.is_symmetric());
1611        assert_eq!(t.scale(), &[0.5][..]);
1612    }
1613
1614    /// An empty scale is neither per-tensor nor per-channel, so it must reach
1615    /// the catch-all and name `scale`. Each of these reaches it down a
1616    /// different path: no axis, an axis, and an axis with zero points.
1617    #[test]
1618    fn quantization_to_tensor_empty_scale_errors_on_scale() {
1619        for (zero_point, axis) in [
1620            (None, None),
1621            (None, Some(0)),
1622            (Some(vec![]), Some(0)),
1623            (Some(vec![]), None),
1624        ] {
1625            let q = Quantization {
1626                scale: vec![],
1627                zero_point,
1628                axis,
1629                dtype: Some(DType::Int8),
1630            };
1631            let err = edgefirst_tensor::Quantization::try_from(&q).unwrap_err();
1632            assert!(
1633                matches!(
1634                    err,
1635                    edgefirst_tensor::Error::QuantizationInvalid { field: "scale", .. }
1636                ),
1637                "axis={axis:?} gave {err:?}"
1638            );
1639        }
1640    }
1641
1642    /// One scale with two zero points is not per-tensor (the zero point is not
1643    /// a single element) and not per-channel (there is one scale), so it is
1644    /// the catch-all's job with or without an axis.
1645    #[test]
1646    fn quantization_to_tensor_single_scale_with_many_zero_points_errors_on_scale() {
1647        for axis in [None, Some(0)] {
1648            let q = Quantization {
1649                scale: vec![0.5],
1650                zero_point: Some(vec![1, 2]),
1651                axis,
1652                dtype: Some(DType::Int8),
1653            };
1654            let err = edgefirst_tensor::Quantization::try_from(&q).unwrap_err();
1655            assert!(
1656                matches!(
1657                    err,
1658                    edgefirst_tensor::Error::QuantizationInvalid { field: "scale", .. }
1659                ),
1660                "axis={axis:?} gave {err:?}"
1661            );
1662        }
1663    }
1664
1665    #[test]
1666    fn per_channel_quantization_is_not_per_tensor() {
1667        let q = Quantization {
1668            scale: vec![0.1, 0.2, 0.3],
1669            zero_point: None,
1670            axis: Some(0),
1671            dtype: Some(DType::Int8),
1672        };
1673        assert!(q.is_per_channel());
1674        assert!(!q.is_per_tensor());
1675
1676        let per_tensor = Quantization {
1677            scale: vec![0.1],
1678            zero_point: None,
1679            axis: None,
1680            dtype: Some(DType::Int8),
1681        };
1682        assert!(per_tensor.is_per_tensor());
1683        assert!(!per_tensor.is_per_channel());
1684    }
1685
1686    /// A single zero point applies to every channel; a per-channel list is
1687    /// indexed. Treating the list as if it had one element would return
1688    /// channel 0's zero point for every channel.
1689    #[test]
1690    fn zero_point_at_indexes_per_channel_zero_points() {
1691        let per_channel = Quantization {
1692            scale: vec![0.1, 0.2, 0.3],
1693            zero_point: Some(vec![10, 20, 30]),
1694            axis: Some(0),
1695            dtype: Some(DType::Int8),
1696        };
1697        assert_eq!(per_channel.zero_point_at(0), 10);
1698        assert_eq!(per_channel.zero_point_at(1), 20);
1699        assert_eq!(per_channel.zero_point_at(2), 30);
1700        // Out of range falls back to 0 rather than panicking.
1701        assert_eq!(per_channel.zero_point_at(3), 0);
1702
1703        let shared = Quantization {
1704            scale: vec![0.1, 0.2, 0.3],
1705            zero_point: Some(vec![7]),
1706            axis: Some(0),
1707            dtype: Some(DType::Int8),
1708        };
1709        assert_eq!(shared.zero_point_at(0), 7);
1710        assert_eq!(shared.zero_point_at(2), 7);
1711
1712        let symmetric = Quantization {
1713            scale: vec![0.1],
1714            zero_point: None,
1715            axis: None,
1716            dtype: Some(DType::Int8),
1717        };
1718        assert_eq!(symmetric.zero_point_at(0), 0);
1719    }
1720
1721    #[test]
1722    fn logical_output_flat_tflite_boxes() {
1723        // Example 3 from the spec: TFLite YOLOv8 detection, flat boxes
1724        let j = r#"{
1725          "name": "boxes", "type": "boxes",
1726          "shape": [1, 64, 8400],
1727          "dshape": [{"batch": 1}, {"num_features": 64}, {"num_boxes": 8400}],
1728          "dtype": "int8",
1729          "quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "int8"},
1730          "decoder": "ultralytics",
1731          "encoding": "dfl",
1732          "normalized": true
1733        }"#;
1734        let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1735        assert_eq!(lo.type_, Some(LogicalType::Boxes));
1736        assert_eq!(lo.encoding, Some(BoxEncoding::Dfl));
1737        assert_eq!(lo.normalized, Some(true));
1738        assert!(!lo.is_split());
1739        assert_eq!(lo.dtype, Some(DType::Int8));
1740    }
1741
1742    #[test]
1743    fn logical_output_hailo_per_scale_split() {
1744        // Example 5 from the spec: Hailo YOLOv8 boxes, per-scale split
1745        let j = r#"{
1746          "name": "boxes", "type": "boxes",
1747          "shape": [1, 64, 8400],
1748          "encoding": "dfl", "decoder": "ultralytics", "normalized": true,
1749          "outputs": [
1750            {
1751              "name": "boxes_0", "type": "boxes",
1752              "stride": 8, "scale_index": 0,
1753              "shape": [1, 80, 80, 64],
1754              "dshape": [{"batch": 1}, {"height": 80}, {"width": 80}, {"num_features": 64}],
1755              "dtype": "uint8",
1756              "quantization": {"scale": 0.0234, "zero_point": 128, "dtype": "uint8"}
1757            }
1758          ]
1759        }"#;
1760        let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1761        assert!(lo.is_split());
1762        assert_eq!(lo.outputs.len(), 1);
1763        let child = &lo.outputs[0];
1764        assert_eq!(child.name, "boxes_0");
1765        assert_eq!(child.type_, Some(PhysicalType::Boxes));
1766        assert_eq!(child.stride, Some(Stride::Square(8)));
1767        assert_eq!(child.scale_index, Some(0));
1768        assert_eq!(child.dtype, DType::Uint8);
1769    }
1770
1771    #[test]
1772    fn logical_output_ara2_xy_wh_channel_split() {
1773        // Example 4 from the spec: ARA-2 boxes split into xy/wh
1774        let j = r#"{
1775          "name": "boxes", "type": "boxes",
1776          "shape": [1, 4, 8400, 1],
1777          "encoding": "direct", "decoder": "ultralytics", "normalized": true,
1778          "outputs": [
1779            {
1780              "name": "_model_22_Div_1_output_0", "type": "boxes_xy",
1781              "shape": [1, 2, 8400, 1],
1782              "dshape": [{"batch": 1}, {"box_coords": 2}, {"num_boxes": 8400}, {"padding": 1}],
1783              "dtype": "int16",
1784              "quantization": {"scale": 3.129e-5, "zero_point": 0, "dtype": "int16"}
1785            },
1786            {
1787              "name": "_model_22_Sub_1_output_0", "type": "boxes_wh",
1788              "shape": [1, 2, 8400, 1],
1789              "dshape": [{"batch": 1}, {"box_coords": 2}, {"num_boxes": 8400}, {"padding": 1}],
1790              "dtype": "int16",
1791              "quantization": {"scale": 3.149e-5, "zero_point": 0, "dtype": "int16"}
1792            }
1793          ]
1794        }"#;
1795        let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1796        assert_eq!(lo.encoding, Some(BoxEncoding::Direct));
1797        assert_eq!(lo.outputs.len(), 2);
1798        assert_eq!(lo.outputs[0].type_, Some(PhysicalType::BoxesXy));
1799        assert_eq!(lo.outputs[1].type_, Some(PhysicalType::BoxesWh));
1800        assert!(lo.outputs[0].stride.is_none());
1801        assert!(lo.outputs[1].stride.is_none());
1802    }
1803
1804    #[test]
1805    fn logical_output_hailo_scores_sigmoid_applied() {
1806        let j = r#"{
1807          "name": "scores", "type": "scores",
1808          "shape": [1, 80, 8400],
1809          "decoder": "ultralytics", "score_format": "per_class",
1810          "outputs": [
1811            {
1812              "name": "scores_0", "type": "scores",
1813              "stride": 8, "scale_index": 0,
1814              "shape": [1, 80, 80, 80],
1815              "dshape": [{"batch": 1}, {"height": 80}, {"width": 80}, {"num_classes": 80}],
1816              "dtype": "uint8",
1817              "quantization": {"scale": 0.003922, "dtype": "uint8"},
1818              "activation_applied": "sigmoid"
1819            }
1820          ]
1821        }"#;
1822        let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1823        assert_eq!(lo.score_format, Some(ScoreFormat::PerClass));
1824        let child = &lo.outputs[0];
1825        assert_eq!(child.activation_applied, Some(Activation::Sigmoid));
1826        assert!(child.activation_required.is_none());
1827    }
1828
1829    #[test]
1830    fn yolo26_end_to_end_detections() {
1831        let j = r#"{
1832          "schema_version": 2,
1833          "decoder_version": "yolo26",
1834          "outputs": [{
1835            "name": "output0", "type": "detections",
1836            "shape": [1, 100, 6],
1837            "dshape": [{"batch": 1}, {"num_boxes": 100}, {"num_features": 6}],
1838            "dtype": "int8",
1839            "quantization": {"scale": 0.0078, "zero_point": 0, "dtype": "int8"},
1840            "normalized": false,
1841            "decoder": "ultralytics"
1842          }]
1843        }"#;
1844        let s: SchemaV2 = serde_json::from_str(j).unwrap();
1845        assert_eq!(s.decoder_version, Some(DecoderVersion::Yolo26));
1846        assert!(s.decoder_version.unwrap().is_end_to_end());
1847        assert_eq!(s.outputs[0].type_, Some(LogicalType::Detections));
1848        assert_eq!(s.outputs[0].normalized, Some(false));
1849        assert!(s.nms.is_none());
1850    }
1851
1852    #[test]
1853    fn modelpack_anchor_detection_with_rect_stride() {
1854        let j = r#"{
1855          "schema_version": 2,
1856          "outputs": [{
1857            "name": "output_0", "type": "detection",
1858            "shape": [1, 40, 40, 54],
1859            "dshape": [{"batch": 1}, {"height": 40}, {"width": 40}, {"num_anchors_x_features": 54}],
1860            "dtype": "uint8",
1861            "quantization": {"scale": 0.176, "zero_point": 198, "dtype": "uint8"},
1862            "decoder": "modelpack",
1863            "encoding": "anchor",
1864            "stride": [16, 16],
1865            "anchors": [[0.054, 0.065], [0.089, 0.139], [0.195, 0.196]]
1866          }]
1867        }"#;
1868        let s: SchemaV2 = serde_json::from_str(j).unwrap();
1869        let lo = &s.outputs[0];
1870        assert_eq!(lo.encoding, Some(BoxEncoding::Anchor));
1871        assert_eq!(lo.stride, Some(Stride::Rect([16, 16])));
1872        assert_eq!(lo.anchors.as_ref().map(|a| a.len()), Some(3));
1873    }
1874
1875    #[test]
1876    fn yolov5_obj_x_class_objectness_logical() {
1877        let j = r#"{
1878          "name": "objectness", "type": "objectness",
1879          "shape": [1, 3, 8400],
1880          "decoder": "ultralytics",
1881          "outputs": [{
1882            "name": "objectness_0", "type": "objectness",
1883            "stride": 8, "scale_index": 0,
1884            "shape": [1, 80, 80, 3],
1885            "dshape": [{"batch": 1}, {"height": 80}, {"width": 80}, {"num_features": 3}],
1886            "dtype": "uint8",
1887            "quantization": {"scale": 0.0039, "zero_point": 0, "dtype": "uint8"},
1888            "activation_applied": "sigmoid"
1889          }]
1890        }"#;
1891        let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1892        assert_eq!(lo.type_, Some(LogicalType::Objectness));
1893        assert_eq!(lo.outputs[0].activation_applied, Some(Activation::Sigmoid));
1894    }
1895
1896    #[test]
1897    fn direct_protos_no_decoder() {
1898        // protos are consumed directly — no `decoder` field
1899        let j = r#"{
1900          "name": "protos", "type": "protos",
1901          "shape": [1, 32, 160, 160],
1902          "dshape": [{"batch": 1}, {"num_protos": 32}, {"height": 160}, {"width": 160}],
1903          "dtype": "uint8",
1904          "quantization": {"scale": 0.0203, "zero_point": 45, "dtype": "uint8"},
1905          "stride": 4
1906        }"#;
1907        let lo: LogicalOutput = serde_json::from_str(j).unwrap();
1908        assert_eq!(lo.type_, Some(LogicalType::Protos));
1909        assert!(lo.decoder.is_none());
1910        assert_eq!(lo.stride, Some(Stride::Square(4)));
1911    }
1912
1913    #[test]
1914    fn full_yolov8_tflite_flat_detection() {
1915        // Example 3: complete two-output YOLOv8 detection schema
1916        let j = r#"{
1917          "schema_version": 2,
1918          "decoder_version": "yolov8",
1919          "nms": "class_agnostic",
1920          "input": { "shape": [1, 640, 640, 3], "cameraadaptor": "rgb" },
1921          "outputs": [
1922            {
1923              "name": "boxes", "type": "boxes",
1924              "shape": [1, 64, 8400],
1925              "dshape": [{"batch": 1}, {"num_features": 64}, {"num_boxes": 8400}],
1926              "dtype": "int8",
1927              "quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "int8"},
1928              "decoder": "ultralytics",
1929              "encoding": "dfl",
1930              "normalized": true
1931            },
1932            {
1933              "name": "scores", "type": "scores",
1934              "shape": [1, 80, 8400],
1935              "dshape": [{"batch": 1}, {"num_classes": 80}, {"num_boxes": 8400}],
1936              "dtype": "int8",
1937              "quantization": {"scale": 0.00392, "zero_point": 0, "dtype": "int8"},
1938              "decoder": "ultralytics",
1939              "score_format": "per_class"
1940            }
1941          ]
1942        }"#;
1943        let s: SchemaV2 = serde_json::from_str(j).unwrap();
1944        assert_eq!(s.schema_version, 2);
1945        assert_eq!(s.decoder_version, Some(DecoderVersion::Yolov8));
1946        assert_eq!(s.nms, Some(NmsMode::ClassAgnostic));
1947        assert_eq!(s.input.as_ref().unwrap().shape, vec![1, 640, 640, 3]);
1948        assert_eq!(s.outputs.len(), 2);
1949    }
1950
1951    #[test]
1952    fn schema_unknown_version_parses_without_validation() {
1953        // Parser accepts any u32; the decoder is responsible for rejecting
1954        // unsupported versions with a useful error.
1955        let j = r#"{"schema_version": 99, "outputs": []}"#;
1956        let s: SchemaV2 = serde_json::from_str(j).unwrap();
1957        assert_eq!(s.schema_version, 99);
1958    }
1959
1960    #[test]
1961    fn serde_roundtrip_preserves_fields() {
1962        let original = SchemaV2 {
1963            schema_version: 2,
1964            input: Some(InputSpec {
1965                shape: vec![1, 3, 640, 640],
1966                dshape: vec![],
1967                cameraadaptor: Some("rgb".into()),
1968            }),
1969            outputs: vec![LogicalOutput {
1970                name: Some("boxes".into()),
1971                type_: Some(LogicalType::Boxes),
1972                shape: vec![1, 4, 8400],
1973                dshape: vec![],
1974                decoder: Some(DecoderKind::Ultralytics),
1975                encoding: Some(BoxEncoding::Direct),
1976                score_format: None,
1977                normalized: Some(true),
1978                anchors: None,
1979                stride: None,
1980                dtype: Some(DType::Float32),
1981                quantization: None,
1982                outputs: vec![],
1983                activation_applied: None,
1984                activation_required: None,
1985            }],
1986            nms: Some(NmsMode::ClassAgnostic),
1987            decoder_version: Some(DecoderVersion::Yolov8),
1988        };
1989        let j = serde_json::to_string(&original).unwrap();
1990        let parsed: SchemaV2 = serde_json::from_str(&j).unwrap();
1991        assert_eq!(parsed, original);
1992    }
1993
1994    // ─── v1 → v2 shim tests ─────────────────────────────────
1995
1996    #[test]
1997    fn parse_v1_yaml_yolov8_seg_testdata() {
1998        let yaml = edgefirst_bench::testdata::read_to_string("yolov8_seg.yaml");
1999        let schema = SchemaV2::parse_yaml(&yaml).expect("parse v1 yaml");
2000        assert_eq!(schema.schema_version, 2);
2001        assert_eq!(schema.outputs.len(), 2);
2002        // First output: Detection [1, 116, 8400]
2003        let det = &schema.outputs[0];
2004        assert_eq!(det.type_, Some(LogicalType::Detection));
2005        assert_eq!(det.shape, vec![1, 116, 8400]);
2006        assert_eq!(det.decoder, Some(DecoderKind::Ultralytics));
2007        assert_eq!(det.encoding, Some(BoxEncoding::Direct));
2008        let q = det.quantization.as_ref().unwrap();
2009        assert_eq!(q.scale.len(), 1);
2010        assert!((q.scale[0] - 0.021_287_762).abs() < 1e-6);
2011        assert_eq!(q.zero_point, Some(vec![31]));
2012        // Second output: Protos [1, 160, 160, 32]
2013        let protos = &schema.outputs[1];
2014        assert_eq!(protos.type_, Some(LogicalType::Protos));
2015        assert_eq!(protos.shape, vec![1, 160, 160, 32]);
2016    }
2017
2018    #[test]
2019    fn parse_v1_json_modelpack_split_testdata() {
2020        let json = edgefirst_bench::testdata::read_to_string("modelpack_split.json");
2021        let schema = SchemaV2::parse_json(&json).expect("parse v1 json");
2022        assert_eq!(schema.schema_version, 2);
2023        assert_eq!(schema.outputs.len(), 2);
2024        // Both are ModelPack anchor detection with anchors
2025        for out in &schema.outputs {
2026            assert_eq!(out.type_, Some(LogicalType::Detection));
2027            assert_eq!(out.decoder, Some(DecoderKind::ModelPack));
2028            assert_eq!(out.encoding, Some(BoxEncoding::Anchor));
2029            assert_eq!(out.anchors.as_ref().map(|a| a.len()), Some(3));
2030        }
2031    }
2032
2033    #[test]
2034    fn parse_v2_json_direct_when_schema_version_present() {
2035        let j = r#"{
2036          "schema_version": 2,
2037          "outputs": [{
2038            "name": "boxes", "type": "boxes",
2039            "shape": [1, 4, 8400],
2040            "dshape": [{"batch": 1}, {"box_coords": 4}, {"num_boxes": 8400}],
2041            "dtype": "float32",
2042            "decoder": "ultralytics",
2043            "encoding": "direct",
2044            "normalized": true
2045          }]
2046        }"#;
2047        let schema = SchemaV2::parse_json(j).unwrap();
2048        assert_eq!(schema.schema_version, 2);
2049        assert_eq!(schema.outputs[0].type_, Some(LogicalType::Boxes));
2050    }
2051
2052    #[test]
2053    fn parse_rejects_future_schema_version() {
2054        let j = r#"{"schema_version": 99, "outputs": []}"#;
2055        let err = SchemaV2::parse_json(j).unwrap_err();
2056        matches!(err, DecoderError::NotSupported(_));
2057    }
2058
2059    #[test]
2060    fn parse_absent_schema_version_treats_as_v1() {
2061        // No schema_version field — classic v1 yolov8 split
2062        let j = r#"{
2063          "outputs": [
2064            {
2065              "type": "boxes", "decoder": "ultralytics",
2066              "shape": [1, 4, 8400],
2067              "quantization": [0.00392, 0]
2068            },
2069            {
2070              "type": "scores", "decoder": "ultralytics",
2071              "shape": [1, 80, 8400],
2072              "quantization": [0.00392, 0]
2073            }
2074          ]
2075        }"#;
2076        let schema = SchemaV2::parse_json(j).expect("v1 legacy parse");
2077        assert_eq!(schema.schema_version, 2); // converted
2078        assert_eq!(schema.outputs.len(), 2);
2079        assert_eq!(schema.outputs[0].type_, Some(LogicalType::Boxes));
2080        assert_eq!(schema.outputs[1].type_, Some(LogicalType::Scores));
2081        // default score_format assumed on v1→v2
2082        assert_eq!(schema.outputs[1].score_format, Some(ScoreFormat::PerClass));
2083    }
2084
2085    #[test]
2086    fn from_v1_preserves_nms_and_decoder_version() {
2087        let v1 = ConfigOutputs {
2088            outputs: vec![ConfigOutput::Boxes(crate::configs::Boxes {
2089                decoder: crate::configs::DecoderType::Ultralytics,
2090                quantization: Some(crate::configs::QuantTuple(0.01, 5)),
2091                shape: vec![1, 4, 8400],
2092                dshape: vec![],
2093                normalized: Some(true),
2094            })],
2095            nms: Some(crate::configs::Nms::ClassAware),
2096            decoder_version: Some(crate::configs::DecoderVersion::Yolo11),
2097        };
2098        let v2 = SchemaV2::from_v1(&v1).unwrap();
2099        assert_eq!(v2.nms, Some(NmsMode::ClassAware));
2100        assert_eq!(v2.decoder_version, Some(DecoderVersion::Yolo11));
2101        assert_eq!(v2.outputs[0].normalized, Some(true));
2102        let q = v2.outputs[0].quantization.as_ref().unwrap();
2103        assert_eq!(q.scale, vec![0.01]);
2104        assert_eq!(q.zero_point, Some(vec![5]));
2105        assert_eq!(q.dtype, None); // v1 did not carry dtype
2106    }
2107
2108    /// Outputs declared without a `type` field must parse successfully
2109    /// and round-trip to JSON without introducing a synthetic type.
2110    /// The metadata v2 spec lists `type` as required on both logical
2111    /// and physical levels, but the HAL tolerates typeless logical
2112    /// outputs as "additional" (auxiliary / diagnostic) tensors that
2113    /// do not participate in decoder dispatch.
2114    #[test]
2115    fn typeless_logical_output_parses_and_roundtrips() {
2116        let j = r#"{
2117            "schema_version": 2,
2118            "outputs": [
2119                {
2120                    "name": "extra_telemetry",
2121                    "shape": [1, 16]
2122                },
2123                {
2124                    "name": "boxes",
2125                    "type": "boxes",
2126                    "shape": [1, 4, 8400]
2127                }
2128            ]
2129        }"#;
2130        let schema: SchemaV2 = serde_json::from_str(j).unwrap();
2131        assert_eq!(schema.outputs.len(), 2);
2132        assert_eq!(schema.outputs[0].type_, None);
2133        assert_eq!(schema.outputs[0].name.as_deref(), Some("extra_telemetry"));
2134        assert_eq!(schema.outputs[1].type_, Some(LogicalType::Boxes));
2135
2136        // Typeless output must not serialize a `type` field.
2137        let round = serde_json::to_string(&schema).unwrap();
2138        let first_obj = round
2139            .split("\"outputs\":[")
2140            .nth(1)
2141            .and_then(|s| s.split("}").next())
2142            .expect("outputs array");
2143        assert!(
2144            !first_obj.contains("\"type\""),
2145            "typeless output must not serialize a `type` field, got: {first_obj}"
2146        );
2147    }
2148
2149    /// Typeless logical outputs are filtered out of the legacy
2150    /// ConfigOutputs — they carry no decoder role and the legacy
2151    /// builder can't represent them. A schema with typeless extras
2152    /// plus a recognised `boxes` role must lower to a legacy config
2153    /// containing only the `boxes` entry.
2154    #[test]
2155    fn typeless_outputs_filtered_from_legacy_config() {
2156        let schema = SchemaV2 {
2157            schema_version: 2,
2158            input: None,
2159            outputs: vec![
2160                LogicalOutput {
2161                    name: Some("diagnostic_histogram".into()),
2162                    type_: None,
2163                    shape: vec![1, 256],
2164                    dshape: vec![],
2165                    decoder: None,
2166                    encoding: None,
2167                    score_format: None,
2168                    normalized: None,
2169                    anchors: None,
2170                    stride: None,
2171                    dtype: None,
2172                    quantization: None,
2173                    outputs: vec![],
2174                    activation_applied: None,
2175                    activation_required: None,
2176                },
2177                LogicalOutput {
2178                    name: Some("boxes".into()),
2179                    type_: Some(LogicalType::Boxes),
2180                    shape: vec![1, 4, 8400],
2181                    dshape: vec![],
2182                    decoder: Some(DecoderKind::Ultralytics),
2183                    encoding: Some(BoxEncoding::Direct),
2184                    score_format: None,
2185                    normalized: Some(true),
2186                    anchors: None,
2187                    stride: None,
2188                    dtype: None,
2189                    quantization: None,
2190                    outputs: vec![],
2191                    activation_applied: None,
2192                    activation_required: None,
2193                },
2194            ],
2195            nms: None,
2196            decoder_version: None,
2197        };
2198        let legacy = schema.to_legacy_config_outputs().unwrap();
2199        assert_eq!(
2200            legacy.outputs.len(),
2201            1,
2202            "typeless output must be filtered from legacy config"
2203        );
2204        assert!(
2205            matches!(legacy.outputs[0], ConfigOutput::Boxes(_)),
2206            "only the typed `boxes` output should survive lowering"
2207        );
2208    }
2209
2210    /// A schema that contains no typed outputs (all typeless) lowers
2211    /// to an empty legacy config. The decoder builder then surfaces
2212    /// its own "No outputs found in config" error — meaningful,
2213    /// decoder-centric, not a serde-level "missing field type".
2214    #[test]
2215    fn all_typeless_schema_produces_empty_legacy_config() {
2216        let schema = SchemaV2 {
2217            schema_version: 2,
2218            input: None,
2219            outputs: vec![LogicalOutput {
2220                name: Some("aux".into()),
2221                type_: None,
2222                shape: vec![1, 8],
2223                dshape: vec![],
2224                decoder: None,
2225                encoding: None,
2226                score_format: None,
2227                normalized: None,
2228                anchors: None,
2229                stride: None,
2230                dtype: None,
2231                quantization: None,
2232                outputs: vec![],
2233                activation_applied: None,
2234                activation_required: None,
2235            }],
2236            nms: None,
2237            decoder_version: None,
2238        };
2239        let legacy = schema.to_legacy_config_outputs().unwrap();
2240        assert!(legacy.outputs.is_empty());
2241    }
2242
2243    /// Physical children may also omit `type`. The schema parses, the
2244    /// output round-trips without a synthetic `type` field, and the
2245    /// uniqueness check doesn't flag a typeless child sharing shape
2246    /// with a typed sibling (the type disambiguator is absent, but we
2247    /// don't bind typeless children by type anyway).
2248    #[test]
2249    fn typeless_physical_child_parses_and_skips_uniqueness() {
2250        let j = r#"{
2251            "name": "boxes",
2252            "type": "boxes",
2253            "shape": [1, 8400, 4],
2254            "outputs": [
2255                {
2256                    "name": "boxes_xy",
2257                    "type": "boxes_xy",
2258                    "shape": [1, 8400, 2],
2259                    "dtype": "float32"
2260                },
2261                {
2262                    "name": "aux_user_managed",
2263                    "shape": [1, 8400, 2],
2264                    "dtype": "float32"
2265                }
2266            ]
2267        }"#;
2268        let lo: LogicalOutput = serde_json::from_str(j).unwrap();
2269        assert_eq!(lo.outputs.len(), 2);
2270        assert_eq!(lo.outputs[0].type_, Some(PhysicalType::BoxesXy));
2271        assert_eq!(lo.outputs[1].type_, None);
2272
2273        // Wrap in a minimal schema so we can call validate().
2274        // BoxesXy and the typeless child share shape `[1, 8400, 2]`;
2275        // the uniqueness check must not treat this as a conflict.
2276        let schema = SchemaV2 {
2277            schema_version: 2,
2278            input: None,
2279            outputs: vec![lo],
2280            nms: None,
2281            decoder_version: None,
2282        };
2283        schema.validate().expect(
2284            "typed + typeless children with equal shape must not trigger \
2285             uniqueness error",
2286        );
2287
2288        // Serialization skips `type` on the typeless child.
2289        let s = serde_json::to_string(&schema).unwrap();
2290        assert!(
2291            s.contains("\"aux_user_managed\""),
2292            "typeless child must survive round-trip: {s}"
2293        );
2294        // Locate the typeless child's JSON object and confirm no `type` key.
2295        let aux_obj = s
2296            .split("\"aux_user_managed\"")
2297            .nth(1)
2298            .and_then(|s| s.split('}').next())
2299            .unwrap_or("");
2300        assert!(
2301            !aux_obj.contains("\"type\""),
2302            "typeless child must not serialize `type`, got: {aux_obj}"
2303        );
2304    }
2305
2306    #[test]
2307    fn from_v1_modelpack_anchor_detection_maps_encoding() {
2308        let v1 = ConfigOutputs {
2309            outputs: vec![ConfigOutput::Detection(crate::configs::Detection {
2310                anchors: Some(vec![[0.1, 0.2], [0.3, 0.4]]),
2311                decoder: crate::configs::DecoderType::ModelPack,
2312                quantization: Some(crate::configs::QuantTuple(0.176, 198)),
2313                shape: vec![1, 40, 40, 54],
2314                dshape: vec![],
2315                normalized: None,
2316            })],
2317            nms: None,
2318            decoder_version: None,
2319        };
2320        let v2 = SchemaV2::from_v1(&v1).unwrap();
2321        assert_eq!(v2.outputs[0].encoding, Some(BoxEncoding::Anchor));
2322        assert_eq!(v2.outputs[0].decoder, Some(DecoderKind::ModelPack));
2323        assert_eq!(v2.outputs[0].anchors.as_ref().map(|a| a.len()), Some(2));
2324    }
2325
2326    // ─── validate() tests ──────────────────────────────────
2327
2328    #[test]
2329    fn validate_accepts_flat_v2_yolov8_detection() {
2330        let j = r#"{
2331          "schema_version": 2,
2332          "outputs": [
2333            {"name":"boxes","type":"boxes","shape":[1,64,8400],
2334             "dtype":"int8","decoder":"ultralytics","encoding":"dfl"},
2335            {"name":"scores","type":"scores","shape":[1,80,8400],
2336             "dtype":"int8","decoder":"ultralytics","score_format":"per_class"}
2337          ]
2338        }"#;
2339        SchemaV2::parse_json(j).unwrap().validate().unwrap();
2340    }
2341
2342    #[test]
2343    fn validate_rejects_unnamed_physical_child() {
2344        let j = r#"{
2345          "schema_version": 2,
2346          "outputs": [{
2347            "name":"boxes","type":"boxes","shape":[1,64,8400],
2348            "encoding":"dfl","decoder":"ultralytics",
2349            "outputs": [{
2350              "name":"","type":"boxes","stride":8,
2351              "shape":[1,80,80,64],"dtype":"uint8"
2352            }]
2353          }]
2354        }"#;
2355        let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
2356        let msg = format!("{err}");
2357        assert!(msg.contains("missing `name`"), "got: {msg}");
2358    }
2359
2360    #[test]
2361    fn validate_rejects_duplicate_physical_shapes() {
2362        let j = r#"{
2363          "schema_version": 2,
2364          "outputs": [{
2365            "name":"boxes","type":"boxes","shape":[1,64,8400],
2366            "encoding":"dfl","decoder":"ultralytics",
2367            "outputs": [
2368              {"name":"a","type":"boxes","stride":8,"shape":[1,80,80,64],"dtype":"uint8"},
2369              {"name":"b","type":"boxes","stride":16,"shape":[1,80,80,64],"dtype":"uint8"}
2370            ]
2371          }]
2372        }"#;
2373        let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
2374        let msg = format!("{err}");
2375        assert!(msg.contains("share shape"), "got: {msg}");
2376    }
2377
2378    #[test]
2379    fn validate_rejects_mixed_decomposition() {
2380        // one child carries stride, the other does not — ill-defined merge
2381        let j = r#"{
2382          "schema_version": 2,
2383          "outputs": [{
2384            "name":"boxes","type":"boxes","shape":[1,4,8400,1],
2385            "encoding":"direct","decoder":"ultralytics",
2386            "outputs": [
2387              {"name":"xy","type":"boxes_xy","shape":[1,2,8400,1],"dtype":"int16"},
2388              {"name":"p0","type":"boxes","stride":8,"shape":[1,80,80,64],"dtype":"uint8"}
2389            ]
2390          }]
2391        }"#;
2392        let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
2393        let msg = format!("{err}");
2394        assert!(msg.contains("uniform"), "got: {msg}");
2395    }
2396
2397    #[test]
2398    fn validate_rejects_dfl_boxes_feature_not_divisible_by_4() {
2399        let j = r#"{
2400          "schema_version": 2,
2401          "outputs": [{
2402            "name":"boxes","type":"boxes","shape":[1,63,8400],
2403            "encoding":"dfl","decoder":"ultralytics",
2404            "outputs": [{
2405              "name":"b0","type":"boxes","stride":8,
2406              "shape":[1,80,80,63],
2407              "dshape":[{"batch":1},{"height":80},{"width":80},{"num_features":63}],
2408              "dtype":"uint8"
2409            }]
2410          }]
2411        }"#;
2412        let err = SchemaV2::parse_json(j).unwrap().validate().unwrap_err();
2413        let msg = format!("{err}");
2414        assert!(msg.contains("not"), "got: {msg}");
2415        assert!(msg.contains("divisible by 4"), "got: {msg}");
2416    }
2417
2418    #[test]
2419    fn validate_accepts_hailo_per_scale_yolov8() {
2420        let j = r#"{
2421          "schema_version": 2,
2422          "outputs": [{
2423            "name":"boxes","type":"boxes","shape":[1,64,8400],
2424            "encoding":"dfl","decoder":"ultralytics","normalized":true,
2425            "outputs": [
2426              {"name":"b0","type":"boxes","stride":8,
2427               "shape":[1,80,80,64],
2428               "dshape":[{"batch":1},{"height":80},{"width":80},{"num_features":64}],
2429               "dtype":"uint8",
2430               "quantization":{"scale":0.0234,"zero_point":128,"dtype":"uint8"}},
2431              {"name":"b1","type":"boxes","stride":16,
2432               "shape":[1,40,40,64],
2433               "dshape":[{"batch":1},{"height":40},{"width":40},{"num_features":64}],
2434               "dtype":"uint8",
2435               "quantization":{"scale":0.0198,"zero_point":130,"dtype":"uint8"}},
2436              {"name":"b2","type":"boxes","stride":32,
2437               "shape":[1,20,20,64],
2438               "dshape":[{"batch":1},{"height":20},{"width":20},{"num_features":64}],
2439               "dtype":"uint8",
2440               "quantization":{"scale":0.0312,"zero_point":125,"dtype":"uint8"}}
2441            ]
2442          }]
2443        }"#;
2444        let s = SchemaV2::parse_json(j).unwrap();
2445        s.validate().unwrap();
2446    }
2447
2448    #[test]
2449    fn validate_accepts_ara2_xy_wh() {
2450        let j = r#"{
2451          "schema_version": 2,
2452          "outputs": [{
2453            "name":"boxes","type":"boxes","shape":[1,4,8400,1],
2454            "encoding":"direct","decoder":"ultralytics","normalized":true,
2455            "outputs": [
2456              {"name":"xy","type":"boxes_xy","shape":[1,2,8400,1],
2457               "dshape":[{"batch":1},{"box_coords":2},{"num_boxes":8400},{"padding":1}],
2458               "dtype":"int16",
2459               "quantization":{"scale":3.1e-5,"zero_point":0,"dtype":"int16"}},
2460              {"name":"wh","type":"boxes_wh","shape":[1,2,8400,1],
2461               "dshape":[{"batch":1},{"box_coords":2},{"num_boxes":8400},{"padding":1}],
2462               "dtype":"int16",
2463               "quantization":{"scale":3.2e-5,"zero_point":0,"dtype":"int16"}}
2464            ]
2465          }]
2466        }"#;
2467        SchemaV2::parse_json(j).unwrap().validate().unwrap();
2468    }
2469
2470    #[test]
2471    fn parse_file_auto_detects_json() {
2472        let tmp = std::env::temp_dir().join(format!("schema_v2_test_{}.json", std::process::id()));
2473        std::fs::write(&tmp, r#"{"schema_version":2,"outputs":[]}"#).unwrap();
2474        let s = SchemaV2::parse_file(&tmp).unwrap();
2475        assert_eq!(s.schema_version, 2);
2476        let _ = std::fs::remove_file(&tmp);
2477    }
2478
2479    #[test]
2480    fn parse_file_auto_detects_yaml() {
2481        let tmp = std::env::temp_dir().join(format!("schema_v2_test_{}.yaml", std::process::id()));
2482        std::fs::write(&tmp, "schema_version: 2\noutputs: []\n").unwrap();
2483        let s = SchemaV2::parse_file(&tmp).unwrap();
2484        assert_eq!(s.schema_version, 2);
2485        let _ = std::fs::remove_file(&tmp);
2486    }
2487
2488    // ─── Real ARA-2 DVM fixtures ────────────────────────────
2489
2490    #[test]
2491    fn parse_real_ara2_int8_dvm_metadata() {
2492        let json = edgefirst_bench::testdata::read_to_string("ara2_int8_edgefirst.json");
2493        let schema = SchemaV2::parse_json(&json).expect("ARA-2 int8 parse");
2494        assert_eq!(schema.schema_version, 2);
2495        assert_eq!(schema.decoder_version, Some(DecoderVersion::Yolov8));
2496        assert_eq!(schema.nms, Some(NmsMode::ClassAgnostic));
2497        assert_eq!(schema.input.as_ref().unwrap().shape, vec![1, 3, 640, 640]);
2498
2499        // Four logical outputs: boxes (split xy/wh), scores, mask_coefs, protos.
2500        assert_eq!(schema.outputs.len(), 4);
2501        let boxes = &schema.outputs[0];
2502        assert_eq!(boxes.type_, Some(LogicalType::Boxes));
2503        assert_eq!(boxes.encoding, Some(BoxEncoding::Direct));
2504        assert_eq!(boxes.normalized, Some(true));
2505        assert_eq!(boxes.shape, vec![1, 4, 8400, 1]); // 4D with padding
2506        assert_eq!(boxes.outputs.len(), 2);
2507        assert_eq!(boxes.outputs[0].type_, Some(PhysicalType::BoxesXy));
2508        assert_eq!(boxes.outputs[1].type_, Some(PhysicalType::BoxesWh));
2509        // xy quant: scale 0.004177791997790337, zp -122, int8
2510        let q_xy = boxes.outputs[0].quantization.as_ref().unwrap();
2511        assert_eq!(q_xy.dtype, Some(DType::Int8));
2512        assert!((q_xy.scale[0] - 0.004_177_792).abs() < 1e-6);
2513        assert_eq!(q_xy.zero_point_at(0), -122);
2514
2515        let scores = &schema.outputs[1];
2516        assert_eq!(scores.type_, Some(LogicalType::Scores));
2517        assert_eq!(scores.score_format, Some(ScoreFormat::PerClass));
2518        assert_eq!(scores.shape, vec![1, 80, 8400, 1]);
2519
2520        let mask_coefs = &schema.outputs[2];
2521        assert_eq!(mask_coefs.type_, Some(LogicalType::MaskCoefs));
2522        assert_eq!(mask_coefs.shape, vec![1, 32, 8400, 1]);
2523
2524        let protos = &schema.outputs[3];
2525        assert_eq!(protos.type_, Some(LogicalType::Protos));
2526        assert_eq!(protos.shape, vec![1, 32, 160, 160]);
2527
2528        // Schema-level validation passes.
2529        schema.validate().expect("ARA-2 int8 validate");
2530    }
2531
2532    #[test]
2533    fn parse_real_ara2_int16_dvm_metadata() {
2534        let json = edgefirst_bench::testdata::read_to_string("ara2_int16_edgefirst.json");
2535        let schema = SchemaV2::parse_json(&json).expect("ARA-2 int16 parse");
2536        assert_eq!(schema.schema_version, 2);
2537        assert_eq!(schema.outputs.len(), 4);
2538        let boxes = &schema.outputs[0];
2539        assert_eq!(boxes.outputs.len(), 2);
2540        let q_xy = boxes.outputs[0].quantization.as_ref().unwrap();
2541        assert_eq!(q_xy.dtype, Some(DType::Int16));
2542        assert!((q_xy.scale[0] - 3.211_570_6e-5).abs() < 1e-10);
2543        assert_eq!(q_xy.zero_point_at(0), 0);
2544        // Mask coefs and protos too are INT16 in this build.
2545        let mc_q = schema.outputs[2].quantization.as_ref().unwrap();
2546        assert_eq!(mc_q.dtype, Some(DType::Int16));
2547        schema.validate().expect("ARA-2 int16 validate");
2548    }
2549
2550    #[test]
2551    fn parse_yaml_with_explicit_schema_version_2() {
2552        let yaml = r#"
2553schema_version: 2
2554outputs:
2555  - name: scores
2556    type: scores
2557    shape: [1, 80, 8400]
2558    dtype: int8
2559    quantization:
2560      scale: 0.00392
2561      dtype: int8
2562    decoder: ultralytics
2563    score_format: per_class
2564"#;
2565        let schema = SchemaV2::parse_yaml(yaml).unwrap();
2566        assert_eq!(schema.schema_version, 2);
2567        assert_eq!(schema.outputs[0].score_format, Some(ScoreFormat::PerClass));
2568    }
2569
2570    // ─── squeeze_padding_dims / to_legacy_config_outputs regressions ────
2571
2572    #[test]
2573    fn squeeze_padding_dims_preserves_shape_when_dshape_absent() {
2574        // Empty dshape must pass shape through untouched. The previous
2575        // `zip` implementation silently truncated to `[]`, which made
2576        // every v2 logical output without named dims arrive at the legacy
2577        // verifier with `shape: []` and fail rank checks.
2578        let (shape, dshape) = squeeze_padding_dims(vec![1, 4, 8400], vec![]);
2579        assert_eq!(shape, vec![1, 4, 8400]);
2580        assert!(dshape.is_empty());
2581    }
2582
2583    fn sample_logical(ty: LogicalType) -> LogicalOutput {
2584        LogicalOutput {
2585            name: Some("sample".into()),
2586            type_: Some(ty),
2587            shape: vec![1, 4, 100],
2588            dshape: vec![],
2589            decoder: None,
2590            encoding: None,
2591            score_format: None,
2592            normalized: Some(true),
2593            anchors: None,
2594            stride: None,
2595            dtype: None,
2596            quantization: None,
2597            outputs: vec![],
2598            activation_applied: None,
2599            activation_required: None,
2600        }
2601    }
2602
2603    #[test]
2604    fn logical_to_legacy_maps_each_supported_type() {
2605        let cases = [
2606            (LogicalType::Boxes, "Boxes"),
2607            (LogicalType::Scores, "Scores"),
2608            (LogicalType::Protos, "Protos"),
2609            (LogicalType::MaskCoefs, "MaskCoefficients"),
2610            (LogicalType::Segmentation, "Segmentation"),
2611            (LogicalType::Masks, "Mask"),
2612            (LogicalType::Classes, "Classes"),
2613            (LogicalType::Detection, "Detection"),
2614            (LogicalType::Detections, "Detection"),
2615        ];
2616        for (ty, expected) in cases {
2617            let out = logical_to_legacy_config_output(&sample_logical(ty)).unwrap();
2618            let name = match &out {
2619                ConfigOutput::Boxes(_) => "Boxes",
2620                ConfigOutput::Scores(_) => "Scores",
2621                ConfigOutput::Protos(_) => "Protos",
2622                ConfigOutput::MaskCoefficients(_) => "MaskCoefficients",
2623                ConfigOutput::Segmentation(_) => "Segmentation",
2624                ConfigOutput::Mask(_) => "Mask",
2625                ConfigOutput::Classes(_) => "Classes",
2626                ConfigOutput::Detection(_) => "Detection",
2627            };
2628            assert_eq!(name, expected, "type {ty:?}");
2629        }
2630    }
2631
2632    #[test]
2633    fn logical_to_legacy_rejects_objectness_and_landmarks() {
2634        for ty in [LogicalType::Objectness, LogicalType::Landmarks] {
2635            let err = logical_to_legacy_config_output(&sample_logical(ty)).unwrap_err();
2636            assert!(
2637                matches!(err, DecoderError::NotSupported(_)),
2638                "expected NotSupported for {ty:?}, got {err:?}"
2639            );
2640        }
2641    }
2642
2643    #[test]
2644    fn logical_to_legacy_rejects_typeless_output() {
2645        let mut logical = sample_logical(LogicalType::Boxes);
2646        logical.type_ = None;
2647        let err = logical_to_legacy_config_output(&logical).unwrap_err();
2648        assert!(matches!(err, DecoderError::InvalidConfig(_)));
2649    }
2650
2651    #[test]
2652    fn to_legacy_modelpack_boxes_preserves_padding_dim() {
2653        // Regression: ModelPack boxes with shape [1, N, 1, 4] (padding dim
2654        // present in dshape) were incorrectly squeezed to [1, N, 4] by
2655        // `logical_to_legacy_config_output`, triggering "Invalid ModelPack
2656        // Boxes shape [1, 1935, 4]". The ModelPack path must skip squeezing.
2657        let j = r#"{
2658          "schema_version": 2,
2659          "outputs": [
2660            {"name":"boxes","type":"boxes",
2661             "shape":[1,1935,1,4],
2662             "dshape":[{"batch":1},{"num_boxes":1935},{"padding":1},{"box_coords":4}],
2663             "decoder":"modelpack"}
2664          ]
2665        }"#;
2666        let schema = SchemaV2::parse_json(j).unwrap();
2667        let legacy = schema.to_legacy_config_outputs().expect("lowers cleanly");
2668        let boxes = match &legacy.outputs[0] {
2669            crate::ConfigOutput::Boxes(b) => b,
2670            other => panic!("expected Boxes, got {other:?}"),
2671        };
2672        // Must preserve the rank-4 shape — the padding dim must NOT be
2673        // squeezed for ModelPack outputs.
2674        assert_eq!(boxes.shape, vec![1, 1935, 1, 4]);
2675        assert_eq!(
2676            boxes.dshape,
2677            vec![
2678                (DimName::Batch, 1),
2679                (DimName::NumBoxes, 1935),
2680                (DimName::Padding, 1),
2681                (DimName::BoxCoords, 4),
2682            ]
2683        );
2684    }
2685
2686    #[test]
2687    fn to_legacy_preserves_shape_for_v2_split_boxes_without_dshape() {
2688        // Regression: `Decoder({...v2 split boxes, shape:[1,4,8400], no dshape...})`
2689        // used to fail with `Invalid Yolo Split Boxes shape []` because
2690        // `squeeze_padding_dims` truncated shape when dshape was empty.
2691        let j = r#"{
2692          "schema_version": 2,
2693          "outputs": [
2694            {"name":"boxes","type":"boxes","shape":[1,4,8400],
2695             "dtype":"float32","decoder":"ultralytics","encoding":"direct"},
2696            {"name":"scores","type":"scores","shape":[1,80,8400],
2697             "dtype":"float32","decoder":"ultralytics","score_format":"per_class"}
2698          ]
2699        }"#;
2700        let schema = SchemaV2::parse_json(j).unwrap();
2701        let legacy = schema.to_legacy_config_outputs().expect("lowers cleanly");
2702        let boxes = match &legacy.outputs[0] {
2703            crate::ConfigOutput::Boxes(b) => b,
2704            other => panic!("expected Boxes, got {other:?}"),
2705        };
2706        assert_eq!(boxes.shape, vec![1, 4, 8400]);
2707        let scores = match &legacy.outputs[1] {
2708            crate::ConfigOutput::Scores(s) => s,
2709            other => panic!("expected Scores, got {other:?}"),
2710        };
2711        assert_eq!(scores.shape, vec![1, 80, 8400]);
2712    }
2713}