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, so
1047        // that is what a schema describing an Ultralytics model must say.
1048        // Leaving it unset is not neutral: the builder's `Nms::Auto` default
1049        // resolves an unset config to ClassAgnostic (builder.rs `resolve_auto`),
1050        // which suppresses a box against an overlapping box of a *different*
1051        // class and silently loses recall against the model's own reference
1052        // output. An explicit `.with_nms(..)` on the builder still overrides.
1053        //
1054        // YOLO26 end-to-end heads do their NMS in-graph, so they carry none:
1055        // this field describes what the *decoder* must add.
1056        nms: match version {
1057            DecoderVersion::Yolo26 => None,
1058            _ => Some(NmsMode::ClassAware),
1059        },
1060        decoder_version: Some(version),
1061    };
1062
1063    let version_label = if version == DecoderVersion::Yolo26 {
1064        "YOLO26"
1065    } else {
1066        "YOLOv8/11"
1067    };
1068    let task_label = if is_segment { "segment" } else { "detect" };
1069    let description = format!("Ultralytics {version_label} {task_label}, {nc} classes");
1070
1071    Ok(InferredSchema {
1072        schema,
1073        labels: meta.names,
1074        description,
1075    })
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080    use super::*;
1081
1082    /// Root of the `testdata/infer` fixture directory.
1083    ///
1084    /// `EDGEFIRST_TESTDATA_DIR` wins when set -- `scripts/on-target-test.sh`
1085    /// and CI both mirror `crates/decoder/testdata/infer` into
1086    /// `$EDGEFIRST_TESTDATA_DIR/infer` (see `on-target-test.sh`'s testdata
1087    /// sync and `.github/workflows/test.yml`'s "Upload testdata as
1088    /// artifact" step). The manifest-relative fallback is baked in at
1089    /// **compile** time, so a cross-compiled binary carries the build
1090    /// host's absolute path and can never find fixtures on a different
1091    /// target -- same reasoning as `decoder_from_edgefirst_json.rs`'s
1092    /// `testdata_root()`.
1093    fn infer_fixture_dir() -> std::path::PathBuf {
1094        std::env::var("EDGEFIRST_TESTDATA_DIR")
1095            .map(std::path::PathBuf::from)
1096            .unwrap_or_else(|_| {
1097                std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata")
1098            })
1099            .join("infer")
1100    }
1101
1102    #[test]
1103    fn parse_names_python_dict_repr() {
1104        let s = "{0: 'person', 1: 'bicycle', 2: \"fire hydrant\"}";
1105        assert_eq!(
1106            parse_names(s).unwrap(),
1107            vec!["person", "bicycle", "fire hydrant"]
1108        );
1109    }
1110
1111    #[test]
1112    fn parse_names_json_object() {
1113        let s = r#"{"0": "person", "1": "bicycle"}"#;
1114        assert_eq!(parse_names(s).unwrap(), vec!["person", "bicycle"]);
1115    }
1116
1117    #[test]
1118    fn parse_names_orders_by_index_not_insertion() {
1119        let s = "{1: 'b', 0: 'a'}";
1120        assert_eq!(parse_names(s).unwrap(), vec!["a", "b"]);
1121    }
1122
1123    #[test]
1124    fn parse_names_rejects_garbage() {
1125        assert!(matches!(
1126            parse_names("not a dict"),
1127            Err(InferError::BadNames(_))
1128        ));
1129    }
1130
1131    #[test]
1132    fn parse_names_rejects_non_contiguous_indices() {
1133        // Python dict-repr form: index 1 is missing.
1134        match parse_names("{0: 'a', 2: 'b'}") {
1135            Err(InferError::BadNames(msg)) => {
1136                assert!(
1137                    msg.contains("non-contiguous"),
1138                    "unexpected BadNames message: {msg}"
1139                );
1140            }
1141            other => panic!("expected BadNames, got {other:?}"),
1142        }
1143
1144        // JSON-object form: same gap.
1145        match parse_names(r#"{"0": "a", "2": "b"}"#) {
1146            Err(InferError::BadNames(msg)) => {
1147                assert!(
1148                    msg.contains("non-contiguous"),
1149                    "unexpected BadNames message: {msg}"
1150                );
1151            }
1152            other => panic!("expected BadNames, got {other:?}"),
1153        }
1154    }
1155
1156    #[test]
1157    fn meta_from_onnx_props() {
1158        let mut m = BTreeMap::new();
1159        m.insert("names".into(), "{0: 'person'}".into());
1160        m.insert("task".into(), "detect".into());
1161        let u = UltralyticsMeta::from_metadata(&m).unwrap();
1162        assert_eq!(u.names, vec!["person"]);
1163        assert_eq!(u.task, Some(Task::Detect));
1164    }
1165
1166    #[test]
1167    fn meta_task_pose_rejected() {
1168        let mut m = BTreeMap::new();
1169        m.insert("names".into(), "{0: 'person'}".into());
1170        m.insert("task".into(), "pose".into());
1171        assert!(matches!(
1172            UltralyticsMeta::from_metadata(&m),
1173            Err(InferError::UnsupportedTask(_))
1174        ));
1175    }
1176
1177    #[test]
1178    fn meta_missing_names_is_not_ultralytics() {
1179        assert!(matches!(
1180            UltralyticsMeta::from_metadata(&BTreeMap::new()),
1181            Err(InferError::NotUltralytics(_))
1182        ));
1183    }
1184
1185    #[test]
1186    fn meta_end2end_true_or_false_from_onnx_flat_props() {
1187        let mut m = BTreeMap::new();
1188        m.insert("names".into(), "{0: 'person'}".into());
1189        m.insert("end2end".into(), "True".into());
1190        assert_eq!(
1191            UltralyticsMeta::from_metadata(&m).unwrap().end2end,
1192            Some(true)
1193        );
1194
1195        m.insert("end2end".into(), "False".into());
1196        assert_eq!(
1197            UltralyticsMeta::from_metadata(&m).unwrap().end2end,
1198            Some(false)
1199        );
1200    }
1201
1202    /// Loads a captured Task-0 fixture's `metadata` map, exactly as
1203    /// `ModelSignals::metadata` would carry it: raw string key/values,
1204    /// regardless of whether the source was ONNX (flat props) or TFLite
1205    /// (single `metadata.json` envelope entry).
1206    fn load_fixture_metadata(name: &str) -> BTreeMap<String, String> {
1207        let path = infer_fixture_dir().join(format!("{name}.signals.json"));
1208        let content = std::fs::read_to_string(&path)
1209            .unwrap_or_else(|e| panic!("failed to read fixture {}: {e}", path.display()));
1210        let json: serde_json::Value =
1211            serde_json::from_str(&content).expect("fixture is valid JSON");
1212        json["metadata"]
1213            .as_object()
1214            .expect("fixture has a metadata object")
1215            .iter()
1216            .map(|(k, v)| {
1217                (
1218                    k.clone(),
1219                    v.as_str().expect("metadata value is a string").to_string(),
1220                )
1221            })
1222            .collect()
1223    }
1224
1225    #[test]
1226    fn meta_from_real_tflite_metadata_json_envelope() {
1227        let meta = load_fixture_metadata("yolov8n_float32");
1228        let u = UltralyticsMeta::from_metadata(&meta).unwrap();
1229        assert_eq!(u.names.len(), 80);
1230        assert_eq!(u.task, Some(Task::Detect));
1231    }
1232
1233    #[test]
1234    fn meta_end2end_true_on_real_yolo26_tflite_fixture() {
1235        let meta = load_fixture_metadata("yolo26n_float32");
1236        let u = UltralyticsMeta::from_metadata(&meta).unwrap();
1237        assert_eq!(u.end2end, Some(true));
1238    }
1239
1240    #[test]
1241    fn meta_end2end_false_on_real_yolov8_onnx_fixture() {
1242        let meta = load_fixture_metadata("yolov8n");
1243        let u = UltralyticsMeta::from_metadata(&meta).unwrap();
1244        assert_eq!(u.end2end, Some(false));
1245    }
1246
1247    /// Loads a captured Task-0 fixture into `ModelSignals`, exactly as the
1248    /// inference runtime would report it: tensor names/shapes/dtypes plus
1249    /// the raw metadata map.
1250    fn signals_from_fixture(name: &str) -> ModelSignals {
1251        let path = infer_fixture_dir().join(format!("{name}.signals.json"));
1252        let content = std::fs::read_to_string(&path)
1253            .unwrap_or_else(|e| panic!("failed to read fixture {}: {e}", path.display()));
1254        let json: serde_json::Value =
1255            serde_json::from_str(&content).expect("fixture is valid JSON");
1256
1257        let source = match json["source"]
1258            .as_str()
1259            .expect("fixture `source` is a string")
1260        {
1261            "onnx" => ModelSource::Onnx,
1262            "tflite" => ModelSource::TfLite,
1263            "coreml" => ModelSource::CoreMl,
1264            other => panic!("fixture {name}: unknown source `{other}`"),
1265        };
1266
1267        fn parse_dtype(s: &str) -> DType {
1268            match s {
1269                "float32" => DType::Float32,
1270                "float16" => DType::Float16,
1271                "int8" => DType::Int8,
1272                "uint8" => DType::Uint8,
1273                "int16" => DType::Int16,
1274                "uint16" => DType::Uint16,
1275                "int32" => DType::Int32,
1276                "uint32" => DType::Uint32,
1277                other => panic!("unknown dtype `{other}`"),
1278            }
1279        }
1280
1281        fn parse_tensor(v: &serde_json::Value) -> TensorInfo {
1282            TensorInfo {
1283                name: v["name"]
1284                    .as_str()
1285                    .expect("tensor `name` is a string")
1286                    .to_string(),
1287                shape: v["shape"]
1288                    .as_array()
1289                    .expect("tensor `shape` is an array")
1290                    .iter()
1291                    .map(|d| d.as_u64().expect("shape dim is a number") as usize)
1292                    .collect(),
1293                dtype: parse_dtype(v["dtype"].as_str().expect("tensor `dtype` is a string")),
1294                // Every captured fixture reports `"quantization": null`
1295                // (see testdata/infer/NOTES.md); real quantized signals
1296                // are out of scope for this task.
1297                quantization: None,
1298            }
1299        }
1300
1301        let inputs = json["inputs"]
1302            .as_array()
1303            .expect("fixture `inputs` is an array")
1304            .iter()
1305            .map(parse_tensor)
1306            .collect();
1307        let outputs = json["outputs"]
1308            .as_array()
1309            .expect("fixture `outputs` is an array")
1310            .iter()
1311            .map(parse_tensor)
1312            .collect();
1313        let metadata = json["metadata"]
1314            .as_object()
1315            .expect("fixture `metadata` is an object")
1316            .iter()
1317            .map(|(k, v)| {
1318                (
1319                    k.clone(),
1320                    v.as_str().expect("metadata value is a string").to_string(),
1321                )
1322            })
1323            .collect();
1324
1325        ModelSignals {
1326            source,
1327            inputs,
1328            outputs,
1329            metadata,
1330        }
1331    }
1332
1333    /// Builds a synthetic Ultralytics-style metadata map (Python dict-repr
1334    /// `names`, `task`, `end2end`) for hand-built layout tests that don't
1335    /// need a full captured fixture.
1336    fn synthetic_metadata(nc: usize, task: &str, end2end: &str) -> BTreeMap<String, String> {
1337        let names = (0..nc)
1338            .map(|i| format!("{i}: 'c{i}'"))
1339            .collect::<Vec<_>>()
1340            .join(", ");
1341        let mut m = BTreeMap::new();
1342        m.insert("names".into(), format!("{{{names}}}"));
1343        m.insert("task".into(), task.into());
1344        m.insert("end2end".into(), end2end.into());
1345        m
1346    }
1347
1348    #[test]
1349    fn infer_transposed_det_layout_classified() {
1350        // Anchors-first layout ([1, A, 4+nc]), as produced by older
1351        // Ultralytics TFLite exporters. Rule 4 must still classify this
1352        // correctly from tensor shape alone, and the builder's dshape-order
1353        // e2e-inference pitfall must be guarded by an explicit
1354        // decoder_version.
1355        let s = ModelSignals {
1356            source: ModelSource::TfLite,
1357            inputs: vec![TensorInfo {
1358                name: "images".into(),
1359                shape: vec![1, 640, 640, 3],
1360                dtype: DType::Float32,
1361                quantization: None,
1362            }],
1363            outputs: vec![TensorInfo {
1364                name: "output0".into(),
1365                shape: vec![1, 8400, 84],
1366                dtype: DType::Float32,
1367                quantization: None,
1368            }],
1369            metadata: synthetic_metadata(80, "detect", "False"),
1370        };
1371        let r = infer_ultralytics_schema(&s).unwrap();
1372        let o = &r.schema.outputs[0];
1373        assert_eq!(
1374            o.dshape,
1375            vec![
1376                (DimName::Batch, 1),
1377                (DimName::NumBoxes, 8400),
1378                (DimName::NumFeatures, 84),
1379            ]
1380        );
1381        assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolov8));
1382    }
1383
1384    #[test]
1385    fn infer_yolo26n_onnx_end_to_end() {
1386        let s = signals_from_fixture("yolo26n");
1387        let r = infer_ultralytics_schema(&s).unwrap();
1388        assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolo26));
1389        assert_eq!(r.schema.outputs[0].shape[2], 6);
1390    }
1391
1392    #[test]
1393    fn infer_class_count_mismatch_rejected() {
1394        let mut s = signals_from_fixture("yolov8n");
1395        s.metadata.insert("names".into(), "{0: 'only-one'}".into()); // nc=1, f=84
1396        assert!(matches!(
1397            infer_ultralytics_schema(&s),
1398            Err(InferError::ClassCountMismatch {
1399                expected: 1,
1400                found: 80
1401            })
1402        ));
1403    }
1404
1405    #[test]
1406    fn infer_seg_class_count_mismatch_rejected() {
1407        // Covers the generalized ClassCountMismatch math for segmentation,
1408        // where the non-class base width is `4 + k` (proto channels), not
1409        // the plain-detection `4`. Real yolov8n-seg det tensor is
1410        // [1, 116, 8400] (features-first, feat = 4+80+32); truncating
1411        // `names` to 1 class makes the expected feat 4+1+32=37, so the
1412        // real 116-feature tensor mismatches with found = 116-(37-1) = 80
1413        // — the true class count the features imply, unaffected by the
1414        // metadata truncation.
1415        let mut s = signals_from_fixture("yolov8n-seg");
1416        s.metadata.insert("names".into(), "{0: 'only-one'}".into()); // nc=1
1417        assert!(matches!(
1418            infer_ultralytics_schema(&s),
1419            Err(InferError::ClassCountMismatch {
1420                expected: 1,
1421                found: 80
1422            })
1423        ));
1424    }
1425
1426    #[test]
1427    fn infer_end2end_flag_shape_conflict_rejected() {
1428        // The end2end flag is the primary signal: a real yolov8n ONNX
1429        // export with its pre-NMS [1, 84, 8400] output, but with `end2end`
1430        // forced to "True", must never be guessed into an end-to-end
1431        // classification — flag/shape disagreement is always an error.
1432        let mut s = signals_from_fixture("yolov8n");
1433        s.metadata.insert("end2end".into(), "True".into());
1434        assert!(matches!(
1435            infer_ultralytics_schema(&s),
1436            Err(InferError::UnsupportedLayout(_))
1437        ));
1438    }
1439
1440    #[test]
1441    fn infer_yolov8n_seg_onnx() {
1442        let r = infer_ultralytics_schema(&signals_from_fixture("yolov8n-seg")).unwrap();
1443        assert_eq!(r.schema.outputs.len(), 2);
1444        let proto = r
1445            .schema
1446            .outputs
1447            .iter()
1448            .find(|o| o.type_ == Some(LogicalType::Protos))
1449            .unwrap();
1450        assert_eq!(proto.dshape[1].0, DimName::NumProtos); // NCHW
1451        let det = r
1452            .schema
1453            .outputs
1454            .iter()
1455            .find(|o| o.type_ == Some(LogicalType::Detection))
1456            .unwrap();
1457        assert!(det.shape.contains(&116)); // 4+80+32
1458    }
1459
1460    #[test]
1461    fn infer_yolov8n_seg_tflite_proto_nchw() {
1462        // The real yolov8n-seg_float32 TFLite export's proto is
1463        // [1, 32, 160, 160], NCHW-ordered — same as its ONNX counterpart.
1464        // An earlier draft of this test assumed TFLite seg exports carry a
1465        // NHWC-transposed proto; no captured fixture confirms that (see
1466        // infer_nhwc_proto_layout_classified for the synthetic NHWC coverage
1467        // the shape-driven classifier still supports).
1468        let r = infer_ultralytics_schema(&signals_from_fixture("yolov8n-seg_float32")).unwrap();
1469        let proto = r
1470            .schema
1471            .outputs
1472            .iter()
1473            .find(|o| o.type_ == Some(LogicalType::Protos))
1474            .unwrap();
1475        assert_eq!(proto.dshape[1].0, DimName::NumProtos); // NCHW
1476        assert_eq!(proto.dshape[1].1, 32);
1477    }
1478
1479    #[test]
1480    fn infer_yolo26n_seg_end_to_end() {
1481        let r = infer_ultralytics_schema(&signals_from_fixture("yolo26n-seg")).unwrap();
1482        assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolo26));
1483        assert_eq!(r.schema.outputs.len(), 2);
1484    }
1485
1486    #[test]
1487    fn infer_nhwc_proto_layout_classified() {
1488        // Hand-built signals: TFLite source, NHWC input, features-first det
1489        // tensor (4+80+32=116), NHWC proto ([1, 160, 160, 32]) — the other
1490        // valid proto channel ordering rule 1 must also classify correctly.
1491        let s = ModelSignals {
1492            source: ModelSource::TfLite,
1493            inputs: vec![TensorInfo {
1494                name: "images".into(),
1495                shape: vec![1, 640, 640, 3],
1496                dtype: DType::Float32,
1497                quantization: None,
1498            }],
1499            outputs: vec![
1500                TensorInfo {
1501                    name: "output0".into(),
1502                    shape: vec![1, 116, 8400],
1503                    dtype: DType::Float32,
1504                    quantization: None,
1505                },
1506                TensorInfo {
1507                    name: "output1".into(),
1508                    shape: vec![1, 160, 160, 32],
1509                    dtype: DType::Float32,
1510                    quantization: None,
1511                },
1512            ],
1513            metadata: synthetic_metadata(80, "segment", "False"),
1514        };
1515        let r = infer_ultralytics_schema(&s).unwrap();
1516        let proto = r
1517            .schema
1518            .outputs
1519            .iter()
1520            .find(|o| o.type_ == Some(LogicalType::Protos))
1521            .unwrap();
1522        assert_eq!(proto.dshape[3].0, DimName::NumProtos); // NHWC
1523        assert_eq!(proto.dshape[3].1, 32);
1524    }
1525
1526    /// The proto output is built field by field over `default_logical()`, so
1527    /// a dropped field reads as `None` rather than failing to compile. The
1528    /// decoder needs every one of these to dequantize and route the tensor.
1529    #[test]
1530    fn infer_proto_output_carries_its_name_decoder_dtype_and_quantization() {
1531        let quant = Quantization {
1532            scale: vec![0.0125],
1533            zero_point: Some(vec![-7]),
1534            axis: None,
1535            dtype: Some(DType::Int8),
1536        };
1537        let s = ModelSignals {
1538            source: ModelSource::TfLite,
1539            inputs: vec![TensorInfo {
1540                name: "images".into(),
1541                shape: vec![1, 640, 640, 3],
1542                dtype: DType::Int8,
1543                quantization: None,
1544            }],
1545            outputs: vec![
1546                TensorInfo {
1547                    name: "output0".into(),
1548                    shape: vec![1, 116, 8400],
1549                    dtype: DType::Int8,
1550                    quantization: Some(Quantization {
1551                        scale: vec![0.003_921_6],
1552                        zero_point: Some(vec![0]),
1553                        axis: None,
1554                        dtype: Some(DType::Int8),
1555                    }),
1556                },
1557                TensorInfo {
1558                    name: "protos".into(),
1559                    shape: vec![1, 160, 160, 32],
1560                    dtype: DType::Int8,
1561                    quantization: Some(quant.clone()),
1562                },
1563            ],
1564            metadata: synthetic_metadata(80, "segment", "False"),
1565        };
1566        let r = infer_ultralytics_schema(&s).unwrap();
1567        let proto = r
1568            .schema
1569            .outputs
1570            .iter()
1571            .find(|o| o.type_ == Some(LogicalType::Protos))
1572            .unwrap();
1573        assert_eq!(proto.name.as_deref(), Some("protos"));
1574        assert_eq!(proto.decoder, Some(DecoderKind::Ultralytics));
1575        assert_eq!(proto.dtype, Some(DType::Int8));
1576        assert_eq!(proto.quantization.as_ref(), Some(&quant));
1577    }
1578
1579    /// A proto tensor makes the model a segmentation model whether or not the
1580    /// metadata says `task: segment`. Requiring both would silently decode a
1581    /// segmentation export as plain detection.
1582    #[test]
1583    fn infer_treats_a_proto_tensor_without_a_task_as_segmentation() {
1584        let mut metadata = synthetic_metadata(80, "segment", "False");
1585        metadata.remove("task");
1586        let s = ModelSignals {
1587            source: ModelSource::TfLite,
1588            inputs: vec![TensorInfo {
1589                name: "images".into(),
1590                shape: vec![1, 640, 640, 3],
1591                dtype: DType::Float32,
1592                quantization: None,
1593            }],
1594            outputs: vec![
1595                TensorInfo {
1596                    name: "output0".into(),
1597                    shape: vec![1, 116, 8400],
1598                    dtype: DType::Float32,
1599                    quantization: None,
1600                },
1601                TensorInfo {
1602                    name: "output1".into(),
1603                    shape: vec![1, 160, 160, 32],
1604                    dtype: DType::Float32,
1605                    quantization: None,
1606                },
1607            ],
1608            metadata,
1609        };
1610        let r = infer_ultralytics_schema(&s).unwrap();
1611        assert!(
1612            r.schema
1613                .outputs
1614                .iter()
1615                .any(|o| o.type_ == Some(LogicalType::Protos)),
1616            "proto tensor present but no protos output was emitted"
1617        );
1618        // The task the metadata did not state is the one reported back, which
1619        // is the only place `is_segment` is observable.
1620        assert!(r.description.contains("segment"), "{}", r.description);
1621    }
1622
1623    fn proto_tensor(shape: Vec<usize>) -> TensorInfo {
1624        TensorInfo {
1625            name: "protos".into(),
1626            shape,
1627            dtype: DType::Float32,
1628            quantization: None,
1629        }
1630    }
1631
1632    /// Both halves of the rank-and-batch guard have to reject on their own.
1633    /// Requiring both instead would index past the end of a rank-3 shape.
1634    #[test]
1635    fn classify_proto_rejects_wrong_rank_or_batch() {
1636        for shape in [vec![1, 32, 160], vec![2, 32, 160, 160], vec![1, 160, 160]] {
1637            let err = classify_proto(&proto_tensor(shape.clone()), 640, 640).unwrap_err();
1638            assert!(
1639                matches!(err, InferError::UnsupportedLayout(_)),
1640                "shape {shape:?} gave {err:?}"
1641            );
1642        }
1643    }
1644
1645    /// The NHWC branch needs both spatial dims to line up. Accepting either
1646    /// one would read the proto count out of a dimension that is not it.
1647    #[test]
1648    fn classify_proto_rejects_a_shape_matching_only_one_spatial_dim() {
1649        // h/4 = w/4 = 160. s[1] matches, s[2] does not.
1650        let err = classify_proto(&proto_tensor(vec![1, 160, 7, 9]), 640, 640).unwrap_err();
1651        assert!(matches!(err, InferError::UnsupportedLayout(_)), "{err:?}");
1652
1653        // The genuine NHWC shape still classifies, with the proto count last.
1654        let (k, dshape) = classify_proto(&proto_tensor(vec![1, 160, 160, 32]), 640, 640).unwrap();
1655        assert_eq!(k, 32);
1656        assert_eq!(dshape[3], (DimName::NumProtos, 32));
1657    }
1658
1659    /// `unquote` strips a matching pair only. A single quote on one end is an
1660    /// unterminated value, not a quoted one.
1661    #[test]
1662    fn unquote_requires_a_matching_pair_of_quotes() {
1663        assert_eq!(unquote("'abc'").as_deref(), Some("abc"));
1664        assert_eq!(unquote("\"abc\"").as_deref(), Some("abc"));
1665        assert_eq!(unquote("'abc"), None);
1666        assert_eq!(unquote("abc'"), None);
1667        assert_eq!(unquote("\"abc"), None);
1668        assert_eq!(unquote("abc\""), None);
1669        assert_eq!(unquote("abc"), None);
1670    }
1671
1672    /// A comma inside a quoted value is part of the value. Without the quote
1673    /// arm the scanner never enters quoted state and splits straight through
1674    /// it.
1675    #[test]
1676    fn split_top_level_commas_keeps_quoted_commas_together() {
1677        let parts = split_top_level_commas("a='x,y', b=2");
1678        assert_eq!(parts, vec!["a='x,y'".to_string(), " b=2".to_string()]);
1679
1680        let parts = split_top_level_commas("a=\"x,y\",b=\"p,q\"");
1681        assert_eq!(
1682            parts,
1683            vec!["a=\"x,y\"".to_string(), "b=\"p,q\"".to_string()]
1684        );
1685    }
1686
1687    /// `InferError` is what a rejection reports to the caller. A `Display`
1688    /// that writes nothing turns every diagnostic into an empty string.
1689    #[test]
1690    fn infer_error_display_states_the_reason() {
1691        let err = InferError::NotUltralytics("no names key".into());
1692        let shown = err.to_string();
1693        assert!(shown.contains("no names key"), "{shown}");
1694        assert!(shown.contains("Ultralytics"), "{shown}");
1695
1696        let err = InferError::UnsupportedLayout("rank-3 proto".into());
1697        assert!(err.to_string().contains("rank-3 proto"));
1698    }
1699
1700    #[test]
1701    fn infer_segment_task_without_proto_rejected() {
1702        let s = ModelSignals {
1703            source: ModelSource::Onnx,
1704            inputs: vec![TensorInfo {
1705                name: "images".into(),
1706                shape: vec![1, 3, 640, 640],
1707                dtype: DType::Float32,
1708                quantization: None,
1709            }],
1710            outputs: vec![TensorInfo {
1711                name: "output0".into(),
1712                shape: vec![1, 84, 8400],
1713                dtype: DType::Float32,
1714                quantization: None,
1715            }],
1716            metadata: synthetic_metadata(80, "segment", "False"),
1717        };
1718        assert!(matches!(
1719            infer_ultralytics_schema(&s),
1720            Err(InferError::UnsupportedLayout(_))
1721        ));
1722    }
1723
1724    #[test]
1725    fn infer_detect_task_with_proto_rejected() {
1726        let s = ModelSignals {
1727            source: ModelSource::Onnx,
1728            inputs: vec![TensorInfo {
1729                name: "images".into(),
1730                shape: vec![1, 3, 640, 640],
1731                dtype: DType::Float32,
1732                quantization: None,
1733            }],
1734            outputs: vec![
1735                TensorInfo {
1736                    name: "output0".into(),
1737                    shape: vec![1, 84, 8400],
1738                    dtype: DType::Float32,
1739                    quantization: None,
1740                },
1741                TensorInfo {
1742                    name: "output1".into(),
1743                    shape: vec![1, 32, 160, 160],
1744                    dtype: DType::Float32,
1745                    quantization: None,
1746                },
1747            ],
1748            metadata: synthetic_metadata(80, "detect", "False"),
1749        };
1750        assert!(matches!(
1751            infer_ultralytics_schema(&s),
1752            Err(InferError::UnsupportedLayout(_))
1753        ));
1754    }
1755
1756    #[test]
1757    fn infer_int8_tflite_classifies_like_float32() {
1758        // Real Ultralytics int8 TFLite exports expose float32 I/O with NO
1759        // boundary quantization — the int8 graph is wrapped in
1760        // dequant/quant ops (Task 0 finding). The classifier must treat
1761        // this fixture exactly like its float32 counterpart, not attempt
1762        // to recover quantization that isn't there.
1763        let r = infer_ultralytics_schema(&signals_from_fixture("yolov8n_int8")).unwrap();
1764        let det = &r.schema.outputs[0];
1765        assert_eq!(det.type_, Some(LogicalType::Detection));
1766        assert_eq!(det.dtype, Some(DType::Float32));
1767        assert_eq!(det.quantization, None);
1768        assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolov8));
1769        assert_eq!(det.normalized, Some(true));
1770    }
1771
1772    #[test]
1773    fn infer_quantized_boundary_carries_quantization() {
1774        // Hand-built signals for a model that DOES expose a quantized
1775        // boundary output (unlike any captured fixture) — the classifier
1776        // must pass `TensorInfo.quantization` through onto the schema's
1777        // detection output untouched.
1778        let quant = Quantization {
1779            scale: vec![0.02],
1780            zero_point: Some(vec![-5]),
1781            axis: None,
1782            dtype: Some(DType::Int8),
1783        };
1784        let s = ModelSignals {
1785            source: ModelSource::TfLite,
1786            inputs: vec![TensorInfo {
1787                name: "images".into(),
1788                shape: vec![1, 640, 640, 3],
1789                dtype: DType::Float32,
1790                quantization: None,
1791            }],
1792            outputs: vec![TensorInfo {
1793                name: "output0".into(),
1794                shape: vec![1, 84, 8400],
1795                dtype: DType::Int8,
1796                quantization: Some(quant.clone()),
1797            }],
1798            metadata: synthetic_metadata(80, "detect", "False"),
1799        };
1800        let r = infer_ultralytics_schema(&s).unwrap();
1801        let det = &r.schema.outputs[0];
1802        assert_eq!(det.dtype, Some(DType::Int8));
1803        assert_eq!(det.quantization, Some(quant));
1804    }
1805
1806    #[test]
1807    fn infer_rejects_batched_input() {
1808        // Every output layout here is batch-1, so a batched input would
1809        // emit an input dshape whose Batch disagrees with the detection
1810        // tensor beside it. Previously this succeeded and emitted
1811        // `(Batch, 4)`.
1812        let s = ModelSignals {
1813            source: ModelSource::Onnx,
1814            inputs: vec![TensorInfo {
1815                name: "images".into(),
1816                shape: vec![4, 3, 640, 640],
1817                dtype: DType::Float32,
1818                quantization: None,
1819            }],
1820            outputs: vec![TensorInfo {
1821                name: "output0".into(),
1822                shape: vec![1, 84, 8400],
1823                dtype: DType::Float32,
1824                quantization: None,
1825            }],
1826            metadata: synthetic_metadata(80, "detect", "False"),
1827        };
1828        match infer_ultralytics_schema(&s) {
1829            Err(InferError::UnsupportedLayout(msg)) => {
1830                assert!(msg.contains("batch 4"), "unexpected message: {msg}");
1831            }
1832            other => panic!("expected UnsupportedLayout, got {other:?}"),
1833        }
1834    }
1835
1836    #[test]
1837    fn infer_rejects_input_without_a_channel_dim() {
1838        let s = ModelSignals {
1839            source: ModelSource::Onnx,
1840            inputs: vec![TensorInfo {
1841                name: "images".into(),
1842                shape: vec![1, 4, 640, 640],
1843                dtype: DType::Float32,
1844                quantization: None,
1845            }],
1846            outputs: vec![TensorInfo {
1847                name: "output0".into(),
1848                shape: vec![1, 84, 8400],
1849                dtype: DType::Float32,
1850                quantization: None,
1851            }],
1852            metadata: synthetic_metadata(80, "detect", "False"),
1853        };
1854        match infer_ultralytics_schema(&s) {
1855            Err(InferError::UnsupportedLayout(msg)) => {
1856                assert!(msg.contains("channel dimension"), "unexpected: {msg}");
1857            }
1858            other => panic!("expected UnsupportedLayout, got {other:?}"),
1859        }
1860    }
1861
1862    #[test]
1863    fn infer_rejects_model_with_no_rank4_input() {
1864        let s = ModelSignals {
1865            source: ModelSource::Onnx,
1866            inputs: vec![TensorInfo {
1867                name: "images".into(),
1868                shape: vec![1, 3],
1869                dtype: DType::Float32,
1870                quantization: None,
1871            }],
1872            outputs: vec![TensorInfo {
1873                name: "output0".into(),
1874                shape: vec![1, 84, 8400],
1875                dtype: DType::Float32,
1876                quantization: None,
1877            }],
1878            metadata: synthetic_metadata(80, "detect", "False"),
1879        };
1880        match infer_ultralytics_schema(&s) {
1881            Err(InferError::UnsupportedLayout(msg)) => {
1882                assert!(msg.contains("no rank-4 model input"), "unexpected: {msg}");
1883            }
1884            other => panic!("expected UnsupportedLayout, got {other:?}"),
1885        }
1886    }
1887
1888    /// Builds detection signals for a 640x640 model with one rank-3 output.
1889    fn det_signals(nc: usize, out_shape: Vec<usize>, end2end: &str) -> ModelSignals {
1890        ModelSignals {
1891            source: ModelSource::Onnx,
1892            inputs: vec![TensorInfo {
1893                name: "images".into(),
1894                shape: vec![1, 3, 640, 640],
1895                dtype: DType::Float32,
1896                quantization: None,
1897            }],
1898            outputs: vec![TensorInfo {
1899                name: "output0".into(),
1900                shape: out_shape,
1901                dtype: DType::Float32,
1902                quantization: None,
1903            }],
1904            metadata: synthetic_metadata(nc, "detect", end2end),
1905        }
1906    }
1907
1908    #[test]
1909    fn infer_end_to_end_accepts_any_max_det_up_to_the_anchor_count() {
1910        // Ultralytics bakes max_det into the graph: the head's postprocess
1911        // is a TopK with k = min(max_det, anchors), so N is whatever the
1912        // export chose. The exporter clamps it to the anchor count
1913        // (exporter.py: `m.max_det = min(args.max_det, available)`), which
1914        // is the same quantity as `expected_anchors` here -- verified by
1915        // exporting yolo26n at max_det = 100/1000/2000/8400/10000, the last
1916        // two both yielding [1, 8400, 6].
1917        //
1918        // 300 is the default; 2000 is what a crowded-scene export uses and
1919        // was rejected outright before; 8400 == expected_anchors is what any
1920        // max_det >= anchors produces and is equally legitimate.
1921        for n in [1, 300, 2000, 8400] {
1922            let r = infer_ultralytics_schema(&det_signals(80, vec![1, n, 6], "True"))
1923                .unwrap_or_else(|e| panic!("max_det={n} must infer: {e}"));
1924            assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolo26));
1925            assert_eq!(r.schema.outputs[0].type_, Some(LogicalType::Detections));
1926            assert_eq!(
1927                r.schema.outputs[0].dshape,
1928                vec![
1929                    (DimName::Batch, 1),
1930                    (DimName::NumBoxes, n),
1931                    (DimName::NumFeatures, 6),
1932                ],
1933                "max_det={n}"
1934            );
1935            // End-to-end ran NMS in-graph, so the schema adds none.
1936            assert_eq!(r.schema.nms, None, "max_det={n}");
1937        }
1938    }
1939
1940    #[test]
1941    fn infer_end_to_end_rejects_more_rows_than_anchors() {
1942        // Upstream cannot emit this: TopK's k is clamped to the anchor
1943        // count, so a longer tensor is not an Ultralytics end-to-end output
1944        // and the bound stays a real check rather than a formality.
1945        match infer_ultralytics_schema(&det_signals(80, vec![1, 8401, 6], "True")) {
1946            Err(InferError::UnsupportedLayout(msg)) => {
1947                assert!(msg.contains("N<=8400"), "unexpected message: {msg}");
1948            }
1949            other => panic!("expected UnsupportedLayout, got {other:?}"),
1950        }
1951    }
1952
1953    #[test]
1954    fn infer_legacy_nms_model_is_unaffected_by_max_det() {
1955        // The pre-NMS (NMS-required) head has no max_det in its graph at
1956        // all: it emits every anchor, and the detection count is decided
1957        // later by the decoder's own NMS. So the schema must classify it by
1958        // the anchor count regardless, and must NOT acquire a NumBoxes
1959        // dimension that looks like an end-to-end row count.
1960        let r = infer_ultralytics_schema(&det_signals(80, vec![1, 84, 8400], "False")).unwrap();
1961        assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolov8));
1962        assert_eq!(r.schema.outputs[0].type_, Some(LogicalType::Detection));
1963        assert_eq!(
1964            r.schema.outputs[0].dshape,
1965            vec![
1966                (DimName::Batch, 1),
1967                (DimName::NumFeatures, 84),
1968                (DimName::NumBoxes, 8400),
1969            ]
1970        );
1971        // Class-aware NMS, matching Ultralytics' own agnostic=False. The
1972        // schema names the mode only; thresholds and max_det stay the
1973        // caller's, set on the builder per frame-rate/recall budget.
1974        assert_eq!(r.schema.nms, Some(NmsMode::ClassAware));
1975
1976        // Anchors-first is classified identically -- neither orientation
1977        // may be mistaken for an end-to-end [1, N, feat] tensor.
1978        let t = infer_ultralytics_schema(&det_signals(80, vec![1, 8400, 84], "False")).unwrap();
1979        assert_eq!(t.schema.decoder_version, Some(DecoderVersion::Yolov8));
1980        assert_eq!(
1981            t.schema.outputs[0].dshape,
1982            vec![
1983                (DimName::Batch, 1),
1984                (DimName::NumBoxes, 8400),
1985                (DimName::NumFeatures, 84),
1986            ]
1987        );
1988    }
1989
1990    #[test]
1991    fn infer_pre_nms_wins_when_a_shape_reads_as_both() {
1992        // With metadata stripped and nc == 2, feat == 4 + nc == 6 == the
1993        // end-to-end feature width, so [1, A, 6] is genuinely both layouts
1994        // at once. pre-NMS is tried first and wins; this is the one place a
1995        // guess remains, so it is pinned rather than left to drift.
1996        let mut m = synthetic_metadata(2, "detect", "False");
1997        m.remove("end2end");
1998        let s = ModelSignals {
1999            metadata: m,
2000            ..det_signals(2, vec![1, 8400, 6], "False")
2001        };
2002        let r = infer_ultralytics_schema(&s).unwrap();
2003        assert_eq!(r.schema.decoder_version, Some(DecoderVersion::Yolov8));
2004    }
2005
2006    #[test]
2007    fn meta_empty_names_rejected() {
2008        // nc == 0 makes feat == 4, so a [1,4,A] output would infer a schema
2009        // the decoder then refuses ("Yolo num_features 4 must be > 4").
2010        let mut m = BTreeMap::new();
2011        m.insert("names".into(), "{}".into());
2012        match UltralyticsMeta::from_metadata(&m) {
2013            Err(InferError::BadNames(msg)) => assert!(msg.contains("empty"), "{msg}"),
2014            other => panic!("expected BadNames, got {other:?}"),
2015        }
2016    }
2017
2018    #[test]
2019    fn meta_malformed_end2end_is_an_error_not_an_absent_key() {
2020        // `None` means "key absent", which selects the shape-only fallback.
2021        // A corrupt value must not collapse into that -- the model is
2022        // actively claiming something, just not something parseable.
2023        let mut m = synthetic_metadata(80, "detect", "False");
2024        m.insert("end2end".into(), "yes".into());
2025        match UltralyticsMeta::from_metadata(&m) {
2026            Err(InferError::BadMetadata(msg)) => assert!(msg.contains("end2end"), "{msg}"),
2027            other => panic!("expected BadMetadata, got {other:?}"),
2028        }
2029
2030        // Absent is still fine, and still means "fall back to shapes".
2031        m.remove("end2end");
2032        assert_eq!(UltralyticsMeta::from_metadata(&m).unwrap().end2end, None);
2033    }
2034
2035    #[test]
2036    fn meta_foreign_vendor_rejected_but_missing_provenance_allowed() {
2037        // A `names` map alone is not an Ultralytics signature; another
2038        // exporter's model would otherwise be given Ultralytics box, score
2039        // and normalization conventions.
2040        let mut m = synthetic_metadata(80, "detect", "False");
2041        m.insert("author".into(), "SomeOtherVendor".into());
2042        assert!(matches!(
2043            UltralyticsMeta::from_metadata(&m),
2044            Err(InferError::NotUltralytics(_))
2045        ));
2046
2047        // Provenance that names Ultralytics passes, in either field.
2048        m.insert("author".into(), "Ultralytics".into());
2049        m.insert("docs".into(), "https://docs.ultralytics.com".into());
2050        assert!(UltralyticsMeta::from_metadata(&m).is_ok());
2051
2052        // Absence is not a contradiction: quantizers and format converters
2053        // routinely keep `names` and drop provenance.
2054        m.remove("author");
2055        m.remove("docs");
2056        assert!(UltralyticsMeta::from_metadata(&m).is_ok());
2057    }
2058
2059    #[test]
2060    fn infer_zero_row_end_to_end_rejected() {
2061        // [1, 0, 6] satisfies every other end-to-end condition but yields a
2062        // schema the decoder refuses for a zero dimension.
2063        assert!(matches!(
2064            infer_ultralytics_schema(&det_signals(80, vec![1, 0, 6], "True")),
2065            Err(InferError::UnsupportedLayout(_))
2066        ));
2067    }
2068
2069    #[test]
2070    fn infer_duplicate_matching_outputs_are_ambiguous() {
2071        let input = TensorInfo {
2072            name: "images".into(),
2073            shape: vec![1, 3, 640, 640],
2074            dtype: DType::Float32,
2075            quantization: None,
2076        };
2077        let t = |name: &str, shape: Vec<usize>| TensorInfo {
2078            name: name.into(),
2079            shape,
2080            dtype: DType::Float32,
2081            quantization: None,
2082        };
2083
2084        // Two identical pre-NMS detection tensors: nothing distinguishes
2085        // them but the order the runtime listed them in.
2086        let pre = ModelSignals {
2087            source: ModelSource::Onnx,
2088            inputs: vec![input.clone()],
2089            outputs: vec![t("a", vec![1, 84, 8400]), t("b", vec![1, 84, 8400])],
2090            metadata: synthetic_metadata(80, "detect", "False"),
2091        };
2092        match infer_ultralytics_schema(&pre) {
2093            Err(InferError::AmbiguousLayout(msg)) => {
2094                assert!(msg.contains("`a`") && msg.contains("`b`"), "{msg}");
2095            }
2096            other => panic!("expected AmbiguousLayout, got {other:?}"),
2097        }
2098
2099        // Same for two end-to-end candidates.
2100        let e2e = ModelSignals {
2101            source: ModelSource::Onnx,
2102            inputs: vec![input],
2103            outputs: vec![t("a", vec![1, 300, 6]), t("b", vec![1, 200, 6])],
2104            metadata: synthetic_metadata(80, "detect", "True"),
2105        };
2106        assert!(matches!(
2107            infer_ultralytics_schema(&e2e),
2108            Err(InferError::AmbiguousLayout(_))
2109        ));
2110    }
2111
2112    #[test]
2113    fn infer_malformed_per_tensor_quantization_rejected() {
2114        let build = |q: Quantization| ModelSignals {
2115            source: ModelSource::TfLite,
2116            inputs: vec![TensorInfo {
2117                name: "images".into(),
2118                shape: vec![1, 640, 640, 3],
2119                dtype: DType::Float32,
2120                quantization: None,
2121            }],
2122            outputs: vec![TensorInfo {
2123                name: "output0".into(),
2124                shape: vec![1, 84, 8400],
2125                dtype: DType::Int8,
2126                quantization: Some(q),
2127            }],
2128            metadata: synthetic_metadata(80, "detect", "False"),
2129        };
2130
2131        // No scale at all: previously emitted `{"scale": []}`, which the
2132        // decoder reads as a zero scale instead of refusing.
2133        match infer_ultralytics_schema(&build(Quantization {
2134            scale: vec![],
2135            zero_point: Some(vec![]),
2136            axis: None,
2137            dtype: Some(DType::Int8),
2138        })) {
2139            Err(InferError::UnsupportedQuantization(msg)) => {
2140                assert!(msg.contains("no scale"), "{msg}");
2141            }
2142            other => panic!("expected UnsupportedQuantization, got {other:?}"),
2143        }
2144
2145        // Zero points that do not pair with the scale were silently
2146        // truncated to the first element.
2147        match infer_ultralytics_schema(&build(Quantization {
2148            scale: vec![0.02],
2149            zero_point: Some(vec![-5, -7]),
2150            axis: None,
2151            dtype: Some(DType::Int8),
2152        })) {
2153            Err(InferError::UnsupportedQuantization(msg)) => {
2154                assert!(msg.contains("zero point"), "{msg}");
2155            }
2156            other => panic!("expected UnsupportedQuantization, got {other:?}"),
2157        }
2158
2159        // Symmetric quantization (no zero points) stays valid.
2160        assert!(infer_ultralytics_schema(&build(Quantization {
2161            scale: vec![0.02],
2162            zero_point: None,
2163            axis: None,
2164            dtype: Some(DType::Int8),
2165        }))
2166        .is_ok());
2167    }
2168
2169    #[test]
2170    fn infer_pre_nms_is_independent_of_output_order() {
2171        // An export may publish more than one anchor-shaped rank-3 output:
2172        // a `[1, 4, A]` box tensor beside the `[1, 4+nc, A]` detection
2173        // tensor, or a `[1, k, A]` mask-coefficient tensor. Scanning must
2174        // not stop at the first near-miss, or the result depends on the
2175        // order the runtime happened to report its outputs in.
2176        let det = TensorInfo {
2177            name: "output0".into(),
2178            shape: vec![1, 84, 8400],
2179            dtype: DType::Float32,
2180            quantization: None,
2181        };
2182        let aux = TensorInfo {
2183            name: "boxes".into(),
2184            shape: vec![1, 4, 8400],
2185            dtype: DType::Float32,
2186            quantization: None,
2187        };
2188        let build = |outputs: Vec<TensorInfo>| ModelSignals {
2189            source: ModelSource::Onnx,
2190            inputs: vec![TensorInfo {
2191                name: "images".into(),
2192                shape: vec![1, 3, 640, 640],
2193                dtype: DType::Float32,
2194                quantization: None,
2195            }],
2196            outputs,
2197            metadata: synthetic_metadata(80, "detect", "False"),
2198        };
2199
2200        let det_first = infer_ultralytics_schema(&build(vec![det.clone(), aux.clone()]))
2201            .expect("detection tensor first must classify");
2202        let aux_first = infer_ultralytics_schema(&build(vec![aux, det]))
2203            .expect("an auxiliary anchor-shaped tensor first must not shadow the detection tensor");
2204        assert_eq!(det_first.schema.outputs[0].name, Some("output0".into()));
2205        assert_eq!(aux_first.schema.outputs[0].name, Some("output0".into()));
2206        assert_eq!(det_first.schema, aux_first.schema);
2207    }
2208
2209    #[test]
2210    fn infer_class_count_mismatch_survives_a_full_scan() {
2211        // When nothing matches, the anchor-shaped near-miss is still the
2212        // more useful diagnostic than "no output matches the layout".
2213        let s = ModelSignals {
2214            source: ModelSource::Onnx,
2215            inputs: vec![TensorInfo {
2216                name: "images".into(),
2217                shape: vec![1, 3, 640, 640],
2218                dtype: DType::Float32,
2219                quantization: None,
2220            }],
2221            outputs: vec![TensorInfo {
2222                name: "output0".into(),
2223                shape: vec![1, 84, 8400],
2224                dtype: DType::Float32,
2225                quantization: None,
2226            }],
2227            // 10 classes would need feat == 14, not the 84 present.
2228            metadata: synthetic_metadata(10, "detect", "False"),
2229        };
2230        assert!(matches!(
2231            infer_ultralytics_schema(&s),
2232            Err(InferError::ClassCountMismatch {
2233                expected: 10,
2234                found: 80
2235            })
2236        ));
2237    }
2238
2239    /// What every captured fixture must infer to, asserted field by field.
2240    ///
2241    /// `yolo11n-seg` and `yolov8n-seg_int8` previously appeared only in
2242    /// `tests/infer_builder.rs`'s "did the builder accept it" loop, which
2243    /// passes for any schema that parses -- so the only v11 segmentation
2244    /// fixture and the only int8 *segmentation* fixture had no field-level
2245    /// coverage at all. A table keeps every fixture honest at once, and
2246    /// `every_fixture_is_asserted` below makes an unasserted one impossible
2247    /// to add.
2248    struct FixtureExpectation {
2249        name: &'static str,
2250        version: DecoderVersion,
2251        det_type: LogicalType,
2252        /// Detection tensor `dshape`, in physical order.
2253        det_dshape: &'static [(DimName, usize)],
2254        normalized: bool,
2255        nms: Option<NmsMode>,
2256        /// Proto channel count and spatial dims, for segmentation fixtures.
2257        proto: Option<(usize, usize, usize)>,
2258    }
2259
2260    const A: usize = 8400; // 640x640 anchors: 80² + 40² + 20²
2261    const NC: usize = 80;
2262
2263    fn fixture_expectations() -> Vec<FixtureExpectation> {
2264        use DimName::{Batch, NumBoxes, NumFeatures};
2265        // Every captured export is features-first ([1, 4+nc, A]); no real
2266        // exporter transposes to anchors-first, despite an early draft of
2267        // these tests assuming TFLite did.
2268        const DET: &[(DimName, usize)] = &[(Batch, 1), (NumFeatures, 4 + NC), (NumBoxes, A)];
2269        const SEG: &[(DimName, usize)] = &[(Batch, 1), (NumFeatures, 4 + NC + 32), (NumBoxes, A)];
2270        // End-to-end heads emit [1, max_det, 6] (+32 mask coefficients).
2271        const E2E: &[(DimName, usize)] = &[(Batch, 1), (NumBoxes, 300), (NumFeatures, 6)];
2272        const E2E_SEG: &[(DimName, usize)] = &[(Batch, 1), (NumBoxes, 300), (NumFeatures, 6 + 32)];
2273
2274        let pre_nms = |name, det_dshape, normalized, proto| FixtureExpectation {
2275            name,
2276            version: DecoderVersion::Yolov8,
2277            det_type: LogicalType::Detection,
2278            det_dshape,
2279            normalized,
2280            nms: Some(NmsMode::ClassAware),
2281            proto,
2282        };
2283        let e2e = |name, det_dshape, normalized, proto| FixtureExpectation {
2284            name,
2285            version: DecoderVersion::Yolo26,
2286            det_type: LogicalType::Detections,
2287            det_dshape,
2288            normalized,
2289            // End-to-end heads ran NMS in-graph; the schema adds none.
2290            nms: None,
2291            proto,
2292        };
2293        // Ultralytics ONNX exports are pixel-space, TFLite exports [0,1];
2294        // proto is (k, H/4, W/4).
2295        const P: Option<(usize, usize, usize)> = Some((32, 160, 160));
2296        vec![
2297            pre_nms("yolov8n", DET, false, None),
2298            pre_nms("yolo11n", DET, false, None),
2299            pre_nms("yolov8n-seg", SEG, false, P),
2300            pre_nms("yolo11n-seg", SEG, false, P),
2301            pre_nms("yolov8n_float32", DET, true, None),
2302            pre_nms("yolov8n_int8", DET, true, None),
2303            pre_nms("yolov8n-seg_float32", SEG, true, P),
2304            pre_nms("yolov8n-seg_int8", SEG, true, P),
2305            e2e("yolo26n", E2E, false, None),
2306            e2e("yolo26n-seg", E2E_SEG, false, P),
2307            e2e("yolo26n_float32", E2E, true, None),
2308            // Same network, different exporter default. The `yolo26n` row above
2309            // was captured from ultralytics 8.4.137, whose `nms` arg defaulted to
2310            // `False`; `exporter.py`'s `model.end2end = self.args.nms is False`
2311            // then selected the one2one (NMS-free, [1, 300, 6]) head. In 8.4.153
2312            // that default is `None`, so the same expression is false and the
2313            // one2many anchor-grid head ships instead -- hence a Yolov8-shaped
2314            // pre-NMS row for a YOLO26 checkpoint. Both heads live in the weights.
2315            pre_nms("yolo26n_coreml", DET, false, None),
2316        ]
2317    }
2318
2319    #[test]
2320    fn every_captured_fixture_infers_the_expected_schema() {
2321        for want in fixture_expectations() {
2322            let name = want.name;
2323            let r = infer_ultralytics_schema(&signals_from_fixture(name))
2324                .unwrap_or_else(|e| panic!("{name}: inference failed: {e}"));
2325
2326            assert_eq!(r.labels.len(), NC, "{name}: class count");
2327            assert_eq!(
2328                r.labels[0], "person",
2329                "{name}: labels must be index-ordered"
2330            );
2331            assert_eq!(r.labels[NC - 1], "toothbrush", "{name}: labels index order");
2332            assert_eq!(
2333                r.schema.decoder_version,
2334                Some(want.version),
2335                "{name}: version"
2336            );
2337            assert_eq!(r.schema.nms, want.nms, "{name}: nms mode");
2338
2339            let det = &r.schema.outputs[0];
2340            assert_eq!(det.type_, Some(want.det_type), "{name}: detection type");
2341            assert_eq!(det.dshape, want.det_dshape, "{name}: detection dshape");
2342            assert_eq!(det.normalized, Some(want.normalized), "{name}: normalized");
2343            assert_eq!(
2344                det.decoder,
2345                Some(DecoderKind::Ultralytics),
2346                "{name}: decoder"
2347            );
2348            assert_eq!(det.encoding, Some(BoxEncoding::Direct), "{name}: encoding");
2349            assert_eq!(
2350                det.score_format,
2351                Some(ScoreFormat::PerClass),
2352                "{name}: scores"
2353            );
2354
2355            match want.proto {
2356                None => {
2357                    assert_eq!(r.schema.outputs.len(), 1, "{name}: detection-only");
2358                }
2359                Some((k, ph, pw)) => {
2360                    assert_eq!(r.schema.outputs.len(), 2, "{name}: detection + proto");
2361                    let proto = &r.schema.outputs[1];
2362                    assert_eq!(proto.type_, Some(LogicalType::Protos), "{name}: proto type");
2363                    assert_eq!(
2364                        proto.dshape,
2365                        vec![
2366                            (DimName::Batch, 1),
2367                            (DimName::NumProtos, k),
2368                            (DimName::Height, ph),
2369                            (DimName::Width, pw),
2370                        ],
2371                        "{name}: proto dshape"
2372                    );
2373                }
2374            }
2375
2376            let task = if want.proto.is_some() {
2377                "segment"
2378            } else {
2379                "detect"
2380            };
2381            assert!(
2382                r.description.contains(task) && r.description.contains("Ultralytics"),
2383                "{name}: description was `{}`",
2384                r.description
2385            );
2386        }
2387    }
2388
2389    #[test]
2390    fn every_fixture_is_asserted() {
2391        // Without this, a fixture can be added (or renamed) and silently
2392        // never asserted on -- which is exactly how `yolo11n-seg` and
2393        // `yolov8n-seg_int8` ended up with no field-level coverage.
2394        let dir = infer_fixture_dir();
2395        let mut on_disk: Vec<String> = std::fs::read_dir(&dir)
2396            .unwrap_or_else(|e| panic!("{} exists: {e}", dir.display()))
2397            .filter_map(|e| {
2398                let name = e.ok()?.file_name().into_string().ok()?;
2399                name.strip_suffix(".signals.json").map(str::to_string)
2400            })
2401            .collect();
2402        on_disk.sort();
2403
2404        let mut asserted: Vec<String> = fixture_expectations()
2405            .iter()
2406            .map(|f| f.name.to_string())
2407            .collect();
2408        asserted.sort();
2409
2410        assert_eq!(
2411            on_disk, asserted,
2412            "every *.signals.json fixture must appear in fixture_expectations()"
2413        );
2414    }
2415
2416    #[test]
2417    fn infer_other_source_refuses_rather_than_guessing_normalization() {
2418        // `normalized` follows the exporter and cannot be read off the
2419        // shapes. ONNX (pixel-space) and TFLite ([0,1]) are measured;
2420        // CoreML (pixel-space) is both source-traced and measured. An
2421        // uncharacterized container gets a
2422        // typed refusal, because guessing scales every box by the input
2423        // size and the resulting schema looks perfectly valid.
2424        let s = ModelSignals {
2425            source: ModelSource::Other,
2426            inputs: vec![TensorInfo {
2427                name: "images".into(),
2428                shape: vec![1, 3, 640, 640],
2429                dtype: DType::Float32,
2430                quantization: None,
2431            }],
2432            outputs: vec![TensorInfo {
2433                name: "output0".into(),
2434                shape: vec![1, 84, 8400],
2435                dtype: DType::Float32,
2436                quantization: None,
2437            }],
2438            metadata: synthetic_metadata(80, "detect", "False"),
2439        };
2440        assert!(matches!(
2441            infer_ultralytics_schema(&s),
2442            Err(InferError::UnknownBoxConvention)
2443        ));
2444
2445        // The same signals under a characterized source succeed, so the
2446        // refusal is about the container and nothing else.
2447        let ok = ModelSignals {
2448            source: ModelSource::Onnx,
2449            ..s
2450        };
2451        assert_eq!(
2452            infer_ultralytics_schema(&ok).unwrap().schema.outputs[0].normalized,
2453            Some(false)
2454        );
2455    }
2456
2457    #[test]
2458    fn infer_per_channel_quantization_rejected() {
2459        // Per-channel quantization on a boundary output is refused here
2460        // rather than passed through. The decoder supports per-tensor only
2461        // (`the v1 decoder only supports per-tensor quantization`), and it
2462        // refuses per-channel with OR without an `axis` -- so emitting the
2463        // schema anyway would defer the failure to `DecoderBuilder::build`,
2464        // where the message names the decoder instead of the tensor that
2465        // caused it. No captured fixture exhibits this; a future export
2466        // convention might.
2467        let s = ModelSignals {
2468            source: ModelSource::TfLite,
2469            inputs: vec![TensorInfo {
2470                name: "images".into(),
2471                shape: vec![1, 640, 640, 3],
2472                dtype: DType::Float32,
2473                quantization: None,
2474            }],
2475            outputs: vec![TensorInfo {
2476                name: "output0".into(),
2477                shape: vec![1, 84, 8400],
2478                dtype: DType::Int8,
2479                quantization: Some(Quantization {
2480                    scale: vec![0.02, 0.03],
2481                    zero_point: Some(vec![-5, -7]),
2482                    axis: Some(1),
2483                    dtype: Some(DType::Int8),
2484                }),
2485            }],
2486            metadata: synthetic_metadata(80, "detect", "False"),
2487        };
2488        match infer_ultralytics_schema(&s) {
2489            Err(InferError::UnsupportedQuantization(msg)) => {
2490                assert!(msg.contains("per-channel"), "unexpected message: {msg}");
2491                assert!(
2492                    msg.contains("output0"),
2493                    "message must name the tensor: {msg}"
2494                );
2495            }
2496            other => panic!("expected UnsupportedQuantization, got {other:?}"),
2497        }
2498    }
2499
2500    #[test]
2501    fn infer_per_channel_proto_quantization_rejected() {
2502        // The same guard runs on the proto tensor, not just the detection
2503        // output -- `check_quantized_boundary` is called for both.
2504        let s = ModelSignals {
2505            source: ModelSource::TfLite,
2506            inputs: vec![TensorInfo {
2507                name: "images".into(),
2508                shape: vec![1, 640, 640, 3],
2509                dtype: DType::Float32,
2510                quantization: None,
2511            }],
2512            outputs: vec![
2513                TensorInfo {
2514                    name: "output0".into(),
2515                    shape: vec![1, 116, 8400],
2516                    dtype: DType::Float32,
2517                    quantization: None,
2518                },
2519                TensorInfo {
2520                    name: "output1".into(),
2521                    shape: vec![1, 32, 160, 160],
2522                    dtype: DType::Int8,
2523                    quantization: Some(Quantization {
2524                        scale: vec![0.02, 0.03],
2525                        zero_point: Some(vec![-5, -7]),
2526                        axis: Some(1),
2527                        dtype: Some(DType::Int8),
2528                    }),
2529                },
2530            ],
2531            metadata: synthetic_metadata(80, "segment", "False"),
2532        };
2533        match infer_ultralytics_schema(&s) {
2534            Err(InferError::UnsupportedQuantization(msg)) => {
2535                assert!(
2536                    msg.contains("output1"),
2537                    "message must name the proto: {msg}"
2538                );
2539            }
2540            other => panic!("expected UnsupportedQuantization, got {other:?}"),
2541        }
2542    }
2543
2544    #[test]
2545    fn infer_integer_output_without_quant_rejected() {
2546        // Same synthetic signals as above but with `quantization: None` —
2547        // an integer-dtype boundary output with no quantization params is
2548        // never silently accepted (there'd be no way to dequantize it).
2549        let s = ModelSignals {
2550            source: ModelSource::TfLite,
2551            inputs: vec![TensorInfo {
2552                name: "images".into(),
2553                shape: vec![1, 640, 640, 3],
2554                dtype: DType::Float32,
2555                quantization: None,
2556            }],
2557            outputs: vec![TensorInfo {
2558                name: "output0".into(),
2559                shape: vec![1, 84, 8400],
2560                dtype: DType::Int8,
2561                quantization: None,
2562            }],
2563            metadata: synthetic_metadata(80, "detect", "False"),
2564        };
2565        match infer_ultralytics_schema(&s) {
2566            Err(InferError::UnsupportedLayout(msg)) => {
2567                assert!(
2568                    msg.contains("quantization"),
2569                    "unexpected UnsupportedLayout message: {msg}"
2570                );
2571            }
2572            other => panic!("expected UnsupportedLayout, got {other:?}"),
2573        }
2574    }
2575
2576    #[test]
2577    fn infer_pre_nms_rejects_non_unit_batch() {
2578        // A [2, 84, 8400] output has a matching anchors/feature pair but a
2579        // non-unit leading (batch) dim. It must never be silently accepted
2580        // and mislabeled batch-1 (find_e2e_candidate already requires
2581        // shape[0] == 1; find_anchors_dim must match it).
2582        let mut s = signals_from_fixture("yolov8n");
2583        s.outputs[0].shape = vec![2, 84, 8400];
2584        assert!(matches!(
2585            infer_ultralytics_schema(&s),
2586            Err(InferError::UnsupportedLayout(_))
2587        ));
2588    }
2589
2590    #[test]
2591    fn coreml_source_resolves_a_box_convention() {
2592        // CoreML must not hit the `Other` refusal: a native `.mlpackage`
2593        // export is a characterized convention, not an unknown one.
2594        let s = ModelSignals {
2595            source: ModelSource::CoreMl,
2596            inputs: vec![TensorInfo {
2597                name: "images".into(),
2598                shape: vec![1, 3, 640, 640],
2599                dtype: DType::Float16,
2600                quantization: None,
2601            }],
2602            outputs: vec![TensorInfo {
2603                name: "output0".into(),
2604                shape: vec![1, 84, 8400],
2605                dtype: DType::Float16,
2606                quantization: None,
2607            }],
2608            metadata: synthetic_metadata(80, "detect", "False"),
2609        };
2610        let r = infer_ultralytics_schema(&s)
2611            .expect("CoreML signals must infer, not raise UnknownBoxConvention");
2612        let o = &r.schema.outputs[0];
2613        assert_eq!(
2614            o.normalized,
2615            Some(false),
2616            "Ultralytics CoreML exports trace the raw model, so boxes are \
2617             pixel-space exactly as in the ONNX export"
2618        );
2619    }
2620
2621    #[test]
2622    fn coreml_nms_pipeline_export_is_refused_not_classified() {
2623        // `yolo export ... format=coreml nms=True` does not produce the
2624        // anchor-grid tensor `ModelSource::CoreMl`'s pixel-space
2625        // resolution reasons about -- it substitutes Apple's own NMS
2626        // pipeline, whose two outputs are dynamically shaped and already
2627        // post-NMS. Real measured shapes (Task 3b brief, from a genuine
2628        // `yolo26n.pt` export): `confidence` and `coordinates` are both
2629        // float32 multiArrays with an empty (fully dynamic) shape --
2630        // nothing here is the `[1, 4+nc, A]` layout this module looks
2631        // for, so inference must refuse it rather than guess. This
2632        // refusal is intentional: do not "fix" it into acceptance, since
2633        // that would apply CoreMl's pixel-space convention (or none at
2634        // all) to output Apple's pipeline has already decoded and scaled
2635        // its own way.
2636        let s = ModelSignals {
2637            source: ModelSource::CoreMl,
2638            inputs: vec![TensorInfo {
2639                name: "image".into(),
2640                shape: vec![1, 3, 640, 640],
2641                dtype: DType::Float16,
2642                quantization: None,
2643            }],
2644            outputs: vec![
2645                TensorInfo {
2646                    name: "confidence".into(),
2647                    shape: vec![],
2648                    dtype: DType::Float32,
2649                    quantization: None,
2650                },
2651                TensorInfo {
2652                    name: "coordinates".into(),
2653                    shape: vec![],
2654                    dtype: DType::Float32,
2655                    quantization: None,
2656                },
2657            ],
2658            metadata: synthetic_metadata(80, "detect", "False"),
2659        };
2660        assert!(matches!(
2661            infer_ultralytics_schema(&s),
2662            Err(InferError::UnsupportedLayout(_))
2663        ));
2664    }
2665}