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