Skip to main content

edgefirst_decoder/
schema.rs

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