1use 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#[derive(Debug, Clone, Default)]
32pub struct QdqWeightInput {
33 pub original_name: String,
35 pub quantized_values: Vec<i8>,
38 pub scales: Vec<f32>,
41 pub zero_points: Vec<i8>,
44 pub bits: u8,
47 pub axis: Option<usize>,
49}
50
51#[derive(Debug, Clone, Copy, Default)]
57#[non_exhaustive]
58pub struct SaveOptions {
59 pub native_int4: bool,
70}
71
72impl SaveOptions {
73 pub fn with_native_int4(mut self, enabled: bool) -> Self {
75 self.native_int4 = enabled;
76 self
77 }
78}
79
80#[derive(Debug)]
82#[must_use]
83pub struct ConnectivityReport {
84 pub valid: bool,
86 pub broken_refs: Vec<String>,
88}
89
90impl ConnectivityReport {
91 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
109pub(crate) fn validate_graph_connectivity(graph: &GraphProto) -> ConnectivityReport {
123 let mut known: HashSet<String> = HashSet::new();
124
125 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 for node in &graph.node {
137 for name in &node.input {
138 if name.is_empty() {
139 continue; }
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 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
162pub(crate) fn ensure_opset_version(model: &mut ModelProto, min_version: i64) {
175 let old_version = get_opset_version(model);
176
177 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 if old_version < min_version {
197 if let Some(graph) = model.graph.as_mut() {
198 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 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
221fn 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
230fn 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, }
251}
252
253const UNHANDLED_OPSET_MIGRATIONS: &[(&str, i64)] = &[
266 ("Upsample", 10), ("Slice", 10), ("TopK", 10), ("Resize", 11), ("Pad", 11), ("Clip", 11), ("Scatter", 11), ("Split", 13), ("Squeeze", 13), ("Unsqueeze", 13), ("ReduceSum", 13), ];
278
279fn 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
301fn 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
318fn 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 if node.op_type == "BatchNormalization" && old_opset < 9 && new_opset >= 9 {
331 node.attribute.retain(|a| a.name != "spatial");
332 }
333
334 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 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 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, ..Default::default()
380 });
381 }
382 }
383 }
384
385 graph.initializer.extend(new_initializers);
386}
387
388#[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
425pub(crate) fn apply_qdq_transform_with_options(
430 graph: &mut GraphProto,
431 inputs: &[QdqWeightInput],
432 options: SaveOptions,
433) -> Result<()> {
434 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 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 graph
481 .initializer
482 .retain(|init| !quant_set.contains(init.name.as_str()));
483
484 graph
491 .input
492 .retain(|inp| !quant_set.contains(inp.name.as_str()));
493
494 let mut dq_nodes = Vec::new();
498
499 for inp in inputs {
500 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 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#[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 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 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 #[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 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 #[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 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 #[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], 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]; 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 let report = validate_graph_connectivity(&graph);
891 assert!(
892 report.valid,
893 "two-weight graph broken: {:?}",
894 report.broken_refs
895 );
896
897 assert_eq!(graph.node.len(), 4);
899
900 assert_eq!(graph.node[0].op_type, "DequantizeLinear");
902 assert_eq!(graph.node[1].op_type, "DequantizeLinear");
903
904 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 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, 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 assert_eq!(quant_init.data_type, tensor_proto::DataType::Int8 as i32);
939
940 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(
1157 unhandled_opset_migrations(&slice_graph, 9, 13),
1158 vec![("Slice", 10)]
1159 );
1160
1161 assert!(unhandled_opset_migrations(&slice_graph, 11, 13).is_empty());
1163
1164 assert!(unhandled_opset_migrations(&slice_graph, 8, 9).is_empty());
1166
1167 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 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 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 ensure_opset_version(&mut model, 13);
1232 assert_eq!(model.ir_version, 10, "must not lower ir_version");
1233 }
1234}