Skip to main content

edgefirst_decoder/
infer.rs

1// SPDX-FileCopyrightText: Copyright 2025-2026 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4//! Ultralytics YOLO schema inference from raw model I/O signals.
5//!
6//! Model export pipelines (ONNX, TFLite, CoreML) carry Ultralytics-authored
7//! metadata (class names, task, input size) alongside the tensor shapes
8//! and dtypes the runtime reports. This module turns that raw signal into
9//! a [`crate::schema::SchemaV2`] the decoder can act on, without requiring
10//! a hand-written `edgefirst.json` for every Ultralytics export.
11
12use std::collections::BTreeMap;
13use std::fmt;
14
15use crate::configs::DimName;
16use crate::schema::{
17    BoxEncoding, DType, DecoderKind, DecoderVersion, InputSpec, LogicalOutput, LogicalType,
18    NmsMode, Quantization, SchemaV2, ScoreFormat,
19};
20
21/// A single input or output tensor as reported by the inference runtime.
22#[derive(Debug, Clone, PartialEq)]
23pub struct TensorInfo {
24    /// Tensor name as reported by the runtime.
25    pub name: String,
26    /// Tensor shape in the model's native layout.
27    pub shape: Vec<usize>,
28    /// Tensor element dtype.
29    pub dtype: DType,
30    /// Quantization parameters, if the tensor is quantized.
31    pub quantization: Option<Quantization>,
32}
33
34/// Container format of the model the signals were read from. Drives
35/// Ultralytics per-format conventions (box normalization, tensor order).
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum ModelSource {
38    /// An ONNX export. Ultralytics emits pixel-space box coordinates here,
39    /// so the inferred schema sets `normalized: false`.
40    Onnx,
41    /// A TFLite/LiteRT export. Ultralytics emits boxes normalized to
42    /// `[0, 1]` here, so the inferred schema sets `normalized: true`.
43    TfLite,
44    /// A CoreML export (`.mlpackage` / `.mlmodelc`). Ultralytics' CoreML
45    /// converter traces the same graph the ONNX exporter traces — the
46    /// `1/w` normalize lives only in `IOSDetectModel`, which is used
47    /// solely on the `nms=True` pipeline path — so a no-NMS export emits
48    /// pixel-space box coordinates and the inferred schema sets
49    /// `normalized: false`, matching [`ModelSource::Onnx`].
50    ///
51    /// Established by source-tracing the Ultralytics exporter and
52    /// confirmed by runtime magnitude measurement against a matched fp16
53    /// ONNX pair; `testdata/infer/NOTES.md` records the reasoning, the
54    /// measurement and the captured fixture.
55    ///
56    /// This resolution covers the no-NMS export only. A `nms=True` export
57    /// runs Apple's NMS pipeline (`image`/`iouThreshold`/
58    /// `confidenceThreshold` inputs, dynamically-shaped `confidence`/
59    /// `coordinates` outputs) — a different artifact entirely, not the
60    /// anchor-grid tensor this resolution reasons about. Inference does
61    /// not classify it as a schema: neither output's shape can match the
62    /// `[1, 4+nc, A]` anchor-grid layout this module looks for.
63    CoreMl,
64    /// Any other container. Inference **refuses** this with
65    /// [`InferError::UnknownBoxConvention`] rather than assuming a box
66    /// convention: whether coordinates are pixel-space or `[0, 1]` follows
67    /// the exporter, is not derivable from tensor shapes, and getting it
68    /// wrong scales every box by the input size. The variant exists so a
69    /// signal collector can report honestly what it read; add a measured
70    /// container here rather than routing it through `Other`.
71    Other,
72}
73
74/// Raw model I/O signals: tensor shapes/dtypes plus unparsed metadata, as
75/// reported by the inference runtime that loaded the model.
76#[derive(Debug, Clone, PartialEq)]
77pub struct ModelSignals {
78    /// Container format the signals were read from.
79    pub source: ModelSource,
80    /// Input tensors.
81    pub inputs: Vec<TensorInfo>,
82    /// Output tensors.
83    pub outputs: Vec<TensorInfo>,
84    /// Raw model metadata key/values (ONNX metadata_props, TFLite
85    /// metadata entries, CoreML's own metadata alongside Ultralytics'
86    /// props). Values are passed verbatim; parsing happens here.
87    pub metadata: BTreeMap<String, String>,
88}
89
90/// Result of inferring a schema from [`ModelSignals`].
91#[derive(Debug, Clone, PartialEq)]
92pub struct InferredSchema {
93    /// The inferred schema.
94    pub schema: SchemaV2,
95    /// Class names, in index order.
96    pub labels: Vec<String>,
97    /// Human-readable summary, e.g. "Ultralytics YOLO26 segment, 80 classes".
98    pub description: String,
99}
100
101/// Errors that can occur while inferring an Ultralytics schema.
102#[derive(Debug)]
103pub enum InferError {
104    /// Model metadata carries no Ultralytics signature.
105    NotUltralytics(String),
106    /// The Ultralytics `names` metadata field could not be parsed.
107    BadNames(String),
108    /// A metadata field is present but carries a value that cannot be
109    /// interpreted. Distinct from an absent field, which is allowed to
110    /// select a fallback.
111    BadMetadata(String),
112    /// The Ultralytics `task` metadata field names an unsupported task.
113    UnsupportedTask(String),
114    /// The number of classes in `names` does not match what the output
115    /// tensor shapes imply.
116    ClassCountMismatch { expected: usize, found: usize },
117    /// The output tensor layout does not match any supported Ultralytics
118    /// convention.
119    UnsupportedLayout(String),
120    /// More than one output matches the same layout, so which tensor
121    /// carries the detections depends on the order the runtime reported
122    /// them. Reported rather than resolved by position.
123    AmbiguousLayout(String),
124    /// A boundary tensor carries quantization the decoder cannot consume.
125    /// Currently this means per-channel quantization: the decoder supports
126    /// per-tensor only, so such a schema would build a decoder that fails.
127    UnsupportedQuantization(String),
128    /// The signals came from [`ModelSource::Other`], for which the box
129    /// coordinate convention is unmeasured. Only the ONNX (pixel-space),
130    /// TFLite (`[0, 1]`), and CoreML (pixel-space) conventions are
131    /// characterized, and picking the wrong one silently scales every box
132    /// by the input size.
133    UnknownBoxConvention,
134}
135
136impl fmt::Display for InferError {
137    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138        match self {
139            InferError::NotUltralytics(s) => {
140                write!(f, "model metadata carries no Ultralytics signature: {s}")
141            }
142            InferError::BadNames(s) => {
143                write!(f, "cannot parse Ultralytics `names` metadata: {s}")
144            }
145            InferError::BadMetadata(s) => {
146                write!(f, "malformed Ultralytics metadata: {s}")
147            }
148            InferError::UnsupportedTask(s) => write!(
149                f,
150                "unsupported Ultralytics task `{s}` (supported: detect, segment)"
151            ),
152            InferError::ClassCountMismatch { expected, found } => write!(
153                f,
154                "class-count mismatch: names give {expected} classes but output features imply {found}"
155            ),
156            InferError::UnsupportedLayout(s) => write!(f, "unsupported output layout: {s}"),
157            InferError::AmbiguousLayout(s) => write!(f, "ambiguous output layout: {s}"),
158            InferError::UnsupportedQuantization(s) => {
159                write!(f, "unsupported quantization: {s}")
160            }
161            InferError::UnknownBoxConvention => write!(
162                f,
163                "box coordinate convention is unknown for this container; only \
164                 onnx (pixel-space), tflite ([0,1]), and coreml (pixel-space) \
165                 are characterized"
166            ),
167        }
168    }
169}
170
171impl std::error::Error for InferError {}
172
173/// Supported Ultralytics export tasks.
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175enum Task {
176    Detect,
177    Segment,
178}
179
180impl Task {
181    fn parse(s: &str) -> Result<Self, InferError> {
182        match s.trim().to_ascii_lowercase().as_str() {
183            "detect" => Ok(Task::Detect),
184            "segment" => Ok(Task::Segment),
185            other => Err(InferError::UnsupportedTask(other.to_string())),
186        }
187    }
188}
189
190/// Parsed Ultralytics export metadata, in whichever raw form (ONNX flat
191/// string props, or TFLite `metadata.json` envelope) it was captured.
192#[derive(Debug, Clone, PartialEq)]
193struct UltralyticsMeta {
194    names: Vec<String>,
195    task: Option<Task>,
196    /// YOLO26's built-in NMS-free end-to-end head signal. `Some(true)` for
197    /// YOLO26 exports, `Some(false)` for NMS-required heads (v8/v11),
198    /// `None` when the metadata omits the field entirely.
199    end2end: Option<bool>,
200}
201
202/// Refuses metadata that positively identifies a *different* vendor.
203///
204/// A `names` map alone is not an Ultralytics signature — any exporter can
205/// write class names — and this function then applies Ultralytics box,
206/// score and normalization conventions to whatever it was given. Real
207/// exports carry `author: "Ultralytics"` and a `docs` URL, so when either
208/// is present and names someone else, that is a positive contradiction and
209/// an error.
210///
211/// Absence is not treated as a contradiction: intermediate tools
212/// (quantizers, format converters) routinely preserve `names` while
213/// dropping provenance fields, and those exports are still the ones this
214/// module exists to read. The layout cross-checks that follow remain the
215/// substantive guard — a model whose shapes don't fit is rejected whatever
216/// its metadata claims.
217fn reject_foreign_vendor(author: Option<&String>, docs: Option<&String>) -> Result<(), InferError> {
218    let names_ultralytics = |v: &String| v.to_ascii_lowercase().contains("ultralytics");
219    for (field, value) in [("author", author), ("docs", docs)] {
220        if let Some(v) = value {
221            if !v.trim().is_empty() && !names_ultralytics(v) {
222                return Err(InferError::NotUltralytics(format!(
223                    "metadata `{field}` is `{v}`, not Ultralytics"
224                )));
225            }
226        }
227    }
228    Ok(())
229}
230
231impl UltralyticsMeta {
232    /// Extracts Ultralytics metadata from a model's raw metadata map.
233    ///
234    /// Handles two forms: flat string props (ONNX `metadata_props`, one
235    /// key per field) and the TFLite `metadata.json` envelope (a single
236    /// entry whose *value* is a JSON document carrying the same fields).
237    fn from_metadata(meta: &BTreeMap<String, String>) -> Result<Self, InferError> {
238        if let Some(names_raw) = meta.get("names") {
239            reject_foreign_vendor(meta.get("author"), meta.get("docs"))?;
240            return Self::from_flat_props(meta, names_raw);
241        }
242
243        // TFLite form: scan every metadata value for a JSON object that
244        // carries a `names` field.
245        for value in meta.values() {
246            if let Ok(serde_json::Value::Object(obj)) = serde_json::from_str(value) {
247                if let Some(names) = obj.get("names") {
248                    let author = obj
249                        .get("author")
250                        .and_then(|v| v.as_str())
251                        .map(str::to_string);
252                    let docs = obj.get("docs").and_then(|v| v.as_str()).map(str::to_string);
253                    reject_foreign_vendor(author.as_ref(), docs.as_ref())?;
254                    return Self::from_json_envelope(&obj, names);
255                }
256            }
257        }
258
259        Err(InferError::NotUltralytics(
260            "no `names` metadata field or metadata.json envelope found".into(),
261        ))
262    }
263
264    fn from_flat_props(
265        meta: &BTreeMap<String, String>,
266        names_raw: &str,
267    ) -> Result<Self, InferError> {
268        let names = parse_names(names_raw)?;
269        let task = meta.get("task").map(|t| Task::parse(t)).transpose()?;
270        let end2end = meta.get("end2end").map(|s| parse_bool_str(s)).transpose()?;
271        Ok(UltralyticsMeta {
272            names,
273            task,
274            end2end,
275        })
276    }
277
278    /// Takes the `names` value directly rather than re-looking it up:
279    /// [`Self::from_metadata`] only reaches here after finding the key, so
280    /// a second lookup would add an error arm that can never fire.
281    fn from_json_envelope(
282        obj: &serde_json::Map<String, serde_json::Value>,
283        names_value: &serde_json::Value,
284    ) -> Result<Self, InferError> {
285        let names = names_from_json_value(names_value)?;
286        let task = obj
287            .get("task")
288            .and_then(|v| v.as_str())
289            .map(Task::parse)
290            .transpose()?;
291        // The envelope types it properly, so anything non-boolean is
292        // corrupt rather than merely absent.
293        let end2end = match obj.get("end2end") {
294            None | Some(serde_json::Value::Null) => None,
295            Some(serde_json::Value::Bool(b)) => Some(*b),
296            Some(other) => {
297                return Err(InferError::BadMetadata(format!(
298                    "`end2end` is {other}, expected a boolean"
299                )))
300            }
301        };
302        Ok(UltralyticsMeta {
303            names,
304            task,
305            end2end,
306        })
307    }
308}
309
310/// Parses an Ultralytics `end2end` metadata string value (`"True"` /
311/// `"False"`, case-insensitively).
312///
313/// An unparseable value is an error rather than `None`: `None` means "the
314/// key is absent", which selects the shape-only fallback. Letting a
315/// corrupt value collapse into that would classify by shape while the
316/// model was actively claiming something else.
317fn parse_bool_str(s: &str) -> Result<bool, InferError> {
318    match s.trim().to_ascii_lowercase().as_str() {
319        "true" => Ok(true),
320        "false" => Ok(false),
321        other => Err(InferError::BadMetadata(format!(
322            "`end2end` is `{other}`, expected true or false"
323        ))),
324    }
325}
326
327/// Parses the Ultralytics `names` metadata field, which appears in two
328/// forms: a stringified Python dict (`"{0: 'person', 1: 'bicycle'}"`, as
329/// captured verbatim from ONNX `metadata_props`) or a JSON object with
330/// string keys (`{"0": "person", "1": "bicycle"}`).
331fn parse_names(s: &str) -> Result<Vec<String>, InferError> {
332    let trimmed = s.trim();
333
334    if let Ok(map) = serde_json::from_str::<BTreeMap<String, String>>(trimmed) {
335        return names_from_str_keyed_map(map);
336    }
337
338    let body = trimmed
339        .strip_prefix('{')
340        .and_then(|s| s.strip_suffix('}'))
341        .ok_or_else(|| InferError::BadNames(format!("not a dict: {trimmed}")))?;
342
343    let mut indexed: BTreeMap<usize, String> = BTreeMap::new();
344    for item in split_top_level_commas(body) {
345        let item = item.trim();
346        if item.is_empty() {
347            continue;
348        }
349        let (key, value) = item
350            .split_once(':')
351            .ok_or_else(|| InferError::BadNames(format!("malformed entry `{item}`")))?;
352        let idx: usize = key
353            .trim()
354            .parse()
355            .map_err(|_| InferError::BadNames(format!("non-numeric key `{key}`")))?;
356        let value = unquote(value.trim())
357            .ok_or_else(|| InferError::BadNames(format!("unquoted value `{value}`")))?;
358        indexed.insert(idx, value);
359    }
360    contiguous_values(indexed)
361}
362
363/// Converts a `names` JSON value (a JSON object keyed by stringified
364/// index) into ordered class names.
365fn names_from_json_value(v: &serde_json::Value) -> Result<Vec<String>, InferError> {
366    let obj = v
367        .as_object()
368        .ok_or_else(|| InferError::BadNames("`names` is not a JSON object".into()))?;
369    let mut map = BTreeMap::new();
370    for (k, val) in obj {
371        let s = val
372            .as_str()
373            .ok_or_else(|| InferError::BadNames(format!("non-string name for key `{k}`")))?;
374        map.insert(k.clone(), s.to_string());
375    }
376    names_from_str_keyed_map(map)
377}
378
379/// Converts a string-keyed names map (JSON form) into ordered class names
380/// by parsing keys as indices, rather than relying on lexicographic string
381/// order (which would misorder e.g. "10" before "2").
382fn names_from_str_keyed_map(map: BTreeMap<String, String>) -> Result<Vec<String>, InferError> {
383    let mut indexed: BTreeMap<usize, String> = BTreeMap::new();
384    for (k, v) in map {
385        let idx: usize = k
386            .parse()
387            .map_err(|_| InferError::BadNames(format!("non-numeric key `{k}`")))?;
388        indexed.insert(idx, v);
389    }
390    contiguous_values(indexed)
391}
392
393/// Requires a `0..n` contiguous index range and returns the values in
394/// index order.
395fn contiguous_values(indexed: BTreeMap<usize, String>) -> Result<Vec<String>, InferError> {
396    // Zero classes is not a degenerate-but-valid model: `feat` would be
397    // `4 + 0`, and the decoder requires at least one class, so a schema
398    // built from it parses and then fails at `DecoderBuilder::build`.
399    // Refuse here, where the message can say what is actually missing.
400    if indexed.is_empty() {
401        return Err(InferError::BadNames("class map is empty".into()));
402    }
403    let n = indexed.len();
404    for i in 0..n {
405        if !indexed.contains_key(&i) {
406            return Err(InferError::BadNames("non-contiguous class indices".into()));
407        }
408    }
409    Ok(indexed.into_values().collect())
410}
411
412/// Splits a dict body on top-level commas, treating `'...'` and `"..."`
413/// spans as opaque so commas inside class names never split an entry.
414fn split_top_level_commas(s: &str) -> Vec<String> {
415    let mut parts = Vec::new();
416    let mut current = String::new();
417    let mut quote: Option<char> = None;
418    for c in s.chars() {
419        match quote {
420            Some(q) => {
421                current.push(c);
422                if c == q {
423                    quote = None;
424                }
425            }
426            None => match c {
427                '\'' | '"' => {
428                    quote = Some(c);
429                    current.push(c);
430                }
431                ',' => parts.push(std::mem::take(&mut current)),
432                _ => current.push(c),
433            },
434        }
435    }
436    if !current.is_empty() {
437        parts.push(current);
438    }
439    parts
440}
441
442/// Strips matching outer `'` or `"` quotes from a value, returning `None`
443/// if the value isn't quoted.
444fn unquote(s: &str) -> Option<String> {
445    let bytes = s.as_bytes();
446    if s.len() >= 2 {
447        let first = bytes[0];
448        let last = bytes[s.len() - 1];
449        if (first == b'\'' && last == b'\'') || (first == b'"' && last == b'"') {
450            return Some(s[1..s.len() - 1].to_string());
451        }
452    }
453    None
454}
455
456/// Classifies a rank-4 model input's spatial layout.
457///
458/// Channel dimension is wherever the shape holds a `3`: index 1 (NCHW) or
459/// index 3 (NHWC). Layout is never assumed from [`ModelSource`] — some
460/// TFLite exports are still NCHW at the tensor level.
461///
462/// Batch must be 1. Every output layout this module classifies is
463/// batch-1 (`classify_pre_nms`, `find_e2e_candidate` and `classify_proto`
464/// all require it), so accepting a batched input would emit a `dshape`
465/// whose `Batch` disagrees with the detection tensor it is paired with.
466#[allow(clippy::type_complexity)]
467fn classify_input_layout(
468    input: &TensorInfo,
469) -> Result<(usize, usize, Vec<(DimName, usize)>), InferError> {
470    debug_assert_eq!(
471        input.shape.len(),
472        4,
473        "caller selects the input by rank-4; see infer_ultralytics_schema"
474    );
475    if input.shape[0] != 1 {
476        return Err(InferError::UnsupportedLayout(format!(
477            "model input `{}` has batch {} but every supported output layout \
478             is batch-1; shape {:?}",
479            input.name, input.shape[0], input.shape
480        )));
481    }
482    if input.shape[1] == 3 {
483        let (h, w) = (input.shape[2], input.shape[3]);
484        Ok((
485            h,
486            w,
487            vec![
488                (DimName::Batch, input.shape[0]),
489                (DimName::NumFeatures, 3),
490                (DimName::Height, h),
491                (DimName::Width, w),
492            ],
493        ))
494    } else if input.shape[3] == 3 {
495        let (h, w) = (input.shape[1], input.shape[2]);
496        Ok((
497            h,
498            w,
499            vec![
500                (DimName::Batch, input.shape[0]),
501                (DimName::Height, h),
502                (DimName::Width, w),
503                (DimName::NumFeatures, 3),
504            ],
505        ))
506    } else {
507        Err(InferError::UnsupportedLayout(format!(
508            "cannot locate the channel dimension (=3) in input shape {:?}",
509            input.shape
510        )))
511    }
512}
513
514/// If `shape` is rank-3 with a unit leading (batch) dim and one of its
515/// remaining two dims equals `expected_anchors`, returns the other dim's
516/// value and its index (1 or 2) within `shape`. `None` if no dim matches,
517/// including when the leading dim isn't 1 — same requirement as
518/// [`find_e2e_candidate`], so a batch > 1 tensor is never silently
519/// mislabeled batch-1.
520fn find_anchors_dim(shape: &[usize], expected_anchors: usize) -> Option<(usize, usize)> {
521    if shape.len() != 3 || shape[0] != 1 {
522        return None;
523    }
524    if shape[2] == expected_anchors {
525        Some((shape[1], 1)) // features candidate at index 1: features-first
526    } else if shape[1] == expected_anchors {
527        Some((shape[2], 2)) // features candidate at index 2: anchors-first
528    } else {
529        None
530    }
531}
532
533/// Classifies the combined pre-NMS detection layout (rule 4): a rank-3
534/// output whose dims contain both `expected_anchors` and `feat` (`4 + nc`).
535///
536/// Every rank-3 output is scanned to completion. A tensor that carries the
537/// anchor count but the wrong feature width is remembered as a fallback
538/// [`InferError::ClassCountMismatch`] rather than returned immediately,
539/// because an export can legitimately publish more than one anchor-shaped
540/// output — a `[1, 4, 8400]` box tensor beside the `[1, 4+nc, 8400]`
541/// detection tensor, or a `[1, k, 8400]` mask-coefficient tensor — and
542/// returning on the first near-miss made the result depend on the order the
543/// runtime happened to report its outputs in. The mismatch is only
544/// surfaced when no output matched, where it is the more useful diagnostic:
545/// a dim equal to the anchor count unambiguously signals pre-NMS intent, so
546/// a feature-width mismatch there is a class-count problem rather than a
547/// layout question.
548#[allow(clippy::type_complexity)]
549fn classify_pre_nms<'a>(
550    rank3_outputs: &[&'a TensorInfo],
551    expected_anchors: usize,
552    feat: usize,
553    nc: usize,
554) -> Result<(&'a TensorInfo, Vec<(DimName, usize)>), InferError> {
555    let mut mismatch: Option<InferError> = None;
556    let mut matched: Option<(&'a TensorInfo, Vec<(DimName, usize)>)> = None;
557    for out in rank3_outputs {
558        let Some((other_dim, feat_idx)) = find_anchors_dim(&out.shape, expected_anchors) else {
559            continue;
560        };
561        if other_dim == feat {
562            let dshape = if feat_idx == 1 {
563                vec![
564                    (DimName::Batch, 1),
565                    (DimName::NumFeatures, feat),
566                    (DimName::NumBoxes, expected_anchors),
567                ]
568            } else {
569                vec![
570                    (DimName::Batch, 1),
571                    (DimName::NumBoxes, expected_anchors),
572                    (DimName::NumFeatures, feat),
573                ]
574            };
575            // Two tensors of the identical detection shape leave nothing to
576            // choose between them but the order the runtime listed them in.
577            if let Some((first, _)) = &matched {
578                return Err(InferError::AmbiguousLayout(format!(
579                    "outputs `{}` and `{}` both match the pre-NMS detection layout \
580                     ({:?}); cannot tell which carries the detections",
581                    first.name, out.name, out.shape
582                )));
583            }
584            matched = Some((out, dshape));
585            continue;
586        }
587        mismatch.get_or_insert_with(|| InferError::ClassCountMismatch {
588            expected: nc,
589            // `feat - nc` is the non-class base width: `4` for detection,
590            // `4 + k` for segmentation (proto channels).
591            found: other_dim.saturating_sub(feat.saturating_sub(nc)),
592        });
593    }
594    if let Some(found) = matched {
595        return Ok(found);
596    }
597    Err(mismatch.unwrap_or_else(|| {
598        InferError::UnsupportedLayout(format!(
599            "no output matches the Ultralytics pre-NMS layout (expected a dim == \
600             {expected_anchors} anchors and a dim == {feat} features [4 + {nc} classes]); \
601             outputs seen: {:?}",
602            rank3_outputs.iter().map(|o| &o.shape).collect::<Vec<_>>()
603        ))
604    }))
605}
606
607/// Finds an end-to-end (YOLO26) candidate: a rank-3 output shaped
608/// `[1, N, feat]`, where `feat` is `6` for detection or `6 + k` for
609/// segmentation (`k` being the proto channel count).
610///
611/// `N` is the export's `max_det`, which Ultralytics bakes into the graph
612/// rather than applying afterwards: the head's `postprocess` runs a TopK
613/// with `k = min(max_det, anchors)` (`nn/modules/head.py`), so the tensor
614/// carries exactly that many rows and no padding. The bound here is
615/// upstream's own clamp, `m.max_det = min(args.max_det, available)` where
616/// `available` is the anchor count summed over strides
617/// (`engine/exporter.py`) — the identical quantity this module computes as
618/// `expected_anchors`. So `N == expected_anchors` is a legitimate
619/// end-to-end shape (any `max_det >= anchors` produces it, verified by
620/// export), not a pre-NMS tensor to be excluded.
621fn find_e2e_candidate<'a>(
622    rank3_outputs: &[&'a TensorInfo],
623    expected_anchors: usize,
624    feat: usize,
625) -> Result<Option<(&'a TensorInfo, usize)>, InferError> {
626    let matches: Vec<(&'a TensorInfo, usize)> = rank3_outputs
627        .iter()
628        .filter_map(|o| {
629            let s = &o.shape;
630            debug_assert_eq!(s.len(), 3, "caller filters to rank-3 outputs");
631            // `N > 0`: a zero-row tensor satisfies every other condition but
632            // produces a schema the decoder rejects for a zero dimension.
633            (s[0] == 1 && s[2] == feat && s[1] > 0 && s[1] <= expected_anchors)
634                .then_some((*o, s[1]))
635        })
636        .collect();
637    // Two tensors of the same end-to-end shape leave nothing to choose
638    // between them but the order the runtime listed them in. This module
639    // reports ambiguity rather than picking.
640    if matches.len() > 1 {
641        return Err(InferError::AmbiguousLayout(format!(
642            "{} outputs match the end-to-end layout [1, N, {feat}]: {:?}; \
643             cannot tell which carries the detections",
644            matches.len(),
645            matches.iter().map(|(o, _)| &o.name).collect::<Vec<_>>()
646        )));
647    }
648    Ok(matches.into_iter().next())
649}
650
651/// Classifies a rank-4 proto tensor (rule 1): the mask-prototype output
652/// paired with a segmentation detection tensor. Expected spatial dims are
653/// `(H/4, W/4)` (Ultralytics proto stride 4); the remaining non-batch dim
654/// is the proto channel count `k`, never hardcoded. Supports both
655/// `[1, k, H/4, W/4]` (NCHW) and `[1, H/4, W/4, k]` (NHWC).
656///
657/// The NCHW check runs first, so a shape where `k` happens to equal one of
658/// the spatial dims (e.g. `[1, 160, 160, 160]` on a 640x640 input, where
659/// `k == H/4 == W/4`) is shape-undecidable and deliberately resolves NCHW
660/// rather than erroring — no real Ultralytics export produces a proto with
661/// that many channels, so this tie-break is a pragmatic default, not a
662/// verified convention.
663fn classify_proto(
664    proto: &TensorInfo,
665    h: usize,
666    w: usize,
667) -> Result<(usize, Vec<(DimName, usize)>), InferError> {
668    let (eh, ew) = (h / 4, w / 4);
669    let s = &proto.shape;
670    if s.len() != 4 || s[0] != 1 {
671        return Err(InferError::UnsupportedLayout(format!(
672            "expected a rank-4 proto tensor with batch 1, found shape {s:?}"
673        )));
674    }
675    if s[2] == eh && s[3] == ew {
676        let k = s[1];
677        Ok((
678            k,
679            vec![
680                (DimName::Batch, 1),
681                (DimName::NumProtos, k),
682                (DimName::Height, eh),
683                (DimName::Width, ew),
684            ],
685        ))
686    } else if s[1] == eh && s[2] == ew {
687        let k = s[3];
688        Ok((
689            k,
690            vec![
691                (DimName::Batch, 1),
692                (DimName::Height, eh),
693                (DimName::Width, ew),
694                (DimName::NumProtos, k),
695            ],
696        ))
697    } else {
698        Err(InferError::UnsupportedLayout(format!(
699            "proto tensor shape {s:?} doesn't match the expected ({eh}, {ew}) spatial dims"
700        )))
701    }
702}
703
704/// Default field values for a [`LogicalOutput`] whose semantic fields are
705/// filled in by the caller; every field not overridden is `None` or empty.
706fn default_logical() -> LogicalOutput {
707    LogicalOutput {
708        name: None,
709        type_: None,
710        shape: Vec::new(),
711        dshape: Vec::new(),
712        decoder: None,
713        encoding: None,
714        score_format: None,
715        normalized: None,
716        anchors: None,
717        stride: None,
718        dtype: None,
719        quantization: None,
720        outputs: Vec::new(),
721        activation_applied: None,
722        activation_required: None,
723    }
724}
725
726/// Rejects an integer-dtype boundary tensor that carries no quantization
727/// parameters — there would be no way to dequantize it, so this is always
728/// an error rather than a value silently passed through as raw integers.
729///
730/// Real Ultralytics int8 TFLite exports never hit this: the int8 graph is
731/// wrapped in dequant/quant ops, so the boundary tensors the runtime
732/// reports are float32 (see `infer_int8_tflite_classifies_like_float32`).
733/// This guard only fires for a genuinely quantized boundary, which no
734/// captured fixture exhibits but a future export convention might.
735/// Per-channel quantization is refused here for the same reason: the
736/// decoder supports per-tensor quantization only, so a per-channel boundary
737/// would produce a schema that parses and then fails at
738/// `DecoderBuilder::build` with an error naming the decoder rather than the
739/// signal that caused it. Refusing at the boundary names the real problem
740/// while the caller still has the tensor in hand.
741fn check_quantized_boundary(tensor: &TensorInfo) -> Result<(), InferError> {
742    if tensor.dtype.is_integer() && tensor.quantization.is_none() {
743        return Err(InferError::UnsupportedLayout(format!(
744            "output `{}` has integer dtype {:?} but no quantization params",
745            tensor.name, tensor.dtype
746        )));
747    }
748    if let Some(q) = &tensor.quantization {
749        if q.scale.len() > 1 {
750            return Err(InferError::UnsupportedQuantization(format!(
751                "output `{}` carries per-channel quantization ({} scales); \
752                 the decoder supports per-tensor quantization only",
753                tensor.name,
754                q.scale.len()
755            )));
756        }
757        // Exactly one scale, not merely "not per-channel". An empty scale
758        // list passed the check above and produced `{"scale": []}`, which
759        // the decoder reads as a zero scale rather than rejecting.
760        if q.scale.is_empty() {
761            return Err(InferError::UnsupportedQuantization(format!(
762                "output `{}` is quantized but carries no scale",
763                tensor.name
764            )));
765        }
766        // Zero points are optional (symmetric quantization), but when
767        // present must pair with the scale; a longer list was silently
768        // truncated to its first element.
769        if let Some(zp) = &q.zero_point {
770            if zp.len() != q.scale.len() {
771                return Err(InferError::UnsupportedQuantization(format!(
772                    "output `{}` has {} scale(s) but {} zero point(s)",
773                    tensor.name,
774                    q.scale.len(),
775                    zp.len()
776                )));
777            }
778        }
779    }
780    Ok(())
781}
782
783/// Infers an Ultralytics YOLO [`SchemaV2`] from raw model I/O signals and
784/// metadata.
785///
786/// Detection and segmentation are both supported. Segmentation is
787/// recognised either by metadata (`task == segment`) or by the presence of
788/// a rank-4 proto tensor; the two signals are cross-checked (rule 5) so a
789/// `segment` task with no proto, or a `detect` task with a proto present,
790/// is always an error rather than a guess.
791///
792/// # Examples
793///
794/// A two-class YOLOv8 detection export, as an inference runtime would
795/// report it — a rank-4 input, one `[1, 4 + nc, anchors]` output, and the
796/// metadata the exporter embedded:
797///
798/// ```
799/// use std::collections::BTreeMap;
800///
801/// use edgefirst_decoder::schema::DType;
802/// use edgefirst_decoder::{
803///     infer_ultralytics_schema, ModelSignals, ModelSource, TensorInfo,
804/// };
805///
806/// let tensor = |name: &str, shape: &[usize]| TensorInfo {
807///     name: name.to_string(),
808///     shape: shape.to_vec(),
809///     dtype: DType::Float32,
810///     quantization: None,
811/// };
812///
813/// let mut metadata = BTreeMap::new();
814/// metadata.insert("task".to_string(), "detect".to_string());
815/// metadata.insert("end2end".to_string(), "False".to_string());
816/// metadata.insert("names".to_string(), "{0: 'person', 1: 'bicycle'}".to_string());
817///
818/// let signals = ModelSignals {
819///     source: ModelSource::Onnx,
820///     inputs: vec![tensor("images", &[1, 3, 640, 640])],
821///     // 8400 anchors for a 640x640 input (80² + 40² + 20²);
822///     // 6 features = 4 box coordinates + 2 classes.
823///     outputs: vec![tensor("output0", &[1, 6, 8400])],
824///     metadata,
825/// };
826///
827/// let inferred = infer_ultralytics_schema(&signals)?;
828/// assert_eq!(inferred.labels, ["person", "bicycle"]);
829/// assert_eq!(inferred.description, "Ultralytics YOLOv8/11 detect, 2 classes");
830///
831/// // Hand the schema to `DecoderBuilder` as `edgefirst.json` v2 JSON.
832/// let config_json = serde_json::to_string(&inferred.schema).unwrap();
833/// assert!(config_json.contains("\"decoder_version\":\"yolov8\""));
834/// # Ok::<(), edgefirst_decoder::InferError>(())
835/// ```
836///
837/// Signals that carry no Ultralytics metadata are refused with a typed
838/// error rather than a best-effort guess:
839///
840/// ```
841/// use std::collections::BTreeMap;
842///
843/// use edgefirst_decoder::{
844///     infer_ultralytics_schema, InferError, ModelSignals, ModelSource,
845/// };
846///
847/// let signals = ModelSignals {
848///     source: ModelSource::Onnx,
849///     inputs: Vec::new(),
850///     outputs: Vec::new(),
851///     metadata: BTreeMap::new(),
852/// };
853///
854/// assert!(matches!(
855///     infer_ultralytics_schema(&signals),
856///     Err(InferError::NotUltralytics(_))
857/// ));
858/// ```
859pub fn infer_ultralytics_schema(signals: &ModelSignals) -> Result<InferredSchema, InferError> {
860    let meta = UltralyticsMeta::from_metadata(&signals.metadata)?;
861
862    let proto = signals.outputs.iter().find(|o| o.shape.len() == 4);
863    match (meta.task, proto.is_some()) {
864        (Some(Task::Segment), false) => {
865            return Err(InferError::UnsupportedLayout(
866                "segment task but no proto tensor".into(),
867            ));
868        }
869        (Some(Task::Detect), true) => {
870            return Err(InferError::UnsupportedLayout(
871                "detect task but proto tensor present".into(),
872            ));
873        }
874        _ => {}
875    }
876    let is_segment = meta.task == Some(Task::Segment) || proto.is_some();
877
878    let input = signals
879        .inputs
880        .iter()
881        .find(|t| t.shape.len() == 4)
882        .ok_or_else(|| {
883            InferError::UnsupportedLayout(format!(
884                "no rank-4 model input found; inputs seen: {:?}",
885                signals.inputs.iter().map(|t| &t.shape).collect::<Vec<_>>()
886            ))
887        })?;
888    let (h, w, input_dshape) = classify_input_layout(input)?;
889
890    let expected_anchors = (h / 8) * (w / 8) + (h / 16) * (w / 16) + (h / 32) * (w / 32);
891    let nc = meta.names.len();
892
893    // Segmentation detection features carry `k` extra mask coefficients
894    // (`4 + nc + k`); the end-to-end feature width grows by the same `k`
895    // (`6 + k`). `k` comes from the proto tensor itself (rule 1), never
896    // hardcoded, so both widths fall back to the plain detection case when
897    // there's no proto.
898    // `is_segment` is already true whenever a proto is present (the two
899    // are cross-checked above), so the proto's presence alone decides here.
900    let (k, proto_dshape) = match proto {
901        Some(proto) => {
902            let (k, dshape) = classify_proto(proto, h, w)?;
903            (k, Some(dshape))
904        }
905        None => (0, None),
906    };
907    let feat = 4 + nc + k;
908    let e2e_feat = 6 + k;
909
910    let rank3_outputs: Vec<&TensorInfo> = signals
911        .outputs
912        .iter()
913        .filter(|o| o.shape.len() == 3)
914        .collect();
915
916    // The `end2end` metadata flag is the primary end-to-end signal (present
917    // on every real export). The shape-based e2e fallback applies only
918    // when the flag is absent (stripped metadata).
919    let (out, dshape, version) = match meta.end2end {
920        Some(true) => {
921            let (out, n) = find_e2e_candidate(&rank3_outputs, expected_anchors, e2e_feat)?
922                .ok_or_else(|| {
923                    InferError::UnsupportedLayout(format!(
924                        "metadata declares end2end=true but no output matches the YOLO26 \
925                         end-to-end layout [1, N<={expected_anchors}, {e2e_feat}]; \
926                         outputs seen: {:?}",
927                        rank3_outputs.iter().map(|o| &o.shape).collect::<Vec<_>>()
928                    ))
929                })?;
930            (
931                out,
932                vec![
933                    (DimName::Batch, 1),
934                    (DimName::NumBoxes, n),
935                    (DimName::NumFeatures, e2e_feat),
936                ],
937                DecoderVersion::Yolo26,
938            )
939        }
940        Some(false) => {
941            let (out, dshape) = classify_pre_nms(&rank3_outputs, expected_anchors, feat, nc)?;
942            (out, dshape, DecoderVersion::Yolov8)
943        }
944        None => match classify_pre_nms(&rank3_outputs, expected_anchors, feat, nc) {
945            Ok((out, dshape)) => (out, dshape, DecoderVersion::Yolov8),
946            Err(err @ InferError::ClassCountMismatch { .. }) => return Err(err),
947            Err(_) => {
948                let (out, n) = find_e2e_candidate(&rank3_outputs, expected_anchors, e2e_feat)?
949                    .ok_or_else(|| {
950                        InferError::UnsupportedLayout(format!(
951                            "no output matches a known Ultralytics detection layout \
952                             (pre-NMS or end-to-end); outputs seen: {:?}",
953                            rank3_outputs.iter().map(|o| &o.shape).collect::<Vec<_>>()
954                        ))
955                    })?;
956                (
957                    out,
958                    vec![
959                        (DimName::Batch, 1),
960                        (DimName::NumBoxes, n),
961                        (DimName::NumFeatures, e2e_feat),
962                    ],
963                    DecoderVersion::Yolo26,
964                )
965            }
966        },
967    };
968
969    check_quantized_boundary(out)?;
970    if let Some(proto) = proto {
971        check_quantized_boundary(proto)?;
972    }
973
974    // Box normalization is the one field shape cannot reveal -- it follows
975    // the exporter. ONNX and TFLite are pinned by runtime magnitude
976    // measurement (see testdata/infer/NOTES.md answer 5: 637.25 px vs
977    // 0.9957 on the same image); CoreML resolves alongside ONNX on both
978    // bases. Source-tracing the Ultralytics exporter shows the `1/w`
979    // normalize lives only in `IOSDetectModel`, used solely on the
980    // `nms=True` path, so a plain no-NMS CoreML export passes the raw model
981    // through pixel-space exactly as ONNX does; the same runtime check run
982    // against a matched fp16 ONNX pair agrees, with x reaching 640.25 on a
983    // 640-pixel input and every coordinate statistic matching within half a
984    // pixel -- fp16 rounding, where a convention difference would be a
985    // factor of 640 (see testdata/infer/NOTES.md for both, and the captured
986    // fixture). Guessing it wrong scales every box by the input size, which
987    // is why `Other` is refused rather than defaulted: everywhere else this
988    // module errors on ambiguity, and this is the field whose corruption
989    // `tests/infer_builder.rs` exists to pin.
990    let normalized = match signals.source {
991        ModelSource::TfLite => true,
992        ModelSource::Onnx | ModelSource::CoreMl => false,
993        ModelSource::Other => {
994            return Err(InferError::UnknownBoxConvention);
995        }
996    };
997    let det = LogicalOutput {
998        name: Some(out.name.clone()),
999        // `Detections` (plural) is the schema vocabulary's fully-decoded
1000        // post-NMS type; `Detection` is the anchor-grid output that still
1001        // needs decoding. Both collapse to the same legacy path today
1002        // (schema.rs `logical_to_legacy`), but this JSON now leaves the
1003        // process through the C and Python bindings, so it has to name the
1004        // tensor it actually describes.
1005        type_: Some(match version {
1006            DecoderVersion::Yolo26 => LogicalType::Detections,
1007            _ => LogicalType::Detection,
1008        }),
1009        shape: out.shape.clone(),
1010        dshape,
1011        decoder: Some(DecoderKind::Ultralytics),
1012        encoding: Some(BoxEncoding::Direct), // graph already decoded DFL
1013        score_format: Some(ScoreFormat::PerClass),
1014        normalized: Some(normalized),
1015        anchors: None,
1016        stride: None,
1017        dtype: Some(out.dtype),
1018        quantization: out.quantization.clone(),
1019        outputs: Vec::new(),
1020        activation_applied: None,
1021        activation_required: None,
1022    };
1023
1024    let mut outputs = vec![det];
1025    if let (Some(proto), Some(proto_dshape)) = (proto, proto_dshape) {
1026        outputs.push(LogicalOutput {
1027            name: Some(proto.name.clone()),
1028            type_: Some(LogicalType::Protos),
1029            shape: proto.shape.clone(),
1030            dshape: proto_dshape,
1031            decoder: Some(DecoderKind::Ultralytics),
1032            dtype: Some(proto.dtype),
1033            quantization: proto.quantization.clone(),
1034            ..default_logical()
1035        });
1036    }
1037
1038    let schema = SchemaV2 {
1039        schema_version: 2,
1040        input: Some(InputSpec {
1041            shape: input.shape.clone(),
1042            dshape: input_dshape,
1043            cameraadaptor: Some("rgb".into()),
1044        }),
1045        outputs,
1046        // Ultralytics runs NMS with `agnostic=False`, i.e. class-aware; stated
1047        // explicitly so the schema describes the model rather than relying on
1048        // the builder's fallback. An explicit `.with_nms(..)` still overrides.
1049        //
1050        // YOLO26 end-to-end heads do their NMS in-graph, so they carry none:
1051        // this field describes what the *decoder* must add.
1052        nms: match version {
1053            DecoderVersion::Yolo26 => None,
1054            _ => Some(NmsMode::ClassAware),
1055        },
1056        nms_multi_label: None,
1057        decoder_version: Some(version),
1058    };
1059
1060    let version_label = if version == DecoderVersion::Yolo26 {
1061        "YOLO26"
1062    } else {
1063        "YOLOv8/11"
1064    };
1065    let task_label = if is_segment { "segment" } else { "detect" };
1066    let description = format!("Ultralytics {version_label} {task_label}, {nc} classes");
1067
1068    Ok(InferredSchema {
1069        schema,
1070        labels: meta.names,
1071        description,
1072    })
1073}
1074
1075#[cfg(test)]
1076mod tests {
1077    use super::*;
1078
1079    /// Root of the `testdata/infer` fixture directory.
1080    ///
1081    /// `EDGEFIRST_TESTDATA_DIR` wins when set -- `scripts/on-target-test.sh`
1082    /// and CI both mirror `crates/decoder/testdata/infer` into
1083    /// `$EDGEFIRST_TESTDATA_DIR/infer` (see `on-target-test.sh`'s testdata
1084    /// sync and `.github/workflows/test.yml`'s "Upload testdata as
1085    /// artifact" step). The manifest-relative fallback is baked in at
1086    /// **compile** time, so a cross-compiled binary carries the build
1087    /// host's absolute path and can never find fixtures on a different
1088    /// target -- same reasoning as `decoder_from_edgefirst_json.rs`'s
1089    /// `testdata_root()`.
1090    fn infer_fixture_dir() -> std::path::PathBuf {
1091        std::env::var("EDGEFIRST_TESTDATA_DIR")
1092            .map(std::path::PathBuf::from)
1093            .unwrap_or_else(|_| {
1094                std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata")
1095            })
1096            .join("infer")
1097    }
1098
1099    #[test]
1100    fn parse_names_python_dict_repr() {
1101        let s = "{0: 'person', 1: 'bicycle', 2: \"fire hydrant\"}";
1102        assert_eq!(
1103            parse_names(s).unwrap(),
1104            vec!["person", "bicycle", "fire hydrant"]
1105        );
1106    }
1107
1108    #[test]
1109    fn parse_names_json_object() {
1110        let s = r#"{"0": "person", "1": "bicycle"}"#;
1111        assert_eq!(parse_names(s).unwrap(), vec!["person", "bicycle"]);
1112    }
1113
1114    #[test]
1115    fn parse_names_orders_by_index_not_insertion() {
1116        let s = "{1: 'b', 0: 'a'}";
1117        assert_eq!(parse_names(s).unwrap(), vec!["a", "b"]);
1118    }
1119
1120    #[test]
1121    fn parse_names_rejects_garbage() {
1122        assert!(matches!(
1123            parse_names("not a dict"),
1124            Err(InferError::BadNames(_))
1125        ));
1126    }
1127
1128    #[test]
1129    fn parse_names_rejects_non_contiguous_indices() {
1130        // Python dict-repr form: index 1 is missing.
1131        match parse_names("{0: 'a', 2: 'b'}") {
1132            Err(InferError::BadNames(msg)) => {
1133                assert!(
1134                    msg.contains("non-contiguous"),
1135                    "unexpected BadNames message: {msg}"
1136                );
1137            }
1138            other => panic!("expected BadNames, got {other:?}"),
1139        }
1140
1141        // JSON-object form: same gap.
1142        match parse_names(r#"{"0": "a", "2": "b"}"#) {
1143            Err(InferError::BadNames(msg)) => {
1144                assert!(
1145                    msg.contains("non-contiguous"),
1146                    "unexpected BadNames message: {msg}"
1147                );
1148            }
1149            other => panic!("expected BadNames, got {other:?}"),
1150        }
1151    }
1152
1153    #[test]
1154    fn meta_from_onnx_props() {
1155        let mut m = BTreeMap::new();
1156        m.insert("names".into(), "{0: 'person'}".into());
1157        m.insert("task".into(), "detect".into());
1158        let u = UltralyticsMeta::from_metadata(&m).unwrap();
1159        assert_eq!(u.names, vec!["person"]);
1160        assert_eq!(u.task, Some(Task::Detect));
1161    }
1162
1163    #[test]
1164    fn meta_task_pose_rejected() {
1165        let mut m = BTreeMap::new();
1166        m.insert("names".into(), "{0: 'person'}".into());
1167        m.insert("task".into(), "pose".into());
1168        assert!(matches!(
1169            UltralyticsMeta::from_metadata(&m),
1170            Err(InferError::UnsupportedTask(_))
1171        ));
1172    }
1173
1174    #[test]
1175    fn meta_missing_names_is_not_ultralytics() {
1176        assert!(matches!(
1177            UltralyticsMeta::from_metadata(&BTreeMap::new()),
1178            Err(InferError::NotUltralytics(_))
1179        ));
1180    }
1181
1182    #[test]
1183    fn meta_end2end_true_or_false_from_onnx_flat_props() {
1184        let mut m = BTreeMap::new();
1185        m.insert("names".into(), "{0: 'person'}".into());
1186        m.insert("end2end".into(), "True".into());
1187        assert_eq!(
1188            UltralyticsMeta::from_metadata(&m).unwrap().end2end,
1189            Some(true)
1190        );
1191
1192        m.insert("end2end".into(), "False".into());
1193        assert_eq!(
1194            UltralyticsMeta::from_metadata(&m).unwrap().end2end,
1195            Some(false)
1196        );
1197    }
1198
1199    /// Loads a captured Task-0 fixture's `metadata` map, exactly as
1200    /// `ModelSignals::metadata` would carry it: raw string key/values,
1201    /// regardless of whether the source was ONNX (flat props) or TFLite
1202    /// (single `metadata.json` envelope entry).
1203    fn load_fixture_metadata(name: &str) -> BTreeMap<String, String> {
1204        let path = infer_fixture_dir().join(format!("{name}.signals.json"));
1205        let content = std::fs::read_to_string(&path)
1206            .unwrap_or_else(|e| panic!("failed to read fixture {}: {e}", path.display()));
1207        let json: serde_json::Value =
1208            serde_json::from_str(&content).expect("fixture is valid JSON");
1209        json["metadata"]
1210            .as_object()
1211            .expect("fixture has a metadata object")
1212            .iter()
1213            .map(|(k, v)| {
1214                (
1215                    k.clone(),
1216                    v.as_str().expect("metadata value is a string").to_string(),
1217                )
1218            })
1219            .collect()
1220    }
1221
1222    #[test]
1223    fn meta_from_real_tflite_metadata_json_envelope() {
1224        let meta = load_fixture_metadata("yolov8n_float32");
1225        let u = UltralyticsMeta::from_metadata(&meta).unwrap();
1226        assert_eq!(u.names.len(), 80);
1227        assert_eq!(u.task, Some(Task::Detect));
1228    }
1229
1230    #[test]
1231    fn meta_end2end_true_on_real_yolo26_tflite_fixture() {
1232        let meta = load_fixture_metadata("yolo26n_float32");
1233        let u = UltralyticsMeta::from_metadata(&meta).unwrap();
1234        assert_eq!(u.end2end, Some(true));
1235    }
1236
1237    #[test]
1238    fn meta_end2end_false_on_real_yolov8_onnx_fixture() {
1239        let meta = load_fixture_metadata("yolov8n");
1240        let u = UltralyticsMeta::from_metadata(&meta).unwrap();
1241        assert_eq!(u.end2end, Some(false));
1242    }
1243
1244    /// Loads a captured Task-0 fixture into `ModelSignals`, exactly as the
1245    /// inference runtime would report it: tensor names/shapes/dtypes plus
1246    /// the raw metadata map.
1247    fn signals_from_fixture(name: &str) -> ModelSignals {
1248        let path = infer_fixture_dir().join(format!("{name}.signals.json"));
1249        let content = std::fs::read_to_string(&path)
1250            .unwrap_or_else(|e| panic!("failed to read fixture {}: {e}", path.display()));
1251        let json: serde_json::Value =
1252            serde_json::from_str(&content).expect("fixture is valid JSON");
1253
1254        let source = match json["source"]
1255            .as_str()
1256            .expect("fixture `source` is a string")
1257        {
1258            "onnx" => ModelSource::Onnx,
1259            "tflite" => ModelSource::TfLite,
1260            "coreml" => ModelSource::CoreMl,
1261            other => panic!("fixture {name}: unknown source `{other}`"),
1262        };
1263
1264        fn parse_dtype(s: &str) -> DType {
1265            match s {
1266                "float32" => DType::Float32,
1267                "float16" => DType::Float16,
1268                "int8" => DType::Int8,
1269                "uint8" => DType::Uint8,
1270                "int16" => DType::Int16,
1271                "uint16" => DType::Uint16,
1272                "int32" => DType::Int32,
1273                "uint32" => DType::Uint32,
1274                other => panic!("unknown dtype `{other}`"),
1275            }
1276        }
1277
1278        fn parse_tensor(v: &serde_json::Value) -> TensorInfo {
1279            TensorInfo {
1280                name: v["name"]
1281                    .as_str()
1282                    .expect("tensor `name` is a string")
1283                    .to_string(),
1284                shape: v["shape"]
1285                    .as_array()
1286                    .expect("tensor `shape` is an array")
1287                    .iter()
1288                    .map(|d| d.as_u64().expect("shape dim is a number") as usize)
1289                    .collect(),
1290                dtype: parse_dtype(v["dtype"].as_str().expect("tensor `dtype` is a string")),
1291                // Every captured fixture reports `"quantization": null`
1292                // (see testdata/infer/NOTES.md); real quantized signals
1293                // are out of scope for this task.
1294                quantization: None,
1295            }
1296        }
1297
1298        let inputs = json["inputs"]
1299            .as_array()
1300            .expect("fixture `inputs` is an array")
1301            .iter()
1302            .map(parse_tensor)
1303            .collect();
1304        let outputs = json["outputs"]
1305            .as_array()
1306            .expect("fixture `outputs` is an array")
1307            .iter()
1308            .map(parse_tensor)
1309            .collect();
1310        let metadata = json["metadata"]
1311            .as_object()
1312            .expect("fixture `metadata` is an object")
1313            .iter()
1314            .map(|(k, v)| {
1315                (
1316                    k.clone(),
1317                    v.as_str().expect("metadata value is a string").to_string(),
1318                )
1319            })
1320            .collect();
1321
1322        ModelSignals {
1323            source,
1324            inputs,
1325            outputs,
1326            metadata,
1327        }
1328    }
1329
1330    /// Builds a synthetic Ultralytics-style metadata map (Python dict-repr
1331    /// `names`, `task`, `end2end`) for hand-built layout tests that don't
1332    /// need a full captured fixture.
1333    fn synthetic_metadata(nc: usize, task: &str, end2end: &str) -> BTreeMap<String, String> {
1334        let names = (0..nc)
1335            .map(|i| format!("{i}: 'c{i}'"))
1336            .collect::<Vec<_>>()
1337            .join(", ");
1338        let mut m = BTreeMap::new();
1339        m.insert("names".into(), format!("{{{names}}}"));
1340        m.insert("task".into(), task.into());
1341        m.insert("end2end".into(), end2end.into());
1342        m
1343    }
1344
1345    #[test]
1346    fn infer_transposed_det_layout_classified() {
1347        // Anchors-first layout ([1, A, 4+nc]), as produced by older
1348        // Ultralytics TFLite exporters. Rule 4 must still classify this
1349        // correctly from tensor shape alone, and the builder's dshape-order
1350        // e2e-inference pitfall must be guarded by an explicit
1351        // decoder_version.
1352        let s = ModelSignals {
1353            source: ModelSource::TfLite,
1354            inputs: vec![TensorInfo {
1355                name: "images".into(),
1356                shape: vec![1, 640, 640, 3],
1357                dtype: DType::Float32,
1358                quantization: None,
1359            }],
1360            outputs: vec![TensorInfo {
1361                name: "output0".into(),
1362                shape: vec![1, 8400, 84],
1363                dtype: DType::Float32,
1364                quantization: None,
1365            }],
1366            metadata: synthetic_metadata(80, "detect", "False"),
1367        };
1368        let r = infer_ultralytics_schema(&s).unwrap();
1369        let o = &r.schema.outputs[0];
1370        assert_eq!(
1371            o.dshape,
1372            vec![
1373                (DimName::Batch, 1),
1374                (DimName::NumBoxes, 8400),
1375                (DimName::NumFeatures, 84),
1376            ]
1377        );
1378        assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolov8));
1379    }
1380
1381    #[test]
1382    fn infer_yolo26n_onnx_end_to_end() {
1383        let s = signals_from_fixture("yolo26n");
1384        let r = infer_ultralytics_schema(&s).unwrap();
1385        assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolo26));
1386        assert_eq!(r.schema.outputs[0].shape[2], 6);
1387    }
1388
1389    #[test]
1390    fn infer_class_count_mismatch_rejected() {
1391        let mut s = signals_from_fixture("yolov8n");
1392        s.metadata.insert("names".into(), "{0: 'only-one'}".into()); // nc=1, f=84
1393        assert!(matches!(
1394            infer_ultralytics_schema(&s),
1395            Err(InferError::ClassCountMismatch {
1396                expected: 1,
1397                found: 80
1398            })
1399        ));
1400    }
1401
1402    #[test]
1403    fn infer_seg_class_count_mismatch_rejected() {
1404        // Covers the generalized ClassCountMismatch math for segmentation,
1405        // where the non-class base width is `4 + k` (proto channels), not
1406        // the plain-detection `4`. Real yolov8n-seg det tensor is
1407        // [1, 116, 8400] (features-first, feat = 4+80+32); truncating
1408        // `names` to 1 class makes the expected feat 4+1+32=37, so the
1409        // real 116-feature tensor mismatches with found = 116-(37-1) = 80
1410        // — the true class count the features imply, unaffected by the
1411        // metadata truncation.
1412        let mut s = signals_from_fixture("yolov8n-seg");
1413        s.metadata.insert("names".into(), "{0: 'only-one'}".into()); // nc=1
1414        assert!(matches!(
1415            infer_ultralytics_schema(&s),
1416            Err(InferError::ClassCountMismatch {
1417                expected: 1,
1418                found: 80
1419            })
1420        ));
1421    }
1422
1423    #[test]
1424    fn infer_end2end_flag_shape_conflict_rejected() {
1425        // The end2end flag is the primary signal: a real yolov8n ONNX
1426        // export with its pre-NMS [1, 84, 8400] output, but with `end2end`
1427        // forced to "True", must never be guessed into an end-to-end
1428        // classification — flag/shape disagreement is always an error.
1429        let mut s = signals_from_fixture("yolov8n");
1430        s.metadata.insert("end2end".into(), "True".into());
1431        assert!(matches!(
1432            infer_ultralytics_schema(&s),
1433            Err(InferError::UnsupportedLayout(_))
1434        ));
1435    }
1436
1437    #[test]
1438    fn infer_yolov8n_seg_onnx() {
1439        let r = infer_ultralytics_schema(&signals_from_fixture("yolov8n-seg")).unwrap();
1440        assert_eq!(r.schema.outputs.len(), 2);
1441        let proto = r
1442            .schema
1443            .outputs
1444            .iter()
1445            .find(|o| o.type_ == Some(LogicalType::Protos))
1446            .unwrap();
1447        assert_eq!(proto.dshape[1].0, DimName::NumProtos); // NCHW
1448        let det = r
1449            .schema
1450            .outputs
1451            .iter()
1452            .find(|o| o.type_ == Some(LogicalType::Detection))
1453            .unwrap();
1454        assert!(det.shape.contains(&116)); // 4+80+32
1455    }
1456
1457    #[test]
1458    fn infer_yolov8n_seg_tflite_proto_nchw() {
1459        // The real yolov8n-seg_float32 TFLite export's proto is
1460        // [1, 32, 160, 160], NCHW-ordered — same as its ONNX counterpart.
1461        // An earlier draft of this test assumed TFLite seg exports carry a
1462        // NHWC-transposed proto; no captured fixture confirms that (see
1463        // infer_nhwc_proto_layout_classified for the synthetic NHWC coverage
1464        // the shape-driven classifier still supports).
1465        let r = infer_ultralytics_schema(&signals_from_fixture("yolov8n-seg_float32")).unwrap();
1466        let proto = r
1467            .schema
1468            .outputs
1469            .iter()
1470            .find(|o| o.type_ == Some(LogicalType::Protos))
1471            .unwrap();
1472        assert_eq!(proto.dshape[1].0, DimName::NumProtos); // NCHW
1473        assert_eq!(proto.dshape[1].1, 32);
1474    }
1475
1476    #[test]
1477    fn infer_yolo26n_seg_end_to_end() {
1478        let r = infer_ultralytics_schema(&signals_from_fixture("yolo26n-seg")).unwrap();
1479        assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolo26));
1480        assert_eq!(r.schema.outputs.len(), 2);
1481    }
1482
1483    #[test]
1484    fn infer_nhwc_proto_layout_classified() {
1485        // Hand-built signals: TFLite source, NHWC input, features-first det
1486        // tensor (4+80+32=116), NHWC proto ([1, 160, 160, 32]) — the other
1487        // valid proto channel ordering rule 1 must also classify correctly.
1488        let s = ModelSignals {
1489            source: ModelSource::TfLite,
1490            inputs: vec![TensorInfo {
1491                name: "images".into(),
1492                shape: vec![1, 640, 640, 3],
1493                dtype: DType::Float32,
1494                quantization: None,
1495            }],
1496            outputs: vec![
1497                TensorInfo {
1498                    name: "output0".into(),
1499                    shape: vec![1, 116, 8400],
1500                    dtype: DType::Float32,
1501                    quantization: None,
1502                },
1503                TensorInfo {
1504                    name: "output1".into(),
1505                    shape: vec![1, 160, 160, 32],
1506                    dtype: DType::Float32,
1507                    quantization: None,
1508                },
1509            ],
1510            metadata: synthetic_metadata(80, "segment", "False"),
1511        };
1512        let r = infer_ultralytics_schema(&s).unwrap();
1513        let proto = r
1514            .schema
1515            .outputs
1516            .iter()
1517            .find(|o| o.type_ == Some(LogicalType::Protos))
1518            .unwrap();
1519        assert_eq!(proto.dshape[3].0, DimName::NumProtos); // NHWC
1520        assert_eq!(proto.dshape[3].1, 32);
1521    }
1522
1523    /// The proto output is built field by field over `default_logical()`, so
1524    /// a dropped field reads as `None` rather than failing to compile. The
1525    /// decoder needs every one of these to dequantize and route the tensor.
1526    #[test]
1527    fn infer_proto_output_carries_its_name_decoder_dtype_and_quantization() {
1528        let quant = Quantization {
1529            scale: vec![0.0125],
1530            zero_point: Some(vec![-7]),
1531            axis: None,
1532            dtype: Some(DType::Int8),
1533        };
1534        let s = ModelSignals {
1535            source: ModelSource::TfLite,
1536            inputs: vec![TensorInfo {
1537                name: "images".into(),
1538                shape: vec![1, 640, 640, 3],
1539                dtype: DType::Int8,
1540                quantization: None,
1541            }],
1542            outputs: vec![
1543                TensorInfo {
1544                    name: "output0".into(),
1545                    shape: vec![1, 116, 8400],
1546                    dtype: DType::Int8,
1547                    quantization: Some(Quantization {
1548                        scale: vec![0.003_921_6],
1549                        zero_point: Some(vec![0]),
1550                        axis: None,
1551                        dtype: Some(DType::Int8),
1552                    }),
1553                },
1554                TensorInfo {
1555                    name: "protos".into(),
1556                    shape: vec![1, 160, 160, 32],
1557                    dtype: DType::Int8,
1558                    quantization: Some(quant.clone()),
1559                },
1560            ],
1561            metadata: synthetic_metadata(80, "segment", "False"),
1562        };
1563        let r = infer_ultralytics_schema(&s).unwrap();
1564        let proto = r
1565            .schema
1566            .outputs
1567            .iter()
1568            .find(|o| o.type_ == Some(LogicalType::Protos))
1569            .unwrap();
1570        assert_eq!(proto.name.as_deref(), Some("protos"));
1571        assert_eq!(proto.decoder, Some(DecoderKind::Ultralytics));
1572        assert_eq!(proto.dtype, Some(DType::Int8));
1573        assert_eq!(proto.quantization.as_ref(), Some(&quant));
1574    }
1575
1576    /// A proto tensor makes the model a segmentation model whether or not the
1577    /// metadata says `task: segment`. Requiring both would silently decode a
1578    /// segmentation export as plain detection.
1579    #[test]
1580    fn infer_treats_a_proto_tensor_without_a_task_as_segmentation() {
1581        let mut metadata = synthetic_metadata(80, "segment", "False");
1582        metadata.remove("task");
1583        let s = ModelSignals {
1584            source: ModelSource::TfLite,
1585            inputs: vec![TensorInfo {
1586                name: "images".into(),
1587                shape: vec![1, 640, 640, 3],
1588                dtype: DType::Float32,
1589                quantization: None,
1590            }],
1591            outputs: vec![
1592                TensorInfo {
1593                    name: "output0".into(),
1594                    shape: vec![1, 116, 8400],
1595                    dtype: DType::Float32,
1596                    quantization: None,
1597                },
1598                TensorInfo {
1599                    name: "output1".into(),
1600                    shape: vec![1, 160, 160, 32],
1601                    dtype: DType::Float32,
1602                    quantization: None,
1603                },
1604            ],
1605            metadata,
1606        };
1607        let r = infer_ultralytics_schema(&s).unwrap();
1608        assert!(
1609            r.schema
1610                .outputs
1611                .iter()
1612                .any(|o| o.type_ == Some(LogicalType::Protos)),
1613            "proto tensor present but no protos output was emitted"
1614        );
1615        // The task the metadata did not state is the one reported back, which
1616        // is the only place `is_segment` is observable.
1617        assert!(r.description.contains("segment"), "{}", r.description);
1618    }
1619
1620    fn proto_tensor(shape: Vec<usize>) -> TensorInfo {
1621        TensorInfo {
1622            name: "protos".into(),
1623            shape,
1624            dtype: DType::Float32,
1625            quantization: None,
1626        }
1627    }
1628
1629    /// Both halves of the rank-and-batch guard have to reject on their own.
1630    /// Requiring both instead would index past the end of a rank-3 shape.
1631    #[test]
1632    fn classify_proto_rejects_wrong_rank_or_batch() {
1633        for shape in [vec![1, 32, 160], vec![2, 32, 160, 160], vec![1, 160, 160]] {
1634            let err = classify_proto(&proto_tensor(shape.clone()), 640, 640).unwrap_err();
1635            assert!(
1636                matches!(err, InferError::UnsupportedLayout(_)),
1637                "shape {shape:?} gave {err:?}"
1638            );
1639        }
1640    }
1641
1642    /// The NHWC branch needs both spatial dims to line up. Accepting either
1643    /// one would read the proto count out of a dimension that is not it.
1644    #[test]
1645    fn classify_proto_rejects_a_shape_matching_only_one_spatial_dim() {
1646        // h/4 = w/4 = 160. s[1] matches, s[2] does not.
1647        let err = classify_proto(&proto_tensor(vec![1, 160, 7, 9]), 640, 640).unwrap_err();
1648        assert!(matches!(err, InferError::UnsupportedLayout(_)), "{err:?}");
1649
1650        // The genuine NHWC shape still classifies, with the proto count last.
1651        let (k, dshape) = classify_proto(&proto_tensor(vec![1, 160, 160, 32]), 640, 640).unwrap();
1652        assert_eq!(k, 32);
1653        assert_eq!(dshape[3], (DimName::NumProtos, 32));
1654    }
1655
1656    /// `unquote` strips a matching pair only. A single quote on one end is an
1657    /// unterminated value, not a quoted one.
1658    #[test]
1659    fn unquote_requires_a_matching_pair_of_quotes() {
1660        assert_eq!(unquote("'abc'").as_deref(), Some("abc"));
1661        assert_eq!(unquote("\"abc\"").as_deref(), Some("abc"));
1662        assert_eq!(unquote("'abc"), None);
1663        assert_eq!(unquote("abc'"), None);
1664        assert_eq!(unquote("\"abc"), None);
1665        assert_eq!(unquote("abc\""), None);
1666        assert_eq!(unquote("abc"), None);
1667    }
1668
1669    /// A comma inside a quoted value is part of the value. Without the quote
1670    /// arm the scanner never enters quoted state and splits straight through
1671    /// it.
1672    #[test]
1673    fn split_top_level_commas_keeps_quoted_commas_together() {
1674        let parts = split_top_level_commas("a='x,y', b=2");
1675        assert_eq!(parts, vec!["a='x,y'".to_string(), " b=2".to_string()]);
1676
1677        let parts = split_top_level_commas("a=\"x,y\",b=\"p,q\"");
1678        assert_eq!(
1679            parts,
1680            vec!["a=\"x,y\"".to_string(), "b=\"p,q\"".to_string()]
1681        );
1682    }
1683
1684    /// `InferError` is what a rejection reports to the caller. A `Display`
1685    /// that writes nothing turns every diagnostic into an empty string.
1686    #[test]
1687    fn infer_error_display_states_the_reason() {
1688        let err = InferError::NotUltralytics("no names key".into());
1689        let shown = err.to_string();
1690        assert!(shown.contains("no names key"), "{shown}");
1691        assert!(shown.contains("Ultralytics"), "{shown}");
1692
1693        let err = InferError::UnsupportedLayout("rank-3 proto".into());
1694        assert!(err.to_string().contains("rank-3 proto"));
1695    }
1696
1697    #[test]
1698    fn infer_segment_task_without_proto_rejected() {
1699        let s = ModelSignals {
1700            source: ModelSource::Onnx,
1701            inputs: vec![TensorInfo {
1702                name: "images".into(),
1703                shape: vec![1, 3, 640, 640],
1704                dtype: DType::Float32,
1705                quantization: None,
1706            }],
1707            outputs: vec![TensorInfo {
1708                name: "output0".into(),
1709                shape: vec![1, 84, 8400],
1710                dtype: DType::Float32,
1711                quantization: None,
1712            }],
1713            metadata: synthetic_metadata(80, "segment", "False"),
1714        };
1715        assert!(matches!(
1716            infer_ultralytics_schema(&s),
1717            Err(InferError::UnsupportedLayout(_))
1718        ));
1719    }
1720
1721    #[test]
1722    fn infer_detect_task_with_proto_rejected() {
1723        let s = ModelSignals {
1724            source: ModelSource::Onnx,
1725            inputs: vec![TensorInfo {
1726                name: "images".into(),
1727                shape: vec![1, 3, 640, 640],
1728                dtype: DType::Float32,
1729                quantization: None,
1730            }],
1731            outputs: vec![
1732                TensorInfo {
1733                    name: "output0".into(),
1734                    shape: vec![1, 84, 8400],
1735                    dtype: DType::Float32,
1736                    quantization: None,
1737                },
1738                TensorInfo {
1739                    name: "output1".into(),
1740                    shape: vec![1, 32, 160, 160],
1741                    dtype: DType::Float32,
1742                    quantization: None,
1743                },
1744            ],
1745            metadata: synthetic_metadata(80, "detect", "False"),
1746        };
1747        assert!(matches!(
1748            infer_ultralytics_schema(&s),
1749            Err(InferError::UnsupportedLayout(_))
1750        ));
1751    }
1752
1753    #[test]
1754    fn infer_int8_tflite_classifies_like_float32() {
1755        // Real Ultralytics int8 TFLite exports expose float32 I/O with NO
1756        // boundary quantization — the int8 graph is wrapped in
1757        // dequant/quant ops (Task 0 finding). The classifier must treat
1758        // this fixture exactly like its float32 counterpart, not attempt
1759        // to recover quantization that isn't there.
1760        let r = infer_ultralytics_schema(&signals_from_fixture("yolov8n_int8")).unwrap();
1761        let det = &r.schema.outputs[0];
1762        assert_eq!(det.type_, Some(LogicalType::Detection));
1763        assert_eq!(det.dtype, Some(DType::Float32));
1764        assert_eq!(det.quantization, None);
1765        assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolov8));
1766        assert_eq!(det.normalized, Some(true));
1767    }
1768
1769    #[test]
1770    fn infer_quantized_boundary_carries_quantization() {
1771        // Hand-built signals for a model that DOES expose a quantized
1772        // boundary output (unlike any captured fixture) — the classifier
1773        // must pass `TensorInfo.quantization` through onto the schema's
1774        // detection output untouched.
1775        let quant = Quantization {
1776            scale: vec![0.02],
1777            zero_point: Some(vec![-5]),
1778            axis: None,
1779            dtype: Some(DType::Int8),
1780        };
1781        let s = ModelSignals {
1782            source: ModelSource::TfLite,
1783            inputs: vec![TensorInfo {
1784                name: "images".into(),
1785                shape: vec![1, 640, 640, 3],
1786                dtype: DType::Float32,
1787                quantization: None,
1788            }],
1789            outputs: vec![TensorInfo {
1790                name: "output0".into(),
1791                shape: vec![1, 84, 8400],
1792                dtype: DType::Int8,
1793                quantization: Some(quant.clone()),
1794            }],
1795            metadata: synthetic_metadata(80, "detect", "False"),
1796        };
1797        let r = infer_ultralytics_schema(&s).unwrap();
1798        let det = &r.schema.outputs[0];
1799        assert_eq!(det.dtype, Some(DType::Int8));
1800        assert_eq!(det.quantization, Some(quant));
1801    }
1802
1803    #[test]
1804    fn infer_rejects_batched_input() {
1805        // Every output layout here is batch-1, so a batched input would
1806        // emit an input dshape whose Batch disagrees with the detection
1807        // tensor beside it. Previously this succeeded and emitted
1808        // `(Batch, 4)`.
1809        let s = ModelSignals {
1810            source: ModelSource::Onnx,
1811            inputs: vec![TensorInfo {
1812                name: "images".into(),
1813                shape: vec![4, 3, 640, 640],
1814                dtype: DType::Float32,
1815                quantization: None,
1816            }],
1817            outputs: vec![TensorInfo {
1818                name: "output0".into(),
1819                shape: vec![1, 84, 8400],
1820                dtype: DType::Float32,
1821                quantization: None,
1822            }],
1823            metadata: synthetic_metadata(80, "detect", "False"),
1824        };
1825        match infer_ultralytics_schema(&s) {
1826            Err(InferError::UnsupportedLayout(msg)) => {
1827                assert!(msg.contains("batch 4"), "unexpected message: {msg}");
1828            }
1829            other => panic!("expected UnsupportedLayout, got {other:?}"),
1830        }
1831    }
1832
1833    #[test]
1834    fn infer_rejects_input_without_a_channel_dim() {
1835        let s = ModelSignals {
1836            source: ModelSource::Onnx,
1837            inputs: vec![TensorInfo {
1838                name: "images".into(),
1839                shape: vec![1, 4, 640, 640],
1840                dtype: DType::Float32,
1841                quantization: None,
1842            }],
1843            outputs: vec![TensorInfo {
1844                name: "output0".into(),
1845                shape: vec![1, 84, 8400],
1846                dtype: DType::Float32,
1847                quantization: None,
1848            }],
1849            metadata: synthetic_metadata(80, "detect", "False"),
1850        };
1851        match infer_ultralytics_schema(&s) {
1852            Err(InferError::UnsupportedLayout(msg)) => {
1853                assert!(msg.contains("channel dimension"), "unexpected: {msg}");
1854            }
1855            other => panic!("expected UnsupportedLayout, got {other:?}"),
1856        }
1857    }
1858
1859    #[test]
1860    fn infer_rejects_model_with_no_rank4_input() {
1861        let s = ModelSignals {
1862            source: ModelSource::Onnx,
1863            inputs: vec![TensorInfo {
1864                name: "images".into(),
1865                shape: vec![1, 3],
1866                dtype: DType::Float32,
1867                quantization: None,
1868            }],
1869            outputs: vec![TensorInfo {
1870                name: "output0".into(),
1871                shape: vec![1, 84, 8400],
1872                dtype: DType::Float32,
1873                quantization: None,
1874            }],
1875            metadata: synthetic_metadata(80, "detect", "False"),
1876        };
1877        match infer_ultralytics_schema(&s) {
1878            Err(InferError::UnsupportedLayout(msg)) => {
1879                assert!(msg.contains("no rank-4 model input"), "unexpected: {msg}");
1880            }
1881            other => panic!("expected UnsupportedLayout, got {other:?}"),
1882        }
1883    }
1884
1885    /// Builds detection signals for a 640x640 model with one rank-3 output.
1886    fn det_signals(nc: usize, out_shape: Vec<usize>, end2end: &str) -> ModelSignals {
1887        ModelSignals {
1888            source: ModelSource::Onnx,
1889            inputs: vec![TensorInfo {
1890                name: "images".into(),
1891                shape: vec![1, 3, 640, 640],
1892                dtype: DType::Float32,
1893                quantization: None,
1894            }],
1895            outputs: vec![TensorInfo {
1896                name: "output0".into(),
1897                shape: out_shape,
1898                dtype: DType::Float32,
1899                quantization: None,
1900            }],
1901            metadata: synthetic_metadata(nc, "detect", end2end),
1902        }
1903    }
1904
1905    #[test]
1906    fn infer_end_to_end_accepts_any_max_det_up_to_the_anchor_count() {
1907        // Ultralytics bakes max_det into the graph: the head's postprocess
1908        // is a TopK with k = min(max_det, anchors), so N is whatever the
1909        // export chose. The exporter clamps it to the anchor count
1910        // (exporter.py: `m.max_det = min(args.max_det, available)`), which
1911        // is the same quantity as `expected_anchors` here -- verified by
1912        // exporting yolo26n at max_det = 100/1000/2000/8400/10000, the last
1913        // two both yielding [1, 8400, 6].
1914        //
1915        // 300 is the default; 2000 is what a crowded-scene export uses and
1916        // was rejected outright before; 8400 == expected_anchors is what any
1917        // max_det >= anchors produces and is equally legitimate.
1918        for n in [1, 300, 2000, 8400] {
1919            let r = infer_ultralytics_schema(&det_signals(80, vec![1, n, 6], "True"))
1920                .unwrap_or_else(|e| panic!("max_det={n} must infer: {e}"));
1921            assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolo26));
1922            assert_eq!(r.schema.outputs[0].type_, Some(LogicalType::Detections));
1923            assert_eq!(
1924                r.schema.outputs[0].dshape,
1925                vec![
1926                    (DimName::Batch, 1),
1927                    (DimName::NumBoxes, n),
1928                    (DimName::NumFeatures, 6),
1929                ],
1930                "max_det={n}"
1931            );
1932            // End-to-end ran NMS in-graph, so the schema adds none.
1933            assert_eq!(r.schema.nms, None, "max_det={n}");
1934        }
1935    }
1936
1937    #[test]
1938    fn infer_end_to_end_rejects_more_rows_than_anchors() {
1939        // Upstream cannot emit this: TopK's k is clamped to the anchor
1940        // count, so a longer tensor is not an Ultralytics end-to-end output
1941        // and the bound stays a real check rather than a formality.
1942        match infer_ultralytics_schema(&det_signals(80, vec![1, 8401, 6], "True")) {
1943            Err(InferError::UnsupportedLayout(msg)) => {
1944                assert!(msg.contains("N<=8400"), "unexpected message: {msg}");
1945            }
1946            other => panic!("expected UnsupportedLayout, got {other:?}"),
1947        }
1948    }
1949
1950    #[test]
1951    fn infer_legacy_nms_model_is_unaffected_by_max_det() {
1952        // The pre-NMS (NMS-required) head has no max_det in its graph at
1953        // all: it emits every anchor, and the detection count is decided
1954        // later by the decoder's own NMS. So the schema must classify it by
1955        // the anchor count regardless, and must NOT acquire a NumBoxes
1956        // dimension that looks like an end-to-end row count.
1957        let r = infer_ultralytics_schema(&det_signals(80, vec![1, 84, 8400], "False")).unwrap();
1958        assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolov8));
1959        assert_eq!(r.schema.outputs[0].type_, Some(LogicalType::Detection));
1960        assert_eq!(
1961            r.schema.outputs[0].dshape,
1962            vec![
1963                (DimName::Batch, 1),
1964                (DimName::NumFeatures, 84),
1965                (DimName::NumBoxes, 8400),
1966            ]
1967        );
1968        // Class-aware NMS, matching Ultralytics' own agnostic=False. The
1969        // schema names the mode only; thresholds and max_det stay the
1970        // caller's, set on the builder per frame-rate/recall budget.
1971        assert_eq!(r.schema.nms, Some(NmsMode::ClassAware));
1972
1973        // Anchors-first is classified identically -- neither orientation
1974        // may be mistaken for an end-to-end [1, N, feat] tensor.
1975        let t = infer_ultralytics_schema(&det_signals(80, vec![1, 8400, 84], "False")).unwrap();
1976        assert_eq!(t.schema.decoder_version, Some(DecoderVersion::Yolov8));
1977        assert_eq!(
1978            t.schema.outputs[0].dshape,
1979            vec![
1980                (DimName::Batch, 1),
1981                (DimName::NumBoxes, 8400),
1982                (DimName::NumFeatures, 84),
1983            ]
1984        );
1985    }
1986
1987    #[test]
1988    fn infer_pre_nms_wins_when_a_shape_reads_as_both() {
1989        // With metadata stripped and nc == 2, feat == 4 + nc == 6 == the
1990        // end-to-end feature width, so [1, A, 6] is genuinely both layouts
1991        // at once. pre-NMS is tried first and wins; this is the one place a
1992        // guess remains, so it is pinned rather than left to drift.
1993        let mut m = synthetic_metadata(2, "detect", "False");
1994        m.remove("end2end");
1995        let s = ModelSignals {
1996            metadata: m,
1997            ..det_signals(2, vec![1, 8400, 6], "False")
1998        };
1999        let r = infer_ultralytics_schema(&s).unwrap();
2000        assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolov8));
2001    }
2002
2003    #[test]
2004    fn meta_empty_names_rejected() {
2005        // nc == 0 makes feat == 4, so a [1,4,A] output would infer a schema
2006        // the decoder then refuses ("Yolo num_features 4 must be > 4").
2007        let mut m = BTreeMap::new();
2008        m.insert("names".into(), "{}".into());
2009        match UltralyticsMeta::from_metadata(&m) {
2010            Err(InferError::BadNames(msg)) => assert!(msg.contains("empty"), "{msg}"),
2011            other => panic!("expected BadNames, got {other:?}"),
2012        }
2013    }
2014
2015    #[test]
2016    fn meta_malformed_end2end_is_an_error_not_an_absent_key() {
2017        // `None` means "key absent", which selects the shape-only fallback.
2018        // A corrupt value must not collapse into that -- the model is
2019        // actively claiming something, just not something parseable.
2020        let mut m = synthetic_metadata(80, "detect", "False");
2021        m.insert("end2end".into(), "yes".into());
2022        match UltralyticsMeta::from_metadata(&m) {
2023            Err(InferError::BadMetadata(msg)) => assert!(msg.contains("end2end"), "{msg}"),
2024            other => panic!("expected BadMetadata, got {other:?}"),
2025        }
2026
2027        // Absent is still fine, and still means "fall back to shapes".
2028        m.remove("end2end");
2029        assert_eq!(UltralyticsMeta::from_metadata(&m).unwrap().end2end, None);
2030    }
2031
2032    #[test]
2033    fn meta_foreign_vendor_rejected_but_missing_provenance_allowed() {
2034        // A `names` map alone is not an Ultralytics signature; another
2035        // exporter's model would otherwise be given Ultralytics box, score
2036        // and normalization conventions.
2037        let mut m = synthetic_metadata(80, "detect", "False");
2038        m.insert("author".into(), "SomeOtherVendor".into());
2039        assert!(matches!(
2040            UltralyticsMeta::from_metadata(&m),
2041            Err(InferError::NotUltralytics(_))
2042        ));
2043
2044        // Provenance that names Ultralytics passes, in either field.
2045        m.insert("author".into(), "Ultralytics".into());
2046        m.insert("docs".into(), "https://docs.ultralytics.com".into());
2047        assert!(UltralyticsMeta::from_metadata(&m).is_ok());
2048
2049        // Absence is not a contradiction: quantizers and format converters
2050        // routinely keep `names` and drop provenance.
2051        m.remove("author");
2052        m.remove("docs");
2053        assert!(UltralyticsMeta::from_metadata(&m).is_ok());
2054    }
2055
2056    #[test]
2057    fn infer_zero_row_end_to_end_rejected() {
2058        // [1, 0, 6] satisfies every other end-to-end condition but yields a
2059        // schema the decoder refuses for a zero dimension.
2060        assert!(matches!(
2061            infer_ultralytics_schema(&det_signals(80, vec![1, 0, 6], "True")),
2062            Err(InferError::UnsupportedLayout(_))
2063        ));
2064    }
2065
2066    #[test]
2067    fn infer_duplicate_matching_outputs_are_ambiguous() {
2068        let input = TensorInfo {
2069            name: "images".into(),
2070            shape: vec![1, 3, 640, 640],
2071            dtype: DType::Float32,
2072            quantization: None,
2073        };
2074        let t = |name: &str, shape: Vec<usize>| TensorInfo {
2075            name: name.into(),
2076            shape,
2077            dtype: DType::Float32,
2078            quantization: None,
2079        };
2080
2081        // Two identical pre-NMS detection tensors: nothing distinguishes
2082        // them but the order the runtime listed them in.
2083        let pre = ModelSignals {
2084            source: ModelSource::Onnx,
2085            inputs: vec![input.clone()],
2086            outputs: vec![t("a", vec![1, 84, 8400]), t("b", vec![1, 84, 8400])],
2087            metadata: synthetic_metadata(80, "detect", "False"),
2088        };
2089        match infer_ultralytics_schema(&pre) {
2090            Err(InferError::AmbiguousLayout(msg)) => {
2091                assert!(msg.contains("`a`") && msg.contains("`b`"), "{msg}");
2092            }
2093            other => panic!("expected AmbiguousLayout, got {other:?}"),
2094        }
2095
2096        // Same for two end-to-end candidates.
2097        let e2e = ModelSignals {
2098            source: ModelSource::Onnx,
2099            inputs: vec![input],
2100            outputs: vec![t("a", vec![1, 300, 6]), t("b", vec![1, 200, 6])],
2101            metadata: synthetic_metadata(80, "detect", "True"),
2102        };
2103        assert!(matches!(
2104            infer_ultralytics_schema(&e2e),
2105            Err(InferError::AmbiguousLayout(_))
2106        ));
2107    }
2108
2109    #[test]
2110    fn infer_malformed_per_tensor_quantization_rejected() {
2111        let build = |q: Quantization| ModelSignals {
2112            source: ModelSource::TfLite,
2113            inputs: vec![TensorInfo {
2114                name: "images".into(),
2115                shape: vec![1, 640, 640, 3],
2116                dtype: DType::Float32,
2117                quantization: None,
2118            }],
2119            outputs: vec![TensorInfo {
2120                name: "output0".into(),
2121                shape: vec![1, 84, 8400],
2122                dtype: DType::Int8,
2123                quantization: Some(q),
2124            }],
2125            metadata: synthetic_metadata(80, "detect", "False"),
2126        };
2127
2128        // No scale at all: previously emitted `{"scale": []}`, which the
2129        // decoder reads as a zero scale instead of refusing.
2130        match infer_ultralytics_schema(&build(Quantization {
2131            scale: vec![],
2132            zero_point: Some(vec![]),
2133            axis: None,
2134            dtype: Some(DType::Int8),
2135        })) {
2136            Err(InferError::UnsupportedQuantization(msg)) => {
2137                assert!(msg.contains("no scale"), "{msg}");
2138            }
2139            other => panic!("expected UnsupportedQuantization, got {other:?}"),
2140        }
2141
2142        // Zero points that do not pair with the scale were silently
2143        // truncated to the first element.
2144        match infer_ultralytics_schema(&build(Quantization {
2145            scale: vec![0.02],
2146            zero_point: Some(vec![-5, -7]),
2147            axis: None,
2148            dtype: Some(DType::Int8),
2149        })) {
2150            Err(InferError::UnsupportedQuantization(msg)) => {
2151                assert!(msg.contains("zero point"), "{msg}");
2152            }
2153            other => panic!("expected UnsupportedQuantization, got {other:?}"),
2154        }
2155
2156        // Symmetric quantization (no zero points) stays valid.
2157        assert!(infer_ultralytics_schema(&build(Quantization {
2158            scale: vec![0.02],
2159            zero_point: None,
2160            axis: None,
2161            dtype: Some(DType::Int8),
2162        }))
2163        .is_ok());
2164    }
2165
2166    #[test]
2167    fn infer_pre_nms_is_independent_of_output_order() {
2168        // An export may publish more than one anchor-shaped rank-3 output:
2169        // a `[1, 4, A]` box tensor beside the `[1, 4+nc, A]` detection
2170        // tensor, or a `[1, k, A]` mask-coefficient tensor. Scanning must
2171        // not stop at the first near-miss, or the result depends on the
2172        // order the runtime happened to report its outputs in.
2173        let det = TensorInfo {
2174            name: "output0".into(),
2175            shape: vec![1, 84, 8400],
2176            dtype: DType::Float32,
2177            quantization: None,
2178        };
2179        let aux = TensorInfo {
2180            name: "boxes".into(),
2181            shape: vec![1, 4, 8400],
2182            dtype: DType::Float32,
2183            quantization: None,
2184        };
2185        let build = |outputs: Vec<TensorInfo>| ModelSignals {
2186            source: ModelSource::Onnx,
2187            inputs: vec![TensorInfo {
2188                name: "images".into(),
2189                shape: vec![1, 3, 640, 640],
2190                dtype: DType::Float32,
2191                quantization: None,
2192            }],
2193            outputs,
2194            metadata: synthetic_metadata(80, "detect", "False"),
2195        };
2196
2197        let det_first = infer_ultralytics_schema(&build(vec![det.clone(), aux.clone()]))
2198            .expect("detection tensor first must classify");
2199        let aux_first = infer_ultralytics_schema(&build(vec![aux, det]))
2200            .expect("an auxiliary anchor-shaped tensor first must not shadow the detection tensor");
2201        assert_eq!(det_first.schema.outputs[0].name, Some("output0".into()));
2202        assert_eq!(aux_first.schema.outputs[0].name, Some("output0".into()));
2203        assert_eq!(det_first.schema, aux_first.schema);
2204    }
2205
2206    #[test]
2207    fn infer_class_count_mismatch_survives_a_full_scan() {
2208        // When nothing matches, the anchor-shaped near-miss is still the
2209        // more useful diagnostic than "no output matches the layout".
2210        let s = ModelSignals {
2211            source: ModelSource::Onnx,
2212            inputs: vec![TensorInfo {
2213                name: "images".into(),
2214                shape: vec![1, 3, 640, 640],
2215                dtype: DType::Float32,
2216                quantization: None,
2217            }],
2218            outputs: vec![TensorInfo {
2219                name: "output0".into(),
2220                shape: vec![1, 84, 8400],
2221                dtype: DType::Float32,
2222                quantization: None,
2223            }],
2224            // 10 classes would need feat == 14, not the 84 present.
2225            metadata: synthetic_metadata(10, "detect", "False"),
2226        };
2227        assert!(matches!(
2228            infer_ultralytics_schema(&s),
2229            Err(InferError::ClassCountMismatch {
2230                expected: 10,
2231                found: 80
2232            })
2233        ));
2234    }
2235
2236    /// What every captured fixture must infer to, asserted field by field.
2237    ///
2238    /// `yolo11n-seg` and `yolov8n-seg_int8` previously appeared only in
2239    /// `tests/infer_builder.rs`'s "did the builder accept it" loop, which
2240    /// passes for any schema that parses -- so the only v11 segmentation
2241    /// fixture and the only int8 *segmentation* fixture had no field-level
2242    /// coverage at all. A table keeps every fixture honest at once, and
2243    /// `every_fixture_is_asserted` below makes an unasserted one impossible
2244    /// to add.
2245    struct FixtureExpectation {
2246        name: &'static str,
2247        version: DecoderVersion,
2248        det_type: LogicalType,
2249        /// Detection tensor `dshape`, in physical order.
2250        det_dshape: &'static [(DimName, usize)],
2251        normalized: bool,
2252        nms: Option<NmsMode>,
2253        /// Proto channel count and spatial dims, for segmentation fixtures.
2254        proto: Option<(usize, usize, usize)>,
2255    }
2256
2257    const A: usize = 8400; // 640x640 anchors: 80² + 40² + 20²
2258    const NC: usize = 80;
2259
2260    fn fixture_expectations() -> Vec<FixtureExpectation> {
2261        use DimName::{Batch, NumBoxes, NumFeatures};
2262        // Every captured export is features-first ([1, 4+nc, A]); no real
2263        // exporter transposes to anchors-first, despite an early draft of
2264        // these tests assuming TFLite did.
2265        const DET: &[(DimName, usize)] = &[(Batch, 1), (NumFeatures, 4 + NC), (NumBoxes, A)];
2266        const SEG: &[(DimName, usize)] = &[(Batch, 1), (NumFeatures, 4 + NC + 32), (NumBoxes, A)];
2267        // End-to-end heads emit [1, max_det, 6] (+32 mask coefficients).
2268        const E2E: &[(DimName, usize)] = &[(Batch, 1), (NumBoxes, 300), (NumFeatures, 6)];
2269        const E2E_SEG: &[(DimName, usize)] = &[(Batch, 1), (NumBoxes, 300), (NumFeatures, 6 + 32)];
2270
2271        let pre_nms = |name, det_dshape, normalized, proto| FixtureExpectation {
2272            name,
2273            version: DecoderVersion::Yolov8,
2274            det_type: LogicalType::Detection,
2275            det_dshape,
2276            normalized,
2277            nms: Some(NmsMode::ClassAware),
2278            proto,
2279        };
2280        let e2e = |name, det_dshape, normalized, proto| FixtureExpectation {
2281            name,
2282            version: DecoderVersion::Yolo26,
2283            det_type: LogicalType::Detections,
2284            det_dshape,
2285            normalized,
2286            // End-to-end heads ran NMS in-graph; the schema adds none.
2287            nms: None,
2288            proto,
2289        };
2290        // Ultralytics ONNX exports are pixel-space, TFLite exports [0,1];
2291        // proto is (k, H/4, W/4).
2292        const P: Option<(usize, usize, usize)> = Some((32, 160, 160));
2293        vec![
2294            pre_nms("yolov8n", DET, false, None),
2295            pre_nms("yolo11n", DET, false, None),
2296            pre_nms("yolov8n-seg", SEG, false, P),
2297            pre_nms("yolo11n-seg", SEG, false, P),
2298            pre_nms("yolov8n_float32", DET, true, None),
2299            pre_nms("yolov8n_int8", DET, true, None),
2300            pre_nms("yolov8n-seg_float32", SEG, true, P),
2301            pre_nms("yolov8n-seg_int8", SEG, true, P),
2302            e2e("yolo26n", E2E, false, None),
2303            e2e("yolo26n-seg", E2E_SEG, false, P),
2304            e2e("yolo26n_float32", E2E, true, None),
2305            // Same network, different exporter default. The `yolo26n` row above
2306            // was captured from ultralytics 8.4.137, whose `nms` arg defaulted to
2307            // `False`; `exporter.py`'s `model.end2end = self.args.nms is False`
2308            // then selected the one2one (NMS-free, [1, 300, 6]) head. In 8.4.153
2309            // that default is `None`, so the same expression is false and the
2310            // one2many anchor-grid head ships instead -- hence a Yolov8-shaped
2311            // pre-NMS row for a YOLO26 checkpoint. Both heads live in the weights.
2312            pre_nms("yolo26n_coreml", DET, false, None),
2313        ]
2314    }
2315
2316    #[test]
2317    fn every_captured_fixture_infers_the_expected_schema() {
2318        for want in fixture_expectations() {
2319            let name = want.name;
2320            let r = infer_ultralytics_schema(&signals_from_fixture(name))
2321                .unwrap_or_else(|e| panic!("{name}: inference failed: {e}"));
2322
2323            assert_eq!(r.labels.len(), NC, "{name}: class count");
2324            assert_eq!(
2325                r.labels[0], "person",
2326                "{name}: labels must be index-ordered"
2327            );
2328            assert_eq!(r.labels[NC - 1], "toothbrush", "{name}: labels index order");
2329            assert_eq!(
2330                r.schema.decoder_version,
2331                Some(want.version),
2332                "{name}: version"
2333            );
2334            assert_eq!(r.schema.nms, want.nms, "{name}: nms mode");
2335
2336            let det = &r.schema.outputs[0];
2337            assert_eq!(det.type_, Some(want.det_type), "{name}: detection type");
2338            assert_eq!(det.dshape, want.det_dshape, "{name}: detection dshape");
2339            assert_eq!(det.normalized, Some(want.normalized), "{name}: normalized");
2340            assert_eq!(
2341                det.decoder,
2342                Some(DecoderKind::Ultralytics),
2343                "{name}: decoder"
2344            );
2345            assert_eq!(det.encoding, Some(BoxEncoding::Direct), "{name}: encoding");
2346            assert_eq!(
2347                det.score_format,
2348                Some(ScoreFormat::PerClass),
2349                "{name}: scores"
2350            );
2351
2352            match want.proto {
2353                None => {
2354                    assert_eq!(r.schema.outputs.len(), 1, "{name}: detection-only");
2355                }
2356                Some((k, ph, pw)) => {
2357                    assert_eq!(r.schema.outputs.len(), 2, "{name}: detection + proto");
2358                    let proto = &r.schema.outputs[1];
2359                    assert_eq!(proto.type_, Some(LogicalType::Protos), "{name}: proto type");
2360                    assert_eq!(
2361                        proto.dshape,
2362                        vec![
2363                            (DimName::Batch, 1),
2364                            (DimName::NumProtos, k),
2365                            (DimName::Height, ph),
2366                            (DimName::Width, pw),
2367                        ],
2368                        "{name}: proto dshape"
2369                    );
2370                }
2371            }
2372
2373            let task = if want.proto.is_some() {
2374                "segment"
2375            } else {
2376                "detect"
2377            };
2378            assert!(
2379                r.description.contains(task) && r.description.contains("Ultralytics"),
2380                "{name}: description was `{}`",
2381                r.description
2382            );
2383        }
2384    }
2385
2386    #[test]
2387    fn every_fixture_is_asserted() {
2388        // Without this, a fixture can be added (or renamed) and silently
2389        // never asserted on -- which is exactly how `yolo11n-seg` and
2390        // `yolov8n-seg_int8` ended up with no field-level coverage.
2391        let dir = infer_fixture_dir();
2392        let mut on_disk: Vec<String> = std::fs::read_dir(&dir)
2393            .unwrap_or_else(|e| panic!("{} exists: {e}", dir.display()))
2394            .filter_map(|e| {
2395                let name = e.ok()?.file_name().into_string().ok()?;
2396                name.strip_suffix(".signals.json").map(str::to_string)
2397            })
2398            .collect();
2399        on_disk.sort();
2400
2401        let mut asserted: Vec<String> = fixture_expectations()
2402            .iter()
2403            .map(|f| f.name.to_string())
2404            .collect();
2405        asserted.sort();
2406
2407        assert_eq!(
2408            on_disk, asserted,
2409            "every *.signals.json fixture must appear in fixture_expectations()"
2410        );
2411    }
2412
2413    #[test]
2414    fn infer_other_source_refuses_rather_than_guessing_normalization() {
2415        // `normalized` follows the exporter and cannot be read off the
2416        // shapes. ONNX (pixel-space) and TFLite ([0,1]) are measured;
2417        // CoreML (pixel-space) is both source-traced and measured. An
2418        // uncharacterized container gets a
2419        // typed refusal, because guessing scales every box by the input
2420        // size and the resulting schema looks perfectly valid.
2421        let s = ModelSignals {
2422            source: ModelSource::Other,
2423            inputs: vec![TensorInfo {
2424                name: "images".into(),
2425                shape: vec![1, 3, 640, 640],
2426                dtype: DType::Float32,
2427                quantization: None,
2428            }],
2429            outputs: vec![TensorInfo {
2430                name: "output0".into(),
2431                shape: vec![1, 84, 8400],
2432                dtype: DType::Float32,
2433                quantization: None,
2434            }],
2435            metadata: synthetic_metadata(80, "detect", "False"),
2436        };
2437        assert!(matches!(
2438            infer_ultralytics_schema(&s),
2439            Err(InferError::UnknownBoxConvention)
2440        ));
2441
2442        // The same signals under a characterized source succeed, so the
2443        // refusal is about the container and nothing else.
2444        let ok = ModelSignals {
2445            source: ModelSource::Onnx,
2446            ..s
2447        };
2448        assert_eq!(
2449            infer_ultralytics_schema(&ok).unwrap().schema.outputs[0].normalized,
2450            Some(false)
2451        );
2452    }
2453
2454    #[test]
2455    fn infer_per_channel_quantization_rejected() {
2456        // Per-channel quantization on a boundary output is refused here
2457        // rather than passed through. The decoder supports per-tensor only
2458        // (`the v1 decoder only supports per-tensor quantization`), and it
2459        // refuses per-channel with OR without an `axis` -- so emitting the
2460        // schema anyway would defer the failure to `DecoderBuilder::build`,
2461        // where the message names the decoder instead of the tensor that
2462        // caused it. No captured fixture exhibits this; a future export
2463        // convention might.
2464        let s = ModelSignals {
2465            source: ModelSource::TfLite,
2466            inputs: vec![TensorInfo {
2467                name: "images".into(),
2468                shape: vec![1, 640, 640, 3],
2469                dtype: DType::Float32,
2470                quantization: None,
2471            }],
2472            outputs: vec![TensorInfo {
2473                name: "output0".into(),
2474                shape: vec![1, 84, 8400],
2475                dtype: DType::Int8,
2476                quantization: Some(Quantization {
2477                    scale: vec![0.02, 0.03],
2478                    zero_point: Some(vec![-5, -7]),
2479                    axis: Some(1),
2480                    dtype: Some(DType::Int8),
2481                }),
2482            }],
2483            metadata: synthetic_metadata(80, "detect", "False"),
2484        };
2485        match infer_ultralytics_schema(&s) {
2486            Err(InferError::UnsupportedQuantization(msg)) => {
2487                assert!(msg.contains("per-channel"), "unexpected message: {msg}");
2488                assert!(
2489                    msg.contains("output0"),
2490                    "message must name the tensor: {msg}"
2491                );
2492            }
2493            other => panic!("expected UnsupportedQuantization, got {other:?}"),
2494        }
2495    }
2496
2497    #[test]
2498    fn infer_per_channel_proto_quantization_rejected() {
2499        // The same guard runs on the proto tensor, not just the detection
2500        // output -- `check_quantized_boundary` is called for both.
2501        let s = ModelSignals {
2502            source: ModelSource::TfLite,
2503            inputs: vec![TensorInfo {
2504                name: "images".into(),
2505                shape: vec![1, 640, 640, 3],
2506                dtype: DType::Float32,
2507                quantization: None,
2508            }],
2509            outputs: vec![
2510                TensorInfo {
2511                    name: "output0".into(),
2512                    shape: vec![1, 116, 8400],
2513                    dtype: DType::Float32,
2514                    quantization: None,
2515                },
2516                TensorInfo {
2517                    name: "output1".into(),
2518                    shape: vec![1, 32, 160, 160],
2519                    dtype: DType::Int8,
2520                    quantization: Some(Quantization {
2521                        scale: vec![0.02, 0.03],
2522                        zero_point: Some(vec![-5, -7]),
2523                        axis: Some(1),
2524                        dtype: Some(DType::Int8),
2525                    }),
2526                },
2527            ],
2528            metadata: synthetic_metadata(80, "segment", "False"),
2529        };
2530        match infer_ultralytics_schema(&s) {
2531            Err(InferError::UnsupportedQuantization(msg)) => {
2532                assert!(
2533                    msg.contains("output1"),
2534                    "message must name the proto: {msg}"
2535                );
2536            }
2537            other => panic!("expected UnsupportedQuantization, got {other:?}"),
2538        }
2539    }
2540
2541    #[test]
2542    fn infer_integer_output_without_quant_rejected() {
2543        // Same synthetic signals as above but with `quantization: None` —
2544        // an integer-dtype boundary output with no quantization params is
2545        // never silently accepted (there'd be no way to dequantize it).
2546        let s = ModelSignals {
2547            source: ModelSource::TfLite,
2548            inputs: vec![TensorInfo {
2549                name: "images".into(),
2550                shape: vec![1, 640, 640, 3],
2551                dtype: DType::Float32,
2552                quantization: None,
2553            }],
2554            outputs: vec![TensorInfo {
2555                name: "output0".into(),
2556                shape: vec![1, 84, 8400],
2557                dtype: DType::Int8,
2558                quantization: None,
2559            }],
2560            metadata: synthetic_metadata(80, "detect", "False"),
2561        };
2562        match infer_ultralytics_schema(&s) {
2563            Err(InferError::UnsupportedLayout(msg)) => {
2564                assert!(
2565                    msg.contains("quantization"),
2566                    "unexpected UnsupportedLayout message: {msg}"
2567                );
2568            }
2569            other => panic!("expected UnsupportedLayout, got {other:?}"),
2570        }
2571    }
2572
2573    #[test]
2574    fn infer_pre_nms_rejects_non_unit_batch() {
2575        // A [2, 84, 8400] output has a matching anchors/feature pair but a
2576        // non-unit leading (batch) dim. It must never be silently accepted
2577        // and mislabeled batch-1 (find_e2e_candidate already requires
2578        // shape[0] == 1; find_anchors_dim must match it).
2579        let mut s = signals_from_fixture("yolov8n");
2580        s.outputs[0].shape = vec![2, 84, 8400];
2581        assert!(matches!(
2582            infer_ultralytics_schema(&s),
2583            Err(InferError::UnsupportedLayout(_))
2584        ));
2585    }
2586
2587    #[test]
2588    fn coreml_source_resolves_a_box_convention() {
2589        // CoreML must not hit the `Other` refusal: a native `.mlpackage`
2590        // export is a characterized convention, not an unknown one.
2591        let s = ModelSignals {
2592            source: ModelSource::CoreMl,
2593            inputs: vec![TensorInfo {
2594                name: "images".into(),
2595                shape: vec![1, 3, 640, 640],
2596                dtype: DType::Float16,
2597                quantization: None,
2598            }],
2599            outputs: vec![TensorInfo {
2600                name: "output0".into(),
2601                shape: vec![1, 84, 8400],
2602                dtype: DType::Float16,
2603                quantization: None,
2604            }],
2605            metadata: synthetic_metadata(80, "detect", "False"),
2606        };
2607        let r = infer_ultralytics_schema(&s)
2608            .expect("CoreML signals must infer, not raise UnknownBoxConvention");
2609        let o = &r.schema.outputs[0];
2610        assert_eq!(
2611            o.normalized,
2612            Some(false),
2613            "Ultralytics CoreML exports trace the raw model, so boxes are \
2614             pixel-space exactly as in the ONNX export"
2615        );
2616    }
2617
2618    #[test]
2619    fn coreml_nms_pipeline_export_is_refused_not_classified() {
2620        // `yolo export ... format=coreml nms=True` does not produce the
2621        // anchor-grid tensor `ModelSource::CoreMl`'s pixel-space
2622        // resolution reasons about -- it substitutes Apple's own NMS
2623        // pipeline, whose two outputs are dynamically shaped and already
2624        // post-NMS. Real measured shapes (Task 3b brief, from a genuine
2625        // `yolo26n.pt` export): `confidence` and `coordinates` are both
2626        // float32 multiArrays with an empty (fully dynamic) shape --
2627        // nothing here is the `[1, 4+nc, A]` layout this module looks
2628        // for, so inference must refuse it rather than guess. This
2629        // refusal is intentional: do not "fix" it into acceptance, since
2630        // that would apply CoreMl's pixel-space convention (or none at
2631        // all) to output Apple's pipeline has already decoded and scaled
2632        // its own way.
2633        let s = ModelSignals {
2634            source: ModelSource::CoreMl,
2635            inputs: vec![TensorInfo {
2636                name: "image".into(),
2637                shape: vec![1, 3, 640, 640],
2638                dtype: DType::Float16,
2639                quantization: None,
2640            }],
2641            outputs: vec![
2642                TensorInfo {
2643                    name: "confidence".into(),
2644                    shape: vec![],
2645                    dtype: DType::Float32,
2646                    quantization: None,
2647                },
2648                TensorInfo {
2649                    name: "coordinates".into(),
2650                    shape: vec![],
2651                    dtype: DType::Float32,
2652                    quantization: None,
2653                },
2654            ],
2655            metadata: synthetic_metadata(80, "detect", "False"),
2656        };
2657        assert!(matches!(
2658            infer_ultralytics_schema(&s),
2659            Err(InferError::UnsupportedLayout(_))
2660        ));
2661    }
2662}