Skip to main content

edgefirst_decoder/
schema.rs

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