Skip to main content

onnx_std/
version.rs

1//! Opset version conversion with composable per-operator adapters (ONNX_RS §10).
2
3use 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
11/// Converts one operator from a source schema version to a target version.
12pub trait OpAdapter: Send + Sync {
13    /// Source `(domain, op_type, version)`.
14    fn source(&self) -> (&str, &str, u32);
15
16    /// Target schema version.
17    fn target_version(&self) -> u32;
18
19    /// Adapt `node`, optionally mutating it through `graph`.
20    fn adapt(&self, node: &Node, graph: &mut Graph) -> Result<AdaptResult, ConvertError>;
21}
22
23/// Result of applying one [`OpAdapter`].
24#[derive(Clone, Debug)]
25pub enum AdaptResult {
26    /// The node is compatible as-is.
27    Compatible,
28    /// The adapter rewrote the node in place.
29    Rewritten,
30    /// The node must be replaced by the supplied nodes.
31    Decomposed { replacement_nodes: Vec<Node> },
32    /// The source node cannot be represented at the target version.
33    Incompatible { reason: String },
34}
35
36/// One node that prevented a complete conversion.
37#[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/// Summary of a conversion attempt.
48///
49/// Conversion is transactional: when `ops_rejected` is non-zero, the model is
50/// left unchanged and the rejected nodes explain which adapters are missing.
51#[derive(Clone, Debug, Default)]
52pub struct ConvertReport {
53    /// Nodes rewritten in place.
54    pub ops_converted: usize,
55    /// Nodes proven compatible without a rewrite.
56    pub ops_unchanged: usize,
57    /// Nodes replaced by adapter-provided subgraphs.
58    pub ops_decomposed: usize,
59    /// Nodes that could not be converted.
60    pub ops_rejected: usize,
61    /// Details for rejected nodes.
62    pub ops_incompatible: Vec<IncompatibleOp>,
63    /// Human-readable conversion diagnostics.
64    pub messages: Vec<String>,
65    pub source_opset: u32,
66    pub target_opset: u32,
67}
68
69/// Failure to execute a version conversion.
70#[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
91/// Registry and executor for opset conversion adapters.
92pub struct VersionConverter {
93    adapters: HashMap<AdapterKey, Box<dyn OpAdapter>>,
94    schemas: SchemaRegistry,
95}
96
97impl VersionConverter {
98    /// Create a converter with the built-in adapters.
99    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    /// Create a converter without operator-specific adapters.
109    ///
110    /// Schema-proven compatible bumps remain available.
111    pub fn empty() -> Self {
112        Self {
113            adapters: HashMap::new(),
114            schemas: SchemaRegistry::builtins(),
115        }
116    }
117
118    /// Register or replace an adapter for its source tuple.
119    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    /// List registered `(source, target)` conversions for an operator.
132    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    /// Convert the default ONNX domain to `target_opset`.
147    ///
148    /// A node without an adapter is accepted only when the schema registry has
149    /// positive evidence that one schema covers the full conversion interval.
150    /// Otherwise it is reported as incompatible and no changes are committed
151    /// to `model`.
152    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
382/// Reshape opset 14 introduced `allowzero` with a default of zero. Materializing
383/// that default makes the old zero-copy semantics explicit.
384struct 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
415/// Opset 13 changed Softmax and LogSoftmax from flattening at `axis` to
416/// operating on exactly one axis. This is the same Shape/Flatten/op/Reshape
417/// rewrite used by the official ONNX 12→13 adapter.
418struct 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}