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