Skip to main content

quantize_rs/onnx_utils/
graph_builder.rs

1//! Graph-level operations for quantized ONNX models.
2//!
3//! Three responsibilities:
4//!   1. **QDQ transform** — replace FP32 initializers with INT8 + DequantizeLinear
5//!   2. **Connectivity validation** — walk the graph and verify every edge resolves
6//!   3. **Opset management** — ensure the model declares a sufficient opset and
7//!      upgrades deprecated op attributes when bumping across breaking opset boundaries
8
9use crate::errors::{QuantizeError, Result};
10use crate::onnx_proto::{
11    attribute_proto, tensor_proto, AttributeProto, GraphProto, ModelProto, OperatorSetIdProto,
12    TensorProto,
13};
14use std::collections::{HashMap, HashSet};
15
16use super::quantization_nodes::{
17    build_dequantize_linear_node, build_quantized_weight_tensor, build_scale_tensor,
18    build_zero_point_tensor, DequantLinearNames, StorageFormat,
19};
20
21// ===========================================================================
22// Public types
23// ===========================================================================
24
25/// One weight to convert: FP32 initializer → INT8 + DequantizeLinear block.
26///
27/// Fields are public for ergonomic construction in callers; new fields are
28/// added only on a minor version bump and downstream `..Default::default()`
29/// callers continue to compile.  Always construct via
30/// `QdqWeightInput { /* fields */, ..Default::default() }`.
31#[derive(Debug, Clone, Default)]
32pub struct QdqWeightInput {
33    /// Original initializer name (e.g., `"conv1.weight"`)
34    pub original_name: String,
35    /// Quantized values as i8.  For INT4 these are in [-8, 7]; for INT8 in [-128, 127].
36    /// Always unpacked — one value per element.
37    pub quantized_values: Vec<i8>,
38    /// Quantization scales (FP32).
39    /// Length 1 for per-tensor; one per channel for per-channel.
40    pub scales: Vec<f32>,
41    /// Zero points (INT8).
42    /// Same length as `scales`.
43    pub zero_points: Vec<i8>,
44    /// Original bit-width (4 or 8).  Informational only — ONNX storage is always INT8.
45    /// Persisted in model metadata so `load_quantized_info` can recover it.
46    pub bits: u8,
47    /// Per-channel quantization axis, or `None` for per-tensor.
48    pub axis: Option<usize>,
49}
50
51/// Options controlling how a quantized model is written to disk.
52///
53/// Marked `#[non_exhaustive]` so future save options can be added without a
54/// breaking change.  Construct with [`SaveOptions::default()`] and the
55/// `.with_*()` builders.
56#[derive(Debug, Clone, Copy, Default)]
57#[non_exhaustive]
58pub struct SaveOptions {
59    /// Use native ONNX INT4 storage (opset 21, `DataType::Int4`) instead of
60    /// widening INT4 values into INT8 bytes.
61    ///
62    /// Native storage halves the on-disk size of INT4-quantized weights
63    /// (true 8× compression from FP32) but raises the required opset to 21.
64    /// Older runtimes without opset 21 support will refuse to load the model.
65    /// Defaults to `false` for backward compatibility.
66    ///
67    /// Only affects weights where [`QdqWeightInput::bits`] is 4; INT8 weights
68    /// are stored as INT8 regardless of this flag.
69    pub native_int4: bool,
70}
71
72impl SaveOptions {
73    /// Opt in to native INT4 storage (requires opset 21).
74    pub fn with_native_int4(mut self, enabled: bool) -> Self {
75        self.native_int4 = enabled;
76        self
77    }
78}
79
80/// Result of a graph-connectivity check.
81#[derive(Debug)]
82#[must_use]
83pub struct ConnectivityReport {
84    /// `true` if every node input resolves to a known tensor.
85    pub valid: bool,
86    /// Human-readable description of every dangling reference.  Empty when valid.
87    pub broken_refs: Vec<String>,
88}
89
90impl ConnectivityReport {
91    /// Render the report as a printable string (useful for CLI `validate` output).
92    pub fn summary(&self) -> String {
93        if self.valid {
94            "  Graph connectivity: OK\n".to_string()
95        } else {
96            let mut s = format!(
97                "  Graph connectivity: BROKEN ({} dangling reference{})\n",
98                self.broken_refs.len(),
99                if self.broken_refs.len() == 1 { "" } else { "s" }
100            );
101            for (i, r) in self.broken_refs.iter().enumerate() {
102                s.push_str(&format!("    {}. {}\n", i + 1, r));
103            }
104            s
105        }
106    }
107}
108
109// ===========================================================================
110// Connectivity validation
111// ===========================================================================
112
113/// Walk the graph and verify every node input resolves to *something*.
114///
115/// A valid input is exactly one of:
116///   • a declared graph input (`graph.input`)
117///   • an initializer name (`graph.initializer`)
118///   • the output of a node that appears **earlier** in `graph.node`
119///
120/// This is the check ONNX Runtime performs on load — and the check that
121/// v0.2.0's `validate` command skipped, letting the rename bug through.
122pub(crate) fn validate_graph_connectivity(graph: &GraphProto) -> ConnectivityReport {
123    let mut known: HashSet<String> = HashSet::new();
124
125    // Seed: graph inputs + initializers are always available
126    for inp in &graph.input {
127        known.insert(inp.name.clone());
128    }
129    for init in &graph.initializer {
130        known.insert(init.name.clone());
131    }
132
133    let mut broken = Vec::new();
134
135    // Walk nodes in serialized order; each node's outputs become known afterwards
136    for node in &graph.node {
137        for name in &node.input {
138            if name.is_empty() {
139                continue; // optional input slot — empty string is valid
140            }
141            if !known.contains(name.as_str()) {
142                broken.push(format!(
143                    "Node '{}' (op={}) → unknown input '{}'",
144                    node.name, node.op_type, name
145                ));
146            }
147        }
148        // Register outputs so later nodes can consume them
149        for name in &node.output {
150            if !name.is_empty() {
151                known.insert(name.clone());
152            }
153        }
154    }
155
156    ConnectivityReport {
157        valid: broken.is_empty(),
158        broken_refs: broken,
159    }
160}
161
162// ===========================================================================
163// Opset version management
164// ===========================================================================
165
166/// Ensure the default ONNX domain opset is at least `min_version`.
167///
168/// DequantizeLinear requires opset ≥ 10 (per-tensor) or ≥ 13 (per-channel axis).
169///
170/// When bumping the opset past a breaking boundary, this function also
171/// upgrades deprecated op attributes:
172///   - **opset 9**: `BatchNormalization.spatial` removed (was always 1)
173///   - **opset 12**: `Dropout.ratio` migrated from attribute to input
174pub(crate) fn ensure_opset_version(model: &mut ModelProto, min_version: i64) {
175    let old_version = get_opset_version(model);
176
177    // Update or insert the default-domain opset entry
178    let mut found = false;
179    for opset in model.opset_import.iter_mut() {
180        if opset.domain.is_empty() {
181            if opset.version < min_version {
182                opset.version = min_version;
183            }
184            found = true;
185            break;
186        }
187    }
188    if !found {
189        model.opset_import.push(OperatorSetIdProto {
190            domain: String::new(),
191            version: min_version,
192        });
193    }
194
195    // Strip deprecated attributes when crossing breaking opset boundaries
196    if old_version < min_version {
197        if let Some(graph) = model.graph.as_mut() {
198            // Warn about ops we *can't* auto-migrate before rewriting the ones
199            // we can.  (`&mut graph` reborrows as `&graph` for the read.)
200            let unhandled = unhandled_opset_migrations(graph, old_version, min_version);
201            if !unhandled.is_empty() {
202                warn_unhandled_opset_migrations(old_version, min_version, &unhandled);
203            }
204            upgrade_deprecated_ops(graph, old_version, min_version);
205        }
206    }
207
208    // Keep `ir_version` consistent with the (possibly bumped) opset.  Native
209    // INT4 emits opset 21, whose `INT4`/`UINT4` tensor types require IR ≥ 10; a
210    // model that advertises opset 21 but an older IR is out of spec (current
211    // ONNX Runtime tolerates it, stricter/older/future ones may not).  Only ever
212    // raise — never lower an already-newer IR — and base it on the *effective*
213    // opset so a model already above `min_version` is still covered.
214    let effective_opset = old_version.max(min_version);
215    let min_ir = min_ir_version_for_opset(effective_opset);
216    if model.ir_version < min_ir {
217        model.ir_version = min_ir;
218    }
219}
220
221/// Get the current default-domain opset version (0 if not present).
222fn get_opset_version(model: &ModelProto) -> i64 {
223    model
224        .opset_import
225        .iter()
226        .find(|o| o.domain.is_empty())
227        .map_or(0, |o| o.version)
228}
229
230/// Minimum ONNX IR version required to declare a given default-domain opset,
231/// per the ONNX spec's opset↔IR mapping (`docs/Versioning.md`).
232///
233/// This matters because bumping the opset alone is not enough: a model that
234/// advertises opset 21 (which is where the `INT4`/`UINT4` tensor types and
235/// native-INT4 `DequantizeLinear` were introduced) but keeps an older
236/// `ir_version` is out of spec.  Lenient runtimes (current ONNX Runtime)
237/// accept it, but stricter or future ones may not.  [`ensure_opset_version`]
238/// uses this to raise `ir_version` in lock-step with the opset it sets.
239fn min_ir_version_for_opset(opset: i64) -> i64 {
240    match opset {
241        ..=8 => 3,
242        9 => 4,
243        10 => 5,
244        11 => 6,
245        12..=14 => 7,
246        15..=18 => 8,
247        19..=20 => 9,
248        21..=22 => 10,
249        _ => 11, // opset 23+ → IR 11 (safe floor for anything newer)
250    }
251}
252
253/// Operators whose schema changed in a backward-incompatible way at the given
254/// opset (an attribute became an input, or the op was removed/renamed) and that
255/// [`upgrade_deprecated_ops`] does **not** rewrite.
256///
257/// When a save bumps the declared opset across one of these boundaries, the node
258/// keeps its old-opset form but the model now advertises the new opset — which
259/// ONNX Runtime may reject on load.  Auto-migrating them correctly needs ONNX's
260/// full version converter, so quantize-rs warns instead of silently emitting a
261/// model that might not load.
262///
263/// `BatchNormalization`, `Dropout`, `Softmax`, and `LogSoftmax` are intentionally
264/// absent — [`upgrade_deprecated_ops`] already handles those.
265const UNHANDLED_OPSET_MIGRATIONS: &[(&str, i64)] = &[
266    ("Upsample", 10),  // removed in opset 10 (replaced by Resize)
267    ("Slice", 10),     // starts/ends/axes/steps: attribute → input
268    ("TopK", 10),      // K: attribute → input
269    ("Resize", 11),    // roi/scales/sizes signature change
270    ("Pad", 11),       // pads/value: attribute → input
271    ("Clip", 11),      // min/max: attribute → input
272    ("Scatter", 11),   // deprecated → ScatterElements
273    ("Split", 13),     // split: attribute → input
274    ("Squeeze", 13),   // axes: attribute → input
275    ("Unsqueeze", 13), // axes: attribute → input
276    ("ReduceSum", 13), // axes: attribute → input
277];
278
279/// Operators present in `graph` whose unhandled migration boundary falls inside
280/// `(old_opset, new_opset]`.  Returns `(op_type, boundary_opset)` pairs, each op
281/// at most once.  Pure and testable; the user-facing warning is emitted by
282/// [`warn_unhandled_opset_migrations`].
283fn unhandled_opset_migrations(
284    graph: &GraphProto,
285    old_opset: i64,
286    new_opset: i64,
287) -> Vec<(&'static str, i64)> {
288    let mut hits: Vec<(&'static str, i64)> = Vec::new();
289    for &(op, boundary) in UNHANDLED_OPSET_MIGRATIONS {
290        if old_opset < boundary
291            && new_opset >= boundary
292            && graph.node.iter().any(|n| n.op_type == op)
293            && !hits.iter().any(|(o, _)| *o == op)
294        {
295            hits.push((op, boundary));
296        }
297    }
298    hits
299}
300
301/// Emit a single stderr warning describing operators that may break when the
302/// declared opset is bumped (see [`unhandled_opset_migrations`]).
303fn warn_unhandled_opset_migrations(old: i64, new: i64, hits: &[(&str, i64)]) {
304    let list = hits
305        .iter()
306        .map(|&(op, b)| format!("{op} (changed at opset {b})"))
307        .collect::<Vec<_>>()
308        .join(", ");
309    log::warn!(
310        "bumping the model opset from {old} to {new} to emit DequantizeLinear, but the graph \
311         contains operator(s) whose schema changed at an opset in that range and which \
312         quantize-rs does not rewrite: {list}.  The saved model will declare opset {new} while \
313         keeping the older node form, so ONNX Runtime may reject it.  If it does, run the model \
314         through onnx.version_converter (or re-export at opset {new}+) before quantizing."
315    );
316}
317
318/// Upgrade graph nodes whose attribute semantics changed between `old_opset`
319/// and `new_opset`.
320///
321/// - **BatchNormalization** (opset 9): `spatial` attribute removed (was always 1)
322/// - **Dropout** (opset 12): `ratio` attribute → 2nd input
323/// - **Softmax / LogSoftmax** (opset 13): default axis changed from 1 to -1
324fn upgrade_deprecated_ops(graph: &mut GraphProto, old_opset: i64, new_opset: i64) {
325    let mut new_initializers: Vec<TensorProto> = Vec::new();
326
327    for (node_idx, node) in graph.node.iter_mut().enumerate() {
328        // Opset 9: BatchNormalization removed the `spatial` attribute.
329        // It was always 1 (the only valid value) and had no effect.
330        if node.op_type == "BatchNormalization" && old_opset < 9 && new_opset >= 9 {
331            node.attribute.retain(|a| a.name != "spatial");
332        }
333
334        // Opset 12: Dropout `ratio` moved from attribute to 2nd input.
335        // Extract the value, remove the attribute, and wire in a constant.
336        if node.op_type == "Dropout" && old_opset < 12 && new_opset >= 12 {
337            let ratio = node
338                .attribute
339                .iter()
340                .find(|a| a.name == "ratio")
341                .map(|a| a.f)
342                .unwrap_or(0.5);
343            node.attribute.retain(|a| a.name != "ratio");
344
345            // Include the node index so two Dropouts with the same
346            // (or empty) first output don't collide on the initializer name.
347            let init_name = format!(
348                "_quantize_rs_dropout_ratio_{}_{}",
349                node_idx,
350                node.output.first().map_or("", |s| s.as_str()),
351            );
352            new_initializers.push(TensorProto {
353                name: init_name.clone(),
354                data_type: tensor_proto::DataType::Float as i32,
355                float_data: vec![ratio],
356                ..Default::default()
357            });
358
359            if node.input.len() < 2 {
360                node.input.push(init_name);
361            } else {
362                node.input[1] = init_name;
363            }
364        }
365
366        // Opset 13: Softmax/LogSoftmax default axis changed from 1 to -1.
367        // If the node has no explicit axis attribute, add axis=1 to preserve
368        // the old behavior.
369        if (node.op_type == "Softmax" || node.op_type == "LogSoftmax")
370            && old_opset < 13
371            && new_opset >= 13
372        {
373            let has_axis = node.attribute.iter().any(|a| a.name == "axis");
374            if !has_axis {
375                node.attribute.push(AttributeProto {
376                    name: "axis".to_string(),
377                    r#type: attribute_proto::AttributeType::Int as i32,
378                    i: 1, // old default
379                    ..Default::default()
380                });
381            }
382        }
383    }
384
385    graph.initializer.extend(new_initializers);
386}
387
388// ===========================================================================
389// QDQ transform
390// ===========================================================================
391
392/// Replace FP32 weight initializers with INT8 quantized equivalents +
393/// DequantizeLinear nodes.
394///
395/// ### What happens per weight in `inputs`:
396///
397/// **Removed:**
398///   - Initializer `"{name}"` (the original FP32 weight data)
399///
400/// **Added (initializers):**
401///   - `"{name}_quantized"` — INT8, same shape as original
402///   - `"{name}_scale"`     — FP32 scalar
403///   - `"{name}_zp"`        — INT8 scalar
404///
405/// **Added (node, prepended before all existing nodes):**
406///   - `DequantizeLinear` with output = `"{name}"`
407///
408/// Because the DequantizeLinear output carries the **original** name, every
409/// downstream node (Conv, MatMul, BatchNorm, …) remains completely unchanged.
410/// Graph connectivity is preserved by construction.
411///
412/// ---
413/// ### INT4 storage note
414///
415/// ONNX `DequantizeLinear` requires INT8 input in opsets < 21.  By default,
416/// INT4-quantized values (range [-8, 7]) are widened to INT8 here — 4×
417/// compression from FP32.  To get the full 8× compression, pass
418/// [`SaveOptions::with_native_int4`]`(true)` to [`apply_qdq_transform_with_options`];
419/// that emits native `DataType::Int4` (opset 21) with two values packed per byte.
420#[cfg(test)]
421pub(crate) fn apply_qdq_transform(graph: &mut GraphProto, inputs: &[QdqWeightInput]) -> Result<()> {
422    apply_qdq_transform_with_options(graph, inputs, SaveOptions::default())
423}
424
425/// Same as [`apply_qdq_transform`] but configurable via [`SaveOptions`].
426///
427/// Prefer this entry point for any new code; the short-form wrapper exists
428/// only for backward compatibility.
429pub(crate) fn apply_qdq_transform_with_options(
430    graph: &mut GraphProto,
431    inputs: &[QdqWeightInput],
432    options: SaveOptions,
433) -> Result<()> {
434    // -----------------------------------------------------------------------
435    // 0.  Snapshot shapes before modifying the initializer list
436    // -----------------------------------------------------------------------
437    let shape_map: HashMap<String, Vec<i64>> = graph
438        .initializer
439        .iter()
440        .map(|init| (init.name.clone(), init.dims.clone()))
441        .collect();
442
443    let quant_set: HashSet<&str> = inputs.iter().map(|i| i.original_name.as_str()).collect();
444
445    // -----------------------------------------------------------------------
446    // 0b.  Pre-flight: verify every requested weight still exists as an FP32
447    //      initializer.  Failing here keeps the graph in a consistent state
448    //      so the caller can retry; without this, a missing input would
449    //      surface only after some initializers were already removed.
450    //      Also catches the "already quantized — second save call" case
451    //      with a helpful diagnostic.
452    // -----------------------------------------------------------------------
453    for inp in inputs {
454        if !shape_map.contains_key(&inp.original_name) {
455            let already_quantized = shape_map
456                .keys()
457                .any(|n| n == &format!("{}_quantized", inp.original_name));
458            return Err(QuantizeError::GraphTransform {
459                reason: if already_quantized {
460                    format!(
461                        "Weight '{}' has already been quantized in this graph \
462                         (found '{}_quantized' initializer); apply_qdq_transform \
463                         is not idempotent — load a fresh OnnxModel before retrying",
464                        inp.original_name, inp.original_name
465                    )
466                } else {
467                    format!(
468                        "Weight '{}' not found in model initializers — \
469                         verify the name matches exactly",
470                        inp.original_name
471                    )
472                },
473            });
474        }
475    }
476
477    // -----------------------------------------------------------------------
478    // 1.  Remove the original FP32 initializers for every weight we're replacing
479    // -----------------------------------------------------------------------
480    graph
481        .initializer
482        .retain(|init| !quant_set.contains(init.name.as_str()));
483
484    // -----------------------------------------------------------------------
485    // 1b. Also remove weights from graph.input (critical fix for "Duplicate definition")
486    // -----------------------------------------------------------------------
487    // Some ONNX models list weights as both initializers AND graph inputs.
488    // This is valid ONNX, but when DequantizeLinear outputs reuse the original
489    // weight names, ONNX Runtime sees two definitions of the same tensor.
490    graph
491        .input
492        .retain(|inp| !quant_set.contains(inp.name.as_str()));
493
494    // -----------------------------------------------------------------------
495    // 2.  Add quantized initializer triples + build DequantizeLinear nodes
496    // -----------------------------------------------------------------------
497    let mut dq_nodes = Vec::new();
498
499    for inp in inputs {
500        // The pre-flight loop above already verified every original_name is
501        // present, and shape_map is not mutated in between — so this is
502        // unreachable in practice. Returning a clean error rather than
503        // panicking means a future refactor that breaks the invariant degrades
504        // to a recoverable `GraphTransform` error instead of a crash.
505        let shape =
506            shape_map
507                .get(&inp.original_name)
508                .ok_or_else(|| QuantizeError::GraphTransform {
509                    reason: format!(
510                        "internal invariant violated: weight '{}' missing from shape_map \
511                     after pre-flight validation",
512                        inp.original_name
513                    ),
514                })?;
515
516        let expected_len: i64 = shape.iter().product();
517        if inp.quantized_values.len() as i64 != expected_len {
518            return Err(QuantizeError::GraphTransform {
519                reason: format!(
520                    "Weight '{}': quantized_values has {} elements but shape {:?} expects {}",
521                    inp.original_name,
522                    inp.quantized_values.len(),
523                    shape,
524                    expected_len
525                ),
526            });
527        }
528
529        let names = DequantLinearNames::from_original(&inp.original_name);
530
531        let format = if options.native_int4 && inp.bits == 4 {
532            StorageFormat::NativeInt4
533        } else {
534            StorageFormat::Int8Widened
535        };
536
537        graph.initializer.push(build_quantized_weight_tensor(
538            &names,
539            &inp.quantized_values,
540            shape,
541            format,
542        ));
543        graph
544            .initializer
545            .push(build_scale_tensor(&names, &inp.scales));
546        graph
547            .initializer
548            .push(build_zero_point_tensor(&names, &inp.zero_points, format));
549
550        dq_nodes.push(build_dequantize_linear_node(&names, inp.axis));
551    }
552
553    // -----------------------------------------------------------------------
554    // 3.  Prepend DequantizeLinear nodes before all existing computation nodes.
555    //     They must appear first so their outputs are "known" when the validator
556    //     (or ONNX Runtime) walks the node list in order.
557    // -----------------------------------------------------------------------
558    let existing_nodes = std::mem::take(&mut graph.node);
559    graph.node = dq_nodes;
560    graph.node.extend(existing_nodes);
561
562    Ok(())
563}
564
565// ===========================================================================
566// Tests
567// ===========================================================================
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572    use crate::onnx_proto::{
573        tensor_proto, GraphProto, ModelProto, NodeProto, OperatorSetIdProto, TensorProto,
574        ValueInfoProto,
575    };
576
577    // -----------------------------------------------------------------------
578    // Test helpers
579    // -----------------------------------------------------------------------
580
581    /// Minimal graph: one graph input "input", one FP32 initializer "w" (shape [2,2]),
582    /// one Conv node consuming both, producing "out".
583    fn make_simple_graph() -> GraphProto {
584        GraphProto {
585            input: vec![ValueInfoProto {
586                name: "input".to_string(),
587                ..Default::default()
588            }],
589            initializer: vec![TensorProto {
590                name: "w".to_string(),
591                data_type: tensor_proto::DataType::Float as i32,
592                dims: vec![2, 2],
593                float_data: vec![1.0, 2.0, 3.0, 4.0],
594                ..Default::default()
595            }],
596            node: vec![NodeProto {
597                op_type: "Conv".to_string(),
598                name: "conv0".to_string(),
599                input: vec!["input".to_string(), "w".to_string()],
600                output: vec!["out".to_string()],
601                ..Default::default()
602            }],
603            ..Default::default()
604        }
605    }
606
607    /// Two-weight graph: "w1" and "w2", two Conv nodes chained.
608    fn make_two_weight_graph() -> GraphProto {
609        GraphProto {
610            input: vec![ValueInfoProto {
611                name: "input".to_string(),
612                ..Default::default()
613            }],
614            initializer: vec![
615                TensorProto {
616                    name: "w1".to_string(),
617                    data_type: tensor_proto::DataType::Float as i32,
618                    dims: vec![2, 2],
619                    float_data: vec![1.0, 2.0, 3.0, 4.0],
620                    ..Default::default()
621                },
622                TensorProto {
623                    name: "w2".to_string(),
624                    data_type: tensor_proto::DataType::Float as i32,
625                    dims: vec![2, 2],
626                    float_data: vec![5.0, 6.0, 7.0, 8.0],
627                    ..Default::default()
628                },
629            ],
630            node: vec![
631                NodeProto {
632                    op_type: "Conv".to_string(),
633                    name: "conv1".to_string(),
634                    input: vec!["input".to_string(), "w1".to_string()],
635                    output: vec!["mid".to_string()],
636                    ..Default::default()
637                },
638                NodeProto {
639                    op_type: "Conv".to_string(),
640                    name: "conv2".to_string(),
641                    input: vec!["mid".to_string(), "w2".to_string()],
642                    output: vec!["out".to_string()],
643                    ..Default::default()
644                },
645            ],
646            ..Default::default()
647        }
648    }
649
650    // -----------------------------------------------------------------------
651    // Connectivity validation tests
652    // -----------------------------------------------------------------------
653
654    #[test]
655    fn test_connectivity_passes_on_valid_graph() {
656        let graph = make_simple_graph();
657        let report = validate_graph_connectivity(&graph);
658        assert!(
659            report.valid,
660            "original graph should be valid; broken: {:?}",
661            report.broken_refs
662        );
663    }
664
665    #[test]
666    fn test_connectivity_detects_renamed_initializer() {
667        // Simulate the exact v0.2.0 bug: rename "w" in the initializer list
668        // without updating the Conv node that references it.
669        let mut graph = make_simple_graph();
670
671        for init in graph.initializer.iter_mut() {
672            if init.name == "w" {
673                init.name = "w__qINT8_s0.00392_z-3_len4".to_string();
674            }
675        }
676
677        let report = validate_graph_connectivity(&graph);
678        assert!(!report.valid, "should detect broken reference to 'w'");
679        assert_eq!(report.broken_refs.len(), 1);
680        assert!(
681            report.broken_refs[0].contains("'w'"),
682            "error should mention 'w': {}",
683            report.broken_refs[0]
684        );
685    }
686
687    #[test]
688    fn test_connectivity_detects_multiple_broken_refs() {
689        let mut graph = make_two_weight_graph();
690
691        for init in graph.initializer.iter_mut() {
692            if init.name == "w1" {
693                init.name = "w1_broken".to_string();
694            } else if init.name == "w2" {
695                init.name = "w2_broken".to_string();
696            }
697        }
698
699        let report = validate_graph_connectivity(&graph);
700        assert!(!report.valid);
701        assert_eq!(report.broken_refs.len(), 2);
702    }
703
704    #[test]
705    fn test_connectivity_summary_formatting() {
706        let valid = ConnectivityReport {
707            valid: true,
708            broken_refs: vec![],
709        };
710        assert!(valid.summary().contains("OK"));
711
712        let broken = ConnectivityReport {
713            valid: false,
714            broken_refs: vec!["Node 'x' → unknown input 'y'".to_string()],
715        };
716        let s = broken.summary();
717        assert!(s.contains("BROKEN"));
718        assert!(s.contains("1 dangling reference"));
719        assert!(s.contains("unknown input 'y'"));
720    }
721
722    // -----------------------------------------------------------------------
723    // Opset version tests
724    // -----------------------------------------------------------------------
725
726    #[test]
727    fn test_ensure_opset_bumps_low_version() {
728        let mut model = ModelProto {
729            opset_import: vec![OperatorSetIdProto {
730                domain: String::new(),
731                version: 10,
732            }],
733            ..Default::default()
734        };
735
736        ensure_opset_version(&mut model, 13);
737
738        assert_eq!(model.opset_import[0].version, 13);
739    }
740
741    #[test]
742    fn test_ensure_opset_leaves_sufficient_version() {
743        let mut model = ModelProto {
744            opset_import: vec![OperatorSetIdProto {
745                domain: String::new(),
746                version: 17,
747            }],
748            ..Default::default()
749        };
750
751        ensure_opset_version(&mut model, 13);
752
753        assert_eq!(model.opset_import[0].version, 17, "should not downgrade");
754    }
755
756    #[test]
757    fn test_ensure_opset_adds_missing_default_domain() {
758        let mut model = ModelProto::default();
759        // No opset_import at all
760        ensure_opset_version(&mut model, 13);
761
762        assert_eq!(model.opset_import.len(), 1);
763        assert!(model.opset_import[0].domain.is_empty());
764        assert_eq!(model.opset_import[0].version, 13);
765    }
766
767    // -----------------------------------------------------------------------
768    // QDQ transform tests
769    // -----------------------------------------------------------------------
770
771    #[test]
772    fn test_qdq_single_weight_produces_valid_graph() {
773        let mut graph = make_simple_graph();
774
775        let inputs = vec![QdqWeightInput {
776            original_name: "w".to_string(),
777            quantized_values: vec![25, 51, 76, 102],
778            scales: vec![0.039_215_686], // ≈ 1/25.5
779            zero_points: vec![0],
780            bits: 8,
781            axis: None,
782        }];
783
784        apply_qdq_transform(&mut graph, &inputs).expect("QDQ transform failed");
785
786        let report = validate_graph_connectivity(&graph);
787        assert!(
788            report.valid,
789            "graph after QDQ must be valid; broken: {:?}",
790            report.broken_refs
791        );
792    }
793
794    #[test]
795    fn test_qdq_adds_correct_initializers() {
796        let mut graph = make_simple_graph();
797
798        let inputs = vec![QdqWeightInput {
799            original_name: "w".to_string(),
800            quantized_values: vec![10, 20, 30, 40],
801            scales: vec![0.1],
802            zero_points: vec![-5],
803            bits: 8,
804            axis: None,
805        }];
806
807        apply_qdq_transform(&mut graph, &inputs).expect("QDQ transform failed");
808
809        let init_names: Vec<&str> = graph.initializer.iter().map(|i| i.name.as_str()).collect();
810
811        assert!(init_names.contains(&"w_quantized"), "missing w_quantized");
812        assert!(init_names.contains(&"w_scale"), "missing w_scale");
813        assert!(init_names.contains(&"w_zp"), "missing w_zp");
814        assert!(
815            !init_names.contains(&"w"),
816            "original FP32 'w' should be removed"
817        );
818    }
819
820    #[test]
821    fn test_qdq_node_order_dequant_first() {
822        let mut graph = make_simple_graph();
823
824        let inputs = vec![QdqWeightInput {
825            original_name: "w".to_string(),
826            quantized_values: vec![10, 20, 30, 40],
827            scales: vec![0.1],
828            zero_points: vec![0],
829            bits: 8,
830            axis: None,
831        }];
832
833        apply_qdq_transform(&mut graph, &inputs).expect("QDQ transform failed");
834
835        let ops: Vec<&str> = graph.node.iter().map(|n| n.op_type.as_str()).collect();
836
837        assert_eq!(ops.len(), 2);
838        assert_eq!(ops[0], "DequantizeLinear");
839        assert_eq!(ops[1], "Conv");
840    }
841
842    #[test]
843    fn test_qdq_dequant_output_is_original_name() {
844        let mut graph = make_simple_graph();
845
846        let inputs = vec![QdqWeightInput {
847            original_name: "w".to_string(),
848            quantized_values: vec![1, 2, 3, 4],
849            scales: vec![1.0],
850            zero_points: vec![0],
851            bits: 8,
852            axis: None,
853        }];
854
855        apply_qdq_transform(&mut graph, &inputs).expect("QDQ transform failed");
856
857        let dq = &graph.node[0]; // first node = DequantizeLinear
858        assert_eq!(
859            dq.output[0], "w",
860            "DequantizeLinear output must be original name"
861        );
862    }
863
864    #[test]
865    fn test_qdq_two_weights_both_transformed() {
866        let mut graph = make_two_weight_graph();
867
868        let inputs = vec![
869            QdqWeightInput {
870                original_name: "w1".to_string(),
871                quantized_values: vec![10, 20, 30, 40],
872                scales: vec![0.1],
873                zero_points: vec![0],
874                bits: 8,
875                axis: None,
876            },
877            QdqWeightInput {
878                original_name: "w2".to_string(),
879                quantized_values: vec![50, 60, 70, 80],
880                scales: vec![0.2],
881                zero_points: vec![-1],
882                bits: 8,
883                axis: None,
884            },
885        ];
886
887        apply_qdq_transform(&mut graph, &inputs).expect("QDQ transform failed");
888
889        // Connectivity must still be valid
890        let report = validate_graph_connectivity(&graph);
891        assert!(
892            report.valid,
893            "two-weight graph broken: {:?}",
894            report.broken_refs
895        );
896
897        // Should have 2 DequantizeLinear + 2 Conv = 4 nodes
898        assert_eq!(graph.node.len(), 4);
899
900        // First two nodes are DequantizeLinear
901        assert_eq!(graph.node[0].op_type, "DequantizeLinear");
902        assert_eq!(graph.node[1].op_type, "DequantizeLinear");
903
904        // Their outputs are the original weight names
905        let dq_outputs: Vec<&str> = graph
906            .node
907            .iter()
908            .take(2)
909            .map(|n| n.output[0].as_str())
910            .collect();
911        assert!(dq_outputs.contains(&"w1"));
912        assert!(dq_outputs.contains(&"w2"));
913    }
914
915    #[test]
916    fn test_qdq_int4_values_stored_as_int8() {
917        let mut graph = make_simple_graph();
918
919        // INT4 range [-8, 7] — these arrive as i8 from ensure_unpacked()
920        let inputs = vec![QdqWeightInput {
921            original_name: "w".to_string(),
922            quantized_values: vec![-8, -1, 0, 7],
923            scales: vec![0.5],
924            zero_points: vec![0],
925            bits: 4, // flag says INT4; storage must still be INT8
926            axis: None,
927        }];
928
929        apply_qdq_transform(&mut graph, &inputs).expect("QDQ transform failed");
930
931        let quant_init = graph
932            .initializer
933            .iter()
934            .find(|i| i.name == "w_quantized")
935            .expect("w_quantized not found");
936
937        // Data type must be INT8 (ONNX DequantizeLinear requirement)
938        assert_eq!(quant_init.data_type, tensor_proto::DataType::Int8 as i32);
939
940        // Byte-level round-trip must be exact
941        let recovered: Vec<i8> = quant_init.raw_data.iter().map(|&b| b as i8).collect();
942        assert_eq!(recovered, vec![-8, -1, 0, 7]);
943    }
944
945    #[test]
946    fn test_qdq_unknown_weight_returns_error() {
947        let mut graph = make_simple_graph();
948
949        let inputs = vec![QdqWeightInput {
950            original_name: "does_not_exist".to_string(),
951            quantized_values: vec![1, 2, 3],
952            scales: vec![1.0],
953            zero_points: vec![0],
954            bits: 8,
955            axis: None,
956        }];
957
958        let result = apply_qdq_transform(&mut graph, &inputs);
959        assert!(result.is_err());
960        assert!(
961            result.unwrap_err().to_string().contains("does_not_exist"),
962            "error should name the missing weight"
963        );
964    }
965
966    #[test]
967    fn test_qdq_non_quantized_initializers_preserved() {
968        // Add an extra initializer "bias" that is NOT being quantized.
969        // It must survive the transform untouched.
970        let mut graph = make_simple_graph();
971
972        graph.initializer.push(TensorProto {
973            name: "bias".to_string(),
974            data_type: tensor_proto::DataType::Float as i32,
975            dims: vec![2],
976            float_data: vec![0.1, 0.2],
977            ..Default::default()
978        });
979
980        // Also add "bias" as a Conv input so connectivity stays valid
981        graph.node[0].input.push("bias".to_string());
982
983        let inputs = vec![QdqWeightInput {
984            original_name: "w".to_string(),
985            quantized_values: vec![10, 20, 30, 40],
986            scales: vec![0.1],
987            zero_points: vec![0],
988            bits: 8,
989            axis: None,
990        }];
991
992        apply_qdq_transform(&mut graph, &inputs).expect("QDQ transform failed");
993
994        // "bias" must still be present and untouched
995        let bias_init = graph.initializer.iter().find(|i| i.name == "bias");
996
997        assert!(
998            bias_init.is_some(),
999            "non-quantized 'bias' initializer must be preserved"
1000        );
1001        assert!((bias_init.unwrap().float_data[0] - 0.1).abs() < 1e-6);
1002
1003        // Full connectivity check
1004        let report = validate_graph_connectivity(&graph);
1005        assert!(report.valid, "broken: {:?}", report.broken_refs);
1006    }
1007
1008    #[test]
1009    fn test_ensure_opset_strips_deprecated_attrs() {
1010        use crate::onnx_proto::NodeProto;
1011
1012        let mut model = ModelProto {
1013            opset_import: vec![OperatorSetIdProto {
1014                domain: String::new(),
1015                version: 7,
1016            }],
1017            graph: Some(GraphProto {
1018                node: vec![
1019                    // BatchNormalization with deprecated `spatial` attr (removed opset 9)
1020                    NodeProto {
1021                        op_type: "BatchNormalization".to_string(),
1022                        input: vec!["x".into(), "s".into(), "b".into(), "m".into(), "v".into()],
1023                        output: vec!["bn_out".into()],
1024                        attribute: vec![
1025                            AttributeProto {
1026                                name: "epsilon".to_string(),
1027                                r#type: attribute_proto::AttributeType::Float as i32,
1028                                f: 1e-5,
1029                                ..Default::default()
1030                            },
1031                            AttributeProto {
1032                                name: "spatial".to_string(),
1033                                r#type: attribute_proto::AttributeType::Int as i32,
1034                                i: 1,
1035                                ..Default::default()
1036                            },
1037                        ],
1038                        ..Default::default()
1039                    },
1040                    // Dropout with `ratio` attribute — should be migrated
1041                    // from attribute to 2nd input.
1042                    NodeProto {
1043                        op_type: "Dropout".to_string(),
1044                        input: vec!["bn_out".into()],
1045                        output: vec!["drop_out".into(), "drop_mask".into()],
1046                        attribute: vec![AttributeProto {
1047                            name: "ratio".to_string(),
1048                            r#type: attribute_proto::AttributeType::Float as i32,
1049                            f: 0.3,
1050                            ..Default::default()
1051                        }],
1052                        ..Default::default()
1053                    },
1054                    // Softmax with NO axis attribute — old default is 1,
1055                    // opset 13 changes default to -1, so axis=1 must be added.
1056                    NodeProto {
1057                        op_type: "Softmax".to_string(),
1058                        input: vec!["drop_out".into()],
1059                        output: vec!["sm_out".into()],
1060                        attribute: vec![],
1061                        ..Default::default()
1062                    },
1063                ],
1064                ..Default::default()
1065            }),
1066            ..Default::default()
1067        };
1068
1069        ensure_opset_version(&mut model, 13);
1070
1071        // Opset should be 13
1072        let opset = model
1073            .opset_import
1074            .iter()
1075            .find(|o| o.domain.is_empty())
1076            .unwrap();
1077        assert_eq!(opset.version, 13);
1078
1079        let graph = model.graph.as_ref().unwrap();
1080
1081        // BatchNormalization: `spatial` must be removed, `epsilon` kept
1082        let bn = &graph.node[0];
1083        assert!(
1084            !bn.attribute.iter().any(|a| a.name == "spatial"),
1085            "BatchNormalization.spatial should be stripped"
1086        );
1087        assert!(
1088            bn.attribute.iter().any(|a| a.name == "epsilon"),
1089            "BatchNormalization.epsilon should be preserved"
1090        );
1091
1092        // Dropout: `ratio` attribute must be removed and moved to 2nd input
1093        let drop = &graph.node[1];
1094        assert!(
1095            !drop.attribute.iter().any(|a| a.name == "ratio"),
1096            "Dropout.ratio attribute should be removed"
1097        );
1098        assert_eq!(drop.input.len(), 2, "Dropout should now have 2 inputs");
1099        let ratio_init_name = &drop.input[1];
1100
1101        // The ratio value should be stored as an initializer
1102        let ratio_init = graph
1103            .initializer
1104            .iter()
1105            .find(|i| &i.name == ratio_init_name)
1106            .expect("Dropout ratio initializer should exist");
1107        assert_eq!(ratio_init.data_type, tensor_proto::DataType::Float as i32);
1108        assert!(
1109            (ratio_init.float_data[0] - 0.3).abs() < 1e-6,
1110            "ratio should be 0.3"
1111        );
1112
1113        // Softmax: must have explicit axis=1 added (old default, since opset 13 changed it to -1)
1114        let sm = &graph.node[2];
1115        assert_eq!(sm.op_type, "Softmax");
1116        let axis_attr = sm
1117            .attribute
1118            .iter()
1119            .find(|a| a.name == "axis")
1120            .expect("Softmax should have axis attribute added");
1121        assert_eq!(axis_attr.i, 1, "Softmax axis should be 1 (old default)");
1122    }
1123
1124    #[test]
1125    fn test_ensure_opset_no_downgrade() {
1126        let mut model = ModelProto {
1127            opset_import: vec![OperatorSetIdProto {
1128                domain: String::new(),
1129                version: 15,
1130            }],
1131            graph: Some(GraphProto::default()),
1132            ..Default::default()
1133        };
1134
1135        // Requesting 10 should NOT downgrade from 15
1136        ensure_opset_version(&mut model, 10);
1137        let opset = model
1138            .opset_import
1139            .iter()
1140            .find(|o| o.domain.is_empty())
1141            .unwrap();
1142        assert_eq!(opset.version, 15);
1143    }
1144
1145    #[test]
1146    fn test_unhandled_opset_migration_detection() {
1147        let slice_graph = GraphProto {
1148            node: vec![NodeProto {
1149                op_type: "Slice".to_string(),
1150                ..Default::default()
1151            }],
1152            ..Default::default()
1153        };
1154
1155        // Crossing 9 → 13 passes the opset-10 Slice boundary → flagged.
1156        assert_eq!(
1157            unhandled_opset_migrations(&slice_graph, 9, 13),
1158            vec![("Slice", 10)]
1159        );
1160
1161        // Already at/above the boundary (11 → 13) → not flagged.
1162        assert!(unhandled_opset_migrations(&slice_graph, 11, 13).is_empty());
1163
1164        // Not crossing far enough (8 → 9, boundary is 10) → not flagged.
1165        assert!(unhandled_opset_migrations(&slice_graph, 8, 9).is_empty());
1166
1167        // Ops that `upgrade_deprecated_ops` handles are never reported.
1168        let softmax_graph = GraphProto {
1169            node: vec![NodeProto {
1170                op_type: "Softmax".to_string(),
1171                ..Default::default()
1172            }],
1173            ..Default::default()
1174        };
1175        assert!(unhandled_opset_migrations(&softmax_graph, 7, 13).is_empty());
1176
1177        // Each affected op is reported once even with multiple boundaries crossed.
1178        let multi = GraphProto {
1179            node: vec![
1180                NodeProto {
1181                    op_type: "Pad".to_string(),
1182                    ..Default::default()
1183                },
1184                NodeProto {
1185                    op_type: "Unsqueeze".to_string(),
1186                    ..Default::default()
1187                },
1188            ],
1189            ..Default::default()
1190        };
1191        let hits = unhandled_opset_migrations(&multi, 7, 21);
1192        assert_eq!(hits.len(), 2);
1193        assert!(hits.contains(&("Pad", 11)));
1194        assert!(hits.contains(&("Unsqueeze", 13)));
1195    }
1196
1197    #[test]
1198    fn test_ensure_opset_bumps_ir_version_for_native_int4() {
1199        // Native INT4 emits opset 21, whose INT4/UINT4 tensor types require
1200        // IR >= 10.  Bumping the opset must drag ir_version along.
1201        let mut model = ModelProto {
1202            ir_version: 8,
1203            opset_import: vec![OperatorSetIdProto {
1204                domain: String::new(),
1205                version: 13,
1206            }],
1207            graph: Some(GraphProto::default()),
1208            ..Default::default()
1209        };
1210        ensure_opset_version(&mut model, 21);
1211        assert_eq!(model.opset_import[0].version, 21);
1212        assert!(
1213            model.ir_version >= 10,
1214            "ir_version must be raised to >= 10 for opset 21, got {}",
1215            model.ir_version
1216        );
1217    }
1218
1219    #[test]
1220    fn test_ensure_opset_never_lowers_ir_version() {
1221        let mut model = ModelProto {
1222            ir_version: 10,
1223            opset_import: vec![OperatorSetIdProto {
1224                domain: String::new(),
1225                version: 17,
1226            }],
1227            graph: Some(GraphProto::default()),
1228            ..Default::default()
1229        };
1230        // Requesting a lower opset must not touch the already-newer IR.
1231        ensure_opset_version(&mut model, 13);
1232        assert_eq!(model.ir_version, 10, "must not lower ir_version");
1233    }
1234}