Skip to main content

edgefirst_decoder/
infer.rs

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