1use std::collections::HashMap;
4
5use onnx_runtime_ir::{Attribute, DataType, Dim, Graph, Node, NodeId};
6
7use crate::{Model, SchemaRegistry};
8
9type AdapterKey = (String, String, u32);
10
11pub trait OpAdapter: Send + Sync {
13 fn source(&self) -> (&str, &str, u32);
15
16 fn target_version(&self) -> u32;
18
19 fn adapt(&self, node: &Node, graph: &mut Graph) -> Result<AdaptResult, ConvertError>;
21}
22
23#[derive(Clone, Debug)]
25pub enum AdaptResult {
26 Compatible,
28 Rewritten,
30 Decomposed { replacement_nodes: Vec<Node> },
32 Incompatible { reason: String },
34}
35
36#[derive(Clone, Debug, PartialEq, Eq)]
38pub struct IncompatibleOp {
39 pub node_name: String,
40 pub domain: String,
41 pub op_type: String,
42 pub source_version: u32,
43 pub target_version: u32,
44 pub reason: String,
45}
46
47#[derive(Clone, Debug, Default)]
52pub struct ConvertReport {
53 pub ops_converted: usize,
55 pub ops_unchanged: usize,
57 pub ops_decomposed: usize,
59 pub ops_rejected: usize,
61 pub ops_incompatible: Vec<IncompatibleOp>,
63 pub messages: Vec<String>,
65 pub source_opset: u32,
66 pub target_opset: u32,
67}
68
69#[derive(Debug, thiserror::Error)]
71pub enum ConvertError {
72 #[error("model has no default-domain opset import")]
73 MissingDefaultOpset,
74 #[error("opset downgrade from {source_version} to {target} is not supported")]
75 DowngradeUnsupported { source_version: u32, target: u32 },
76 #[error(
77 "invalid adapter for {domain}::{op_type}: target version {target} must exceed source version {source_version}"
78 )]
79 InvalidAdapter {
80 domain: String,
81 op_type: String,
82 source_version: u32,
83 target: u32,
84 },
85 #[error("adapter for node '{node}' failed: {message}")]
86 Adapter { node: String, message: String },
87 #[error("adapter for node '{node}' returned an empty decomposition")]
88 EmptyDecomposition { node: String },
89}
90
91pub struct VersionConverter {
93 adapters: HashMap<AdapterKey, Box<dyn OpAdapter>>,
94 schemas: SchemaRegistry,
95}
96
97impl VersionConverter {
98 pub fn new() -> Self {
100 let mut converter = Self::empty();
101 converter.register(ReshapeAllowZeroAdapter::new(5));
102 converter.register(ReshapeAllowZeroAdapter::new(13));
103 converter.register(Softmax12To13Adapter::new("Softmax"));
104 converter.register(Softmax12To13Adapter::new("LogSoftmax"));
105 converter
106 }
107
108 pub fn empty() -> Self {
112 Self {
113 adapters: HashMap::new(),
114 schemas: SchemaRegistry::builtins(),
115 }
116 }
117
118 pub fn register<A: OpAdapter + 'static>(&mut self, adapter: A) {
120 let (domain, op_type, source) = adapter.source();
121 self.adapters.insert(
122 (
123 normalize_domain(domain).to_string(),
124 op_type.to_string(),
125 source,
126 ),
127 Box::new(adapter),
128 );
129 }
130
131 pub fn available_conversions(&self, domain: &str, op_type: &str) -> Vec<(u32, u32)> {
133 let domain = normalize_domain(domain);
134 let mut conversions = self
135 .adapters
136 .iter()
137 .filter_map(|((candidate_domain, candidate_op, source), adapter)| {
138 (candidate_domain == domain && candidate_op == op_type)
139 .then_some((*source, adapter.target_version()))
140 })
141 .collect::<Vec<_>>();
142 conversions.sort_unstable();
143 conversions
144 }
145
146 pub fn convert(
153 &self,
154 model: &mut Model,
155 target_opset: u32,
156 ) -> Result<ConvertReport, ConvertError> {
157 let source_opset = default_opset(&model.graph)?;
158 if target_opset < source_opset {
159 return Err(ConvertError::DowngradeUnsupported {
160 source_version: source_opset,
161 target: target_opset,
162 });
163 }
164
165 let mut report = ConvertReport {
166 source_opset,
167 target_opset,
168 ..ConvertReport::default()
169 };
170 if source_opset == target_opset {
171 return Ok(report);
172 }
173
174 let mut graph = model.graph.clone();
175 self.convert_graph(&mut graph, source_opset, target_opset, &mut report)?;
176
177 if report.ops_rejected == 0 {
178 set_default_opset(&mut graph, target_opset);
179 model.graph = graph;
180 } else {
181 report.messages.push(format!(
182 "conversion was not applied because {} node(s) were rejected",
183 report.ops_rejected
184 ));
185 }
186 Ok(report)
187 }
188
189 fn convert_graph(
190 &self,
191 graph: &mut Graph,
192 source_opset: u32,
193 target_opset: u32,
194 report: &mut ConvertReport,
195 ) -> Result<(), ConvertError> {
196 let node_ids = graph.nodes.keys().collect::<Vec<_>>();
197 for node_id in node_ids {
198 self.convert_subgraphs(graph, node_id, source_opset, target_opset, report)?;
199
200 let node = graph.node(node_id).clone();
201 if is_default_domain(&node.domain) {
202 self.convert_node(graph, node_id, source_opset, target_opset, report)?;
203 }
204 }
205 Ok(())
206 }
207
208 fn convert_subgraphs(
209 &self,
210 graph: &mut Graph,
211 node_id: NodeId,
212 source_opset: u32,
213 target_opset: u32,
214 report: &mut ConvertReport,
215 ) -> Result<(), ConvertError> {
216 let attribute_names = graph
217 .node(node_id)
218 .attributes
219 .keys()
220 .cloned()
221 .collect::<Vec<_>>();
222
223 for attribute_name in attribute_names {
224 let converted = {
225 let Some(attribute) = graph.node_mut(node_id).attributes.get_mut(&attribute_name)
226 else {
227 continue;
228 };
229 match attribute {
230 Attribute::Graph(subgraph) => {
231 self.convert_graph(subgraph, source_opset, target_opset, report)?;
232 vec![(attribute_name.clone(), (**subgraph).clone())]
233 }
234 Attribute::Graphs(subgraphs) => {
235 let mut converted = Vec::with_capacity(subgraphs.len());
236 for (index, subgraph) in subgraphs.iter_mut().enumerate() {
237 self.convert_graph(subgraph, source_opset, target_opset, report)?;
238 converted
239 .push((format!("{attribute_name}[{index}]"), subgraph.clone()));
240 }
241 converted
242 }
243 _ => continue,
244 }
245 };
246
247 for (key, subgraph) in converted {
248 graph.subgraphs.insert((node_id, key), subgraph);
249 }
250 }
251 Ok(())
252 }
253
254 fn convert_node(
255 &self,
256 graph: &mut Graph,
257 node_id: NodeId,
258 source_opset: u32,
259 target_opset: u32,
260 report: &mut ConvertReport,
261 ) -> Result<(), ConvertError> {
262 let original = graph.node(node_id).clone();
263 let domain = normalize_domain(&original.domain);
264 let mut current = source_opset;
265 let mut rewritten = false;
266 let mut decomposed = false;
267
268 while current < target_opset {
269 let key = (domain.to_string(), original.op_type.clone(), current);
270 let Some(adapter) = self.adapters.get(&key) else {
271 if self.schema_compatible(&original.op_type, domain, current, target_opset) {
272 break;
273 }
274 reject(
275 report,
276 &original,
277 source_opset,
278 target_opset,
279 format!(
280 "no adapter from opset {current} to {target_opset}, and schemas do not prove compatibility"
281 ),
282 );
283 return Ok(());
284 };
285
286 let next = adapter.target_version();
287 if next <= current || next > target_opset {
288 return Err(ConvertError::InvalidAdapter {
289 domain: domain.to_string(),
290 op_type: original.op_type.clone(),
291 source_version: current,
292 target: next,
293 });
294 }
295
296 let node = graph.node(node_id).clone();
297 match adapter
298 .adapt(&node, graph)
299 .map_err(|error| ConvertError::Adapter {
300 node: display_node(&original),
301 message: error.to_string(),
302 })? {
303 AdaptResult::Compatible => {}
304 AdaptResult::Rewritten => rewritten = true,
305 AdaptResult::Decomposed { replacement_nodes } => {
306 if replacement_nodes.is_empty() {
307 return Err(ConvertError::EmptyDecomposition {
308 node: display_node(&original),
309 });
310 }
311 let mut replacements = replacement_nodes.into_iter();
312 graph.replace_node(node_id, replacements.next().expect("checked non-empty"));
313 for replacement in replacements {
314 graph.insert_node(replacement);
315 }
316 decomposed = true;
317 }
318 AdaptResult::Incompatible { reason } => {
319 reject(report, &original, source_opset, target_opset, reason);
320 return Ok(());
321 }
322 }
323 current = next;
324
325 if decomposed && current < target_opset {
326 reject(
327 report,
328 &original,
329 source_opset,
330 target_opset,
331 "adapter decomposed the node before the final target opset".to_string(),
332 );
333 return Ok(());
334 }
335 }
336
337 if decomposed {
338 report.ops_decomposed += 1;
339 } else if rewritten {
340 report.ops_converted += 1;
341 } else {
342 report.ops_unchanged += 1;
343 }
344 Ok(())
345 }
346
347 fn schema_compatible(&self, op_type: &str, domain: &str, source: u32, target: u32) -> bool {
348 let Some(source_schema) = self.schemas.lookup(op_type, domain, u64::from(source)) else {
349 return false;
350 };
351 let Some(target_schema) = self.schemas.lookup(op_type, domain, u64::from(target)) else {
352 return false;
353 };
354 if source_schema.since_version != target_schema.since_version {
355 return false;
356 }
357
358 let target = u64::from(target);
359 source_schema
360 .until_version
361 .is_some_and(|until| target <= until)
362 || self
363 .schemas
364 .iter()
365 .filter(|schema| {
366 schema.name == op_type
367 && normalize_domain(&schema.domain) == normalize_domain(domain)
368 && schema.since_version > source_schema.since_version
369 })
370 .map(|schema| schema.since_version)
371 .min()
372 .is_some_and(|next_version| target < next_version)
373 }
374}
375
376impl Default for VersionConverter {
377 fn default() -> Self {
378 Self::new()
379 }
380}
381
382struct ReshapeAllowZeroAdapter {
385 source: u32,
386}
387
388impl ReshapeAllowZeroAdapter {
389 const fn new(source: u32) -> Self {
390 Self { source }
391 }
392}
393
394impl OpAdapter for ReshapeAllowZeroAdapter {
395 fn source(&self) -> (&str, &str, u32) {
396 ("", "Reshape", self.source)
397 }
398
399 fn target_version(&self) -> u32 {
400 14
401 }
402
403 fn adapt(&self, node: &Node, graph: &mut Graph) -> Result<AdaptResult, ConvertError> {
404 if node.attributes.contains_key("allowzero") {
405 return Ok(AdaptResult::Compatible);
406 }
407 graph
408 .node_mut(node.id)
409 .attributes
410 .insert("allowzero".to_string(), Attribute::Int(0));
411 Ok(AdaptResult::Rewritten)
412 }
413}
414
415struct Softmax12To13Adapter {
419 op_type: &'static str,
420}
421
422impl Softmax12To13Adapter {
423 const fn new(op_type: &'static str) -> Self {
424 Self { op_type }
425 }
426}
427
428impl OpAdapter for Softmax12To13Adapter {
429 fn source(&self) -> (&str, &str, u32) {
430 ("", self.op_type, 12)
431 }
432
433 fn target_version(&self) -> u32 {
434 13
435 }
436
437 fn adapt(&self, node: &Node, graph: &mut Graph) -> Result<AdaptResult, ConvertError> {
438 if node.inputs.len() != 1 || node.outputs.len() != 1 {
439 return Ok(AdaptResult::Incompatible {
440 reason: format!("{} requires exactly one input and one output", self.op_type),
441 });
442 }
443 let Some(input) = node.inputs.first().copied().flatten() else {
444 return Ok(AdaptResult::Incompatible {
445 reason: format!("{} requires one present input", self.op_type),
446 });
447 };
448 let Some(&output) = node.outputs.first() else {
449 return Ok(AdaptResult::Incompatible {
450 reason: format!("{} requires one output", self.op_type),
451 });
452 };
453 if !graph.value_shape_is_known(input) {
454 return Ok(AdaptResult::Incompatible {
455 reason: format!(
456 "{} 12→13 conversion requires a known input rank",
457 self.op_type
458 ),
459 });
460 }
461 let rank = graph.value(input).rank();
462 let Ok(rank_i64) = i64::try_from(rank) else {
463 return Ok(AdaptResult::Incompatible {
464 reason: format!(
465 "{} input rank exceeds the supported integer range",
466 self.op_type
467 ),
468 });
469 };
470 if rank == 0 {
471 return Ok(AdaptResult::Incompatible {
472 reason: format!("{} input must have rank at least 1", self.op_type),
473 });
474 }
475 let old_axis = node
476 .attributes
477 .get("axis")
478 .and_then(Attribute::as_int)
479 .unwrap_or(1);
480 let normalized_axis = if old_axis < 0 {
481 old_axis.checked_add(rank_i64)
482 } else {
483 Some(old_axis)
484 };
485 let Some(normalized_axis) = normalized_axis.filter(|axis| (0..rank_i64).contains(axis))
486 else {
487 return Ok(AdaptResult::Incompatible {
488 reason: format!(
489 "{} axis {old_axis} is outside [-{rank}, {rank})",
490 self.op_type
491 ),
492 });
493 };
494
495 if normalized_axis == rank_i64 - 1 {
496 graph
497 .node_mut(node.id)
498 .attributes
499 .insert("axis".into(), Attribute::Int(-1));
500 return Ok(AdaptResult::Rewritten);
501 }
502
503 let input_value = graph.value(input);
504 let dtype = input_value.dtype;
505 let shape_value = graph.create_value(DataType::Int64, vec![Dim::Static(rank)]);
506 let flattened = graph.create_value(dtype, Vec::new());
507 let intermediate = graph.create_value(dtype, Vec::new());
508 graph.mark_value_shape_unknown(flattened);
509 graph.mark_value_shape_unknown(intermediate);
510
511 let helper_name =
512 |suffix: &str| (!node.name.is_empty()).then(|| format!("{}_{}", node.name, suffix));
513
514 let mut shape = Node::new(NodeId(0), "Shape", vec![Some(input)], vec![shape_value]);
515 shape.name = helper_name("shape").unwrap_or_default();
516 shape.device = node.device;
517
518 let mut flatten = Node::new(NodeId(0), "Flatten", vec![Some(input)], vec![flattened]);
519 flatten.name = helper_name("flatten").unwrap_or_default();
520 flatten
521 .attributes
522 .insert("axis".into(), Attribute::Int(normalized_axis));
523 flatten.device = node.device;
524
525 let mut softmax = node.clone();
526 softmax.id = NodeId(0);
527 softmax.inputs = vec![Some(flattened)];
528 softmax.outputs = vec![intermediate];
529 softmax.attributes.insert("axis".into(), Attribute::Int(-1));
530
531 let mut reshape = Node::new(
532 NodeId(0),
533 "Reshape",
534 vec![Some(intermediate), Some(shape_value)],
535 vec![output],
536 );
537 reshape.name = helper_name("reshape").unwrap_or_default();
538 reshape.device = node.device;
539
540 Ok(AdaptResult::Decomposed {
541 replacement_nodes: vec![shape, flatten, softmax, reshape],
542 })
543 }
544}
545
546fn default_opset(graph: &Graph) -> Result<u32, ConvertError> {
547 graph
548 .opset_imports
549 .get("")
550 .or_else(|| graph.opset_imports.get("ai.onnx"))
551 .copied()
552 .ok_or(ConvertError::MissingDefaultOpset)?
553 .try_into()
554 .map_err(|_| ConvertError::MissingDefaultOpset)
555}
556
557fn set_default_opset(graph: &mut Graph, target: u32) {
558 graph.opset_imports.remove("ai.onnx");
559 graph.opset_imports.insert(String::new(), u64::from(target));
560}
561
562fn is_default_domain(domain: &str) -> bool {
563 domain.is_empty() || domain == "ai.onnx"
564}
565
566fn normalize_domain(domain: &str) -> &str {
567 if is_default_domain(domain) {
568 "ai.onnx"
569 } else {
570 domain
571 }
572}
573
574fn display_node(node: &Node) -> String {
575 if node.name.is_empty() {
576 node.op_type.clone()
577 } else {
578 node.name.clone()
579 }
580}
581
582fn reject(
583 report: &mut ConvertReport,
584 node: &Node,
585 source_version: u32,
586 target_version: u32,
587 reason: String,
588) {
589 let detail = IncompatibleOp {
590 node_name: node.name.clone(),
591 domain: node.domain.clone(),
592 op_type: node.op_type.clone(),
593 source_version,
594 target_version,
595 reason,
596 };
597 report
598 .messages
599 .push(format!("{}: {}", display_node(node), detail.reason));
600 report.ops_rejected += 1;
601 report.ops_incompatible.push(detail);
602}
603
604#[cfg(test)]
605mod tests {
606 use super::*;
607 use onnx_runtime_ir::{DataType, Node, static_shape};
608
609 struct If13To14Adapter;
610
611 impl OpAdapter for If13To14Adapter {
612 fn source(&self) -> (&str, &str, u32) {
613 ("", "If", 13)
614 }
615
616 fn target_version(&self) -> u32 {
617 14
618 }
619
620 fn adapt(&self, _node: &Node, _graph: &mut Graph) -> Result<AdaptResult, ConvertError> {
621 Ok(AdaptResult::Compatible)
622 }
623 }
624
625 fn unary_model(op_type: &str, opset: u32) -> Model {
626 let mut graph = Graph::new();
627 graph.opset_imports.insert(String::new(), u64::from(opset));
628 let input = graph.create_named_value("X", DataType::Float32, static_shape([2, 3]));
629 let output = graph.create_named_value("Y", DataType::Float32, static_shape([2, 3]));
630 graph.add_input(input);
631 graph.add_output(output);
632 let mut node = Node::new(NodeId(0), op_type, vec![Some(input)], vec![output]);
633 node.name = format!("{op_type}_0");
634 graph.insert_node(node);
635 Model::new(graph)
636 }
637
638 fn unary_graph(op_type: &str) -> Graph {
639 let mut graph = Graph::new();
640 let input = graph.create_named_value("branch_X", DataType::Float32, static_shape([2, 3]));
641 let output = graph.create_named_value("branch_Y", DataType::Float32, static_shape([2, 3]));
642 graph.add_input(input);
643 graph.add_output(output);
644 let mut node = Node::new(NodeId(0), op_type, vec![Some(input)], vec![output]);
645 node.name = format!("{op_type}_branch");
646 graph.insert_node(node);
647 graph
648 }
649
650 fn if_model(branch_op: &str, include_root_reshape: bool) -> Model {
651 let mut graph = Graph::new();
652 graph.opset_imports.insert(String::new(), 13);
653
654 if include_root_reshape {
655 let input = graph.create_named_value("root_X", DataType::Float32, static_shape([2, 3]));
656 let output =
657 graph.create_named_value("root_Y", DataType::Float32, static_shape([2, 3]));
658 graph.add_input(input);
659 let mut reshape = Node::new(NodeId(0), "Reshape", vec![Some(input)], vec![output]);
660 reshape.name = "Reshape_root".to_string();
661 graph.insert_node(reshape);
662 }
663
664 let condition = graph.create_named_value("condition", DataType::Bool, static_shape([]));
665 let output = graph.create_named_value("if_Y", DataType::Float32, static_shape([2, 3]));
666 graph.add_input(condition);
667 graph.add_output(output);
668
669 let branch = unary_graph(branch_op);
670 let mut if_node = Node::new(NodeId(0), "If", vec![Some(condition)], vec![output]);
671 if_node.name = "If_0".to_string();
672 if_node.attributes.insert(
673 "then_branch".to_string(),
674 Attribute::Graph(Box::new(branch.clone())),
675 );
676 let if_node_id = graph.insert_node(if_node);
677 graph
678 .subgraphs
679 .insert((if_node_id, "then_branch".to_string()), branch);
680
681 Model::new(graph)
682 }
683
684 #[test]
685 fn schema_compatible_bump_requires_a_known_later_boundary() {
686 let mut model = unary_model("StableOp", 1);
687 let before = model.graph.nodes.values().next().unwrap().clone();
688 let mut converter = VersionConverter::empty();
689 converter
690 .schemas
691 .load_yaml(
692 r#"
693domain: ""
694name: StableOp
695since_version: 1
696"#,
697 )
698 .unwrap();
699 converter
700 .schemas
701 .load_yaml(
702 r#"
703domain: ""
704name: StableOp
705since_version: 5
706"#,
707 )
708 .unwrap();
709
710 let report = converter.convert(&mut model, 4).unwrap();
711
712 let after = model.graph.nodes.values().next().unwrap();
713 assert_eq!(model.graph.opset_imports[""], 4);
714 assert_eq!(after.op_type, before.op_type);
715 assert_eq!(after.inputs, before.inputs);
716 assert_eq!(after.outputs, before.outputs);
717 assert_eq!(report.ops_unchanged, 1);
718 assert_eq!(report.ops_converted, 0);
719 assert_eq!(report.ops_rejected, 0);
720 }
721
722 #[test]
723 fn nested_subgraph_nodes_are_converted_and_indexes_stay_in_sync() {
724 let mut model = if_model("Reshape", false);
725 let mut converter = VersionConverter::new();
726 converter.register(If13To14Adapter);
727
728 let report = converter.convert(&mut model, 14).unwrap();
729
730 assert_eq!(model.graph.opset_imports[""], 14);
731 assert_eq!(report.ops_converted, 1);
732 assert_eq!(report.ops_unchanged, 1);
733 assert_eq!(report.ops_rejected, 0);
734
735 let if_node = model
736 .graph
737 .nodes
738 .values()
739 .find(|node| node.op_type == "If")
740 .unwrap();
741 let Attribute::Graph(branch) = &if_node.attributes["then_branch"] else {
742 panic!("then_branch must be a graph");
743 };
744 assert_eq!(
745 branch.nodes.values().next().unwrap().attributes["allowzero"].as_int(),
746 Some(0)
747 );
748 let indexed = &model.graph.subgraphs[&(if_node.id, "then_branch".to_string())];
749 assert_eq!(
750 indexed.nodes.values().next().unwrap().attributes["allowzero"].as_int(),
751 Some(0)
752 );
753 }
754
755 #[test]
756 fn nested_rejection_rolls_back_root_rewrites_and_opset_bump() {
757 let mut model = if_model("UnknownChangedOp", true);
758 let mut converter = VersionConverter::new();
759 converter.register(If13To14Adapter);
760
761 let report = converter.convert(&mut model, 14).unwrap();
762
763 assert_eq!(model.graph.opset_imports[""], 13);
764 assert_eq!(report.ops_rejected, 1);
765 assert_eq!(
766 report.ops_incompatible[0].node_name,
767 "UnknownChangedOp_branch"
768 );
769 let root_reshape = model
770 .graph
771 .nodes
772 .values()
773 .find(|node| node.name == "Reshape_root")
774 .unwrap();
775 assert!(!root_reshape.attributes.contains_key("allowzero"));
776 }
777
778 #[test]
779 fn target_beyond_known_schema_history_is_rejected() {
780 let mut model = unary_model("Conv", 11);
781
782 let report = VersionConverter::new().convert(&mut model, 22).unwrap();
783
784 assert_eq!(model.graph.opset_imports[""], 11);
785 assert_eq!(report.ops_rejected, 1);
786 assert_eq!(report.ops_incompatible[0].op_type, "Conv");
787 assert!(
788 report.ops_incompatible[0]
789 .reason
790 .contains("schemas do not prove compatibility")
791 );
792 }
793
794 #[test]
795 fn reshape_adapter_materializes_allowzero_default() {
796 let mut model = unary_model("Reshape", 13);
797
798 let report = VersionConverter::new().convert(&mut model, 14).unwrap();
799
800 let node = model.graph.nodes.values().next().unwrap();
801 assert_eq!(
802 node.attributes.get("allowzero").and_then(Attribute::as_int),
803 Some(0)
804 );
805 assert_eq!(model.graph.opset_imports[""], 14);
806 assert_eq!(report.ops_converted, 1);
807 assert_eq!(report.ops_unchanged, 0);
808 assert_eq!(report.ops_rejected, 0);
809 }
810
811 #[test]
812 fn softmax_and_log_softmax_12_to_13_adapters_are_registered() {
813 let converter = VersionConverter::new();
814 assert_eq!(converter.available_conversions("", "Softmax"), [(12, 13)]);
815 assert_eq!(
816 converter.available_conversions("ai.onnx", "LogSoftmax"),
817 [(12, 13)]
818 );
819 }
820
821 #[test]
822 fn softmax_last_axis_is_rewritten_without_decomposition() {
823 let mut model = unary_model("Softmax", 12);
824
825 let report = VersionConverter::new().convert(&mut model, 13).unwrap();
826
827 let node = model.graph.nodes.values().next().unwrap();
828 assert_eq!(node.attributes["axis"].as_int(), Some(-1));
829 assert_eq!(model.graph.opset_imports[""], 13);
830 assert_eq!(report.ops_converted, 1);
831 assert_eq!(report.ops_decomposed, 0);
832 assert_eq!(report.ops_rejected, 0);
833 }
834
835 #[test]
836 fn softmax_non_last_axis_uses_official_flatten_reshape_rewrite() {
837 let mut graph = Graph::new();
838 graph.opset_imports.insert(String::new(), 12);
839 let input = graph.create_named_value("X", DataType::Float32, static_shape([2, 3, 4]));
840 let output = graph.create_named_value("Y", DataType::Float32, static_shape([2, 3, 4]));
841 graph.add_input(input);
842 graph.add_output(output);
843 let mut softmax = Node::new(NodeId(0), "Softmax", vec![Some(input)], vec![output]);
844 softmax.name = "softmax".into();
845 softmax.attributes.insert("axis".into(), Attribute::Int(1));
846 graph.insert_node(softmax);
847 let mut model = Model::new(graph);
848
849 let report = VersionConverter::new().convert(&mut model, 13).unwrap();
850
851 assert_eq!(model.graph.opset_imports[""], 13);
852 assert_eq!(report.ops_decomposed, 1);
853 assert_eq!(report.ops_converted, 0);
854 assert_eq!(report.ops_rejected, 0);
855 assert_eq!(model.graph.num_nodes(), 4);
856 model.graph.validate().unwrap();
857
858 let shape = model
859 .graph
860 .nodes
861 .values()
862 .find(|node| node.op_type == "Shape")
863 .unwrap();
864 let flatten = model
865 .graph
866 .nodes
867 .values()
868 .find(|node| node.op_type == "Flatten")
869 .unwrap();
870 let converted = model
871 .graph
872 .nodes
873 .values()
874 .find(|node| node.op_type == "Softmax")
875 .unwrap();
876 let reshape = model
877 .graph
878 .nodes
879 .values()
880 .find(|node| node.op_type == "Reshape")
881 .unwrap();
882
883 assert_eq!(flatten.attributes["axis"].as_int(), Some(1));
884 assert_eq!(converted.attributes["axis"].as_int(), Some(-1));
885 assert_eq!(
886 model.graph.value(flatten.inputs[0].unwrap()).id,
887 shape.inputs[0].unwrap()
888 );
889 assert_eq!(
890 model.graph.value(converted.inputs[0].unwrap()).producer,
891 Some(flatten.id)
892 );
893 assert_eq!(
894 model.graph.value(reshape.inputs[0].unwrap()).producer,
895 Some(converted.id)
896 );
897 assert_eq!(
898 model.graph.value(reshape.inputs[1].unwrap()).producer,
899 Some(shape.id)
900 );
901 assert_eq!(model.graph.value(output).producer, Some(reshape.id));
902 let flattened_output = flatten.outputs[0];
903 let converted_output = converted.outputs[0];
904
905 crate::infer_shapes(&mut model).unwrap();
906 assert_eq!(
907 model.graph.value(flattened_output).shape,
908 static_shape([2, 12])
909 );
910 assert_eq!(
911 model.graph.value(converted_output).shape,
912 static_shape([2, 12])
913 );
914 assert_eq!(model.graph.value(output).shape, static_shape([2, 3, 4]));
915 }
916
917 #[test]
918 fn missing_adapter_is_reported_and_model_is_unchanged() {
919 let mut model = unary_model("UnknownChangedOp", 10);
920
921 let report = VersionConverter::new().convert(&mut model, 11).unwrap();
922
923 assert_eq!(model.graph.opset_imports[""], 10);
924 assert_eq!(report.ops_rejected, 1);
925 assert_eq!(report.ops_incompatible.len(), 1);
926 assert!(report.ops_incompatible[0].reason.contains("no adapter"));
927 assert!(
928 report
929 .messages
930 .iter()
931 .any(|message| message.contains("not applied"))
932 );
933 }
934
935 #[test]
936 fn downgrade_returns_clear_error() {
937 let mut model = unary_model("Relu", 14);
938
939 let error = VersionConverter::new().convert(&mut model, 13).unwrap_err();
940
941 assert!(matches!(
942 error,
943 ConvertError::DowngradeUnsupported {
944 source_version: 14,
945 target: 13
946 }
947 ));
948 assert_eq!(model.graph.opset_imports[""], 14);
949 }
950}