Skip to main content

onnx_std/check/
rules.rs

1//! Built-in validation rules (ONNX_RS §8.2).
2//!
3//! * [`MissingOpsetImportRule`] — every operator domain used by a node must have
4//!   a matching `opset_import` (§8.2 "IR rules").
5//! * [`DuplicateValueNameRule`] — no two values may share a name (§8.2
6//!   "structural rules": unique value names / single producer).
7//! * [`GraphAcyclicRule`] — the dataflow graph must be acyclic (§8.2).
8//! * [`SchemaNodeConformsRule`] — nodes match their resolved operator schema.
9//! * [`InputOutputDeclaredRule`] — graph inputs and outputs are named, live values.
10//! * [`NoUnconnectedNodesRule`] — node inputs resolve to graph sources or producers.
11//! * [`TypeConstraintSatisfiedRule`] — node value types satisfy schema constraints.
12//! * [`InitializerTypeMatchesDeclaredRule`] — initializer and value dtypes agree.
13//! * [`IrVersionSupportedRule`] — the model declares a valid ONNX IR version.
14//! * [`MultiDeviceConfigurationRule`] — IR v11+ distributed annotations are
15//!   internally consistent.
16
17use std::collections::{HashMap, HashSet};
18
19use onnx_runtime_ir::{Attribute, Graph, ValueId};
20use onnx_runtime_loader::proto::onnx::{
21    AttributeProto, DeviceConfigurationProto, FunctionProto, GraphProto, ModelProto, NodeProto,
22    SparseTensorProto, StringStringEntryProto, TensorProto, TensorShapeProto, TypeProto,
23    ValueInfoProto, attribute_proto, tensor_proto, type_proto,
24};
25
26use super::{Severity, ValidationContext, ValidationRule, Violation, ViolationLocation};
27use crate::model::Model;
28use crate::schema::{AttributeType, OpSchema};
29
30/// Normalise a node/opset domain: the empty string and `"ai.onnx"` both denote
31/// the default ONNX domain.
32fn normalize_domain(domain: &str) -> &str {
33    if domain.is_empty() { "ai.onnx" } else { domain }
34}
35
36/// Every operator domain referenced by a node must be declared in
37/// `opset_imports` (ONNX_RS §8.2, IR rule `OpsetImportPresentRule`).
38pub struct MissingOpsetImportRule;
39
40impl ValidationRule for MissingOpsetImportRule {
41    fn id(&self) -> &str {
42        "ir.opset_import_present"
43    }
44
45    fn severity(&self) -> Severity {
46        Severity::Error
47    }
48
49    fn check(&self, model: &Model, _ctx: &ValidationContext) -> Vec<Violation> {
50        // Pre-normalise the declared import domains once.
51        let declared: std::collections::HashSet<&str> = model
52            .graph
53            .opset_imports
54            .keys()
55            .map(|d| normalize_domain(d))
56            .collect();
57        check_graph_opset_imports(
58            &model.graph,
59            &model.metadata.graph_name,
60            &declared,
61            self.id(),
62        )
63    }
64}
65
66/// No two distinct values may carry the same (non-empty) name — ONNX graphs are
67/// SSA, so a name identifies a unique edge (ONNX_RS §8.2 `UniqueValueNamesRule`).
68pub struct DuplicateValueNameRule;
69
70impl ValidationRule for DuplicateValueNameRule {
71    fn id(&self) -> &str {
72        "structure.duplicate_value_name"
73    }
74
75    fn severity(&self) -> Severity {
76        Severity::Error
77    }
78
79    fn check(&self, model: &Model, _ctx: &ValidationContext) -> Vec<Violation> {
80        check_graph_duplicate_names(&model.graph, self.id())
81    }
82}
83
84/// Graph inputs and outputs must reference live, named values (ONNX_RS §8.2
85/// `InputOutputDeclaredRule`). The shared IR always carries dtype and shape for
86/// a live value, so liveness plus a non-empty ONNX name establishes declaration.
87pub struct InputOutputDeclaredRule;
88
89impl ValidationRule for InputOutputDeclaredRule {
90    fn id(&self) -> &str {
91        "structure.input_output_declared"
92    }
93
94    fn severity(&self) -> Severity {
95        Severity::Error
96    }
97
98    fn check(&self, model: &Model, _ctx: &ValidationContext) -> Vec<Violation> {
99        check_graph_io_declared(&model.graph, &model.metadata.graph_name, self.id())
100    }
101}
102
103/// Every present node input must have a source, and every named node output must
104/// be consumed, exported, or captured by a subgraph (ONNX_RS §8.2).
105pub struct NoUnconnectedNodesRule;
106
107impl ValidationRule for NoUnconnectedNodesRule {
108    fn id(&self) -> &str {
109        "structure.no_unconnected_nodes"
110    }
111
112    fn severity(&self) -> Severity {
113        Severity::Error
114    }
115
116    fn check(&self, model: &Model, _ctx: &ValidationContext) -> Vec<Violation> {
117        check_graph_connections(
118            &model.graph,
119            &model.metadata.graph_name,
120            self.id(),
121            &HashSet::new(),
122        )
123    }
124}
125
126fn check_graph_io_declared(graph: &Graph, graph_name: &str, rule_id: &str) -> Vec<Violation> {
127    let mut violations = Vec::new();
128    for (kind, ids) in [("input", &graph.inputs), ("output", &graph.outputs)] {
129        for &value_id in ids {
130            match graph.try_value(value_id) {
131                None => violations.push(Violation {
132                    rule_id: rule_id.to_string(),
133                    severity: Severity::Error,
134                    message: format!("graph {kind} references missing value id {}", value_id.0),
135                    location: ViolationLocation::Graph {
136                        graph_name: graph_name.to_string(),
137                    },
138                }),
139                Some(value) if value.name.as_deref().is_none_or(str::is_empty) => {
140                    violations.push(Violation {
141                        rule_id: rule_id.to_string(),
142                        severity: Severity::Error,
143                        message: format!(
144                            "graph {kind} value id {} has no declared name",
145                            value_id.0
146                        ),
147                        location: ViolationLocation::Value {
148                            value_name: value_label(value_id, value.name.as_deref()),
149                        },
150                    });
151                }
152                Some(_) => {}
153            }
154        }
155    }
156    for subgraph in graph.subgraphs.values() {
157        violations.extend(check_graph_io_declared(subgraph, graph_name, rule_id));
158    }
159    violations
160}
161
162fn check_graph_connections(
163    graph: &Graph,
164    graph_name: &str,
165    rule_id: &str,
166    outer_scope: &HashSet<String>,
167) -> Vec<Violation> {
168    let mut violations = Vec::new();
169    let inputs: HashSet<ValueId> = graph.inputs.iter().copied().collect();
170    let graph_outputs: HashSet<ValueId> = graph.outputs.iter().copied().collect();
171    for (_node_id, node) in graph.nodes.iter() {
172        for (index, value_id) in node.inputs.iter().enumerate() {
173            let Some(value_id) = value_id else { continue };
174            let problem = match graph.try_value(*value_id) {
175                None => Some(format!(
176                    "input {index} references missing value id {}",
177                    value_id.0
178                )),
179                Some(value)
180                    if value.producer.is_none()
181                        && !inputs.contains(value_id)
182                        && !graph.initializers.contains_key(value_id) =>
183                {
184                    (!value
185                        .name
186                        .as_ref()
187                        .is_some_and(|name| outer_scope.contains(name)))
188                    .then(|| {
189                        format!(
190                            "input {index} references '{}' which is neither a graph input, initializer, node output, nor outer-scope value",
191                            value_label(*value_id, value.name.as_deref())
192                        )
193                    })
194                }
195                Some(value)
196                    if value
197                        .producer
198                        .is_some_and(|producer| !graph.nodes.contains(producer)) =>
199                {
200                    Some(format!(
201                        "input {index} references '{}' whose producer is missing",
202                        value_label(*value_id, value.name.as_deref())
203                    ))
204                }
205                Some(_) => None,
206            };
207            if let Some(message) = problem {
208                violations.push(node_violation(
209                    rule_id,
210                    graph_name,
211                    node,
212                    format!("node '{}' {message}", node_label(node)),
213                ));
214            }
215        }
216        for (index, &value_id) in node.outputs.iter().enumerate() {
217            let problem = match graph.try_value(value_id) {
218                None => Some(format!(
219                    "output {index} references missing value id {}",
220                    value_id.0
221                )),
222                Some(value) if value.name.as_deref().is_none_or(str::is_empty) => None,
223                Some(value)
224                    if graph_outputs.contains(&value_id)
225                        || graph.has_uses(value_id)
226                        || value
227                            .name
228                            .as_deref()
229                            .is_some_and(|name| subgraphs_capture_name(graph, name)) =>
230                {
231                    None
232                }
233                Some(value) => Some(format!(
234                    "output {index} '{}' is neither consumed, a graph output, nor captured by a subgraph",
235                    value_label(value_id, value.name.as_deref())
236                )),
237            };
238            if let Some(message) = problem {
239                violations.push(node_violation(
240                    rule_id,
241                    graph_name,
242                    node,
243                    format!("node '{}' {message}", node_label(node)),
244                ));
245            }
246        }
247    }
248    let mut visible_names = outer_scope.clone();
249    visible_names.extend(
250        graph
251            .values
252            .values()
253            .filter_map(|value| value.name.as_ref())
254            .filter(|name| !name.is_empty())
255            .cloned(),
256    );
257    for subgraph in graph.subgraphs.values() {
258        violations.extend(check_graph_connections(
259            subgraph,
260            graph_name,
261            rule_id,
262            &visible_names,
263        ));
264    }
265    violations
266}
267
268fn subgraphs_capture_name(graph: &Graph, name: &str) -> bool {
269    graph.subgraphs.values().any(|subgraph| {
270        let locally_defined = subgraph.values.values().any(|value| {
271            value.name.as_deref() == Some(name)
272                && (value.producer.is_some()
273                    || subgraph.inputs.contains(&value.id)
274                    || subgraph.initializers.contains_key(&value.id))
275        });
276        let directly_captured = subgraph.nodes.values().any(|node| {
277            node.input_values().any(|value_id| {
278                subgraph.try_value(value_id).is_some_and(|value| {
279                    value.name.as_deref() == Some(name)
280                        && value.producer.is_none()
281                        && !subgraph.inputs.contains(&value_id)
282                        && !subgraph.initializers.contains_key(&value_id)
283                })
284            })
285        });
286        directly_captured || (!locally_defined && subgraphs_capture_name(subgraph, name))
287    })
288}
289
290fn check_graph_opset_imports(
291    graph: &Graph,
292    graph_name: &str,
293    declared: &std::collections::HashSet<&str>,
294    rule_id: &str,
295) -> Vec<Violation> {
296    let mut violations = Vec::new();
297    for (_nid, node) in graph.nodes.iter() {
298        let domain = normalize_domain(&node.domain);
299        if !declared.contains(domain) {
300            violations.push(Violation {
301                rule_id: rule_id.to_string(),
302                severity: Severity::Error,
303                message: format!(
304                    "node '{}' ({}) uses domain '{}' but no matching opset_import is declared",
305                    node_label(node),
306                    node.op_type,
307                    if node.domain.is_empty() {
308                        "ai.onnx"
309                    } else {
310                        &node.domain
311                    },
312                ),
313                location: ViolationLocation::Node {
314                    graph_name: graph_name.to_string(),
315                    node_name: node_label(node),
316                },
317            });
318        }
319    }
320    for subgraph in graph.subgraphs.values() {
321        violations.extend(check_graph_opset_imports(
322            subgraph, graph_name, declared, rule_id,
323        ));
324    }
325    violations
326}
327
328fn check_graph_duplicate_names(graph: &Graph, rule_id: &str) -> Vec<Violation> {
329    let mut counts: HashMap<&str, usize> = HashMap::new();
330    for (_vid, value) in graph.values.iter() {
331        if let Some(name) = value.name.as_deref()
332            && !name.is_empty()
333        {
334            *counts.entry(name).or_insert(0) += 1;
335        }
336    }
337
338    let mut dups: Vec<(&str, usize)> = counts.into_iter().filter(|(_, count)| *count > 1).collect();
339    dups.sort_by(|a, b| a.0.cmp(b.0));
340
341    let mut violations: Vec<Violation> = dups
342        .into_iter()
343        .map(|(name, count)| Violation {
344            rule_id: rule_id.to_string(),
345            severity: Severity::Error,
346            message: format!("value name '{}' is used by {} distinct values", name, count),
347            location: ViolationLocation::Value {
348                value_name: name.to_string(),
349            },
350        })
351        .collect();
352    for subgraph in graph.subgraphs.values() {
353        violations.extend(check_graph_duplicate_names(subgraph, rule_id));
354    }
355    violations
356}
357
358/// The dataflow graph must be acyclic (ONNX_RS §8.2 `GraphAcyclicRule`). Wraps
359/// the IR's own topological-order check.
360pub struct GraphAcyclicRule;
361
362impl ValidationRule for GraphAcyclicRule {
363    fn id(&self) -> &str {
364        "structure.graph_acyclic"
365    }
366
367    fn severity(&self) -> Severity {
368        Severity::Error
369    }
370
371    fn check(&self, model: &Model, _ctx: &ValidationContext) -> Vec<Violation> {
372        check_graph_acyclic(&model.graph, &model.metadata.graph_name, self.id())
373    }
374}
375
376/// Recursively check `graph` (and its control-flow subgraphs) for cycles.
377fn check_graph_acyclic(graph: &Graph, graph_name: &str, rule_id: &str) -> Vec<Violation> {
378    let mut violations = Vec::new();
379    if graph.topological_order().is_err() {
380        violations.push(Violation {
381            rule_id: rule_id.to_string(),
382            severity: Severity::Error,
383            message: "graph contains a cycle (no valid topological order)".to_string(),
384            location: ViolationLocation::Graph {
385                graph_name: graph_name.to_string(),
386            },
387        });
388    }
389    for sub in graph.subgraphs.values() {
390        violations.extend(check_graph_acyclic(sub, graph_name, rule_id));
391    }
392    violations
393}
394
395/// Each node must resolve to an opset-compatible schema and conform to its
396/// input/output and attribute declarations (ONNX_RS §8.2).
397pub struct SchemaNodeConformsRule;
398
399impl ValidationRule for SchemaNodeConformsRule {
400    fn id(&self) -> &str {
401        "schema.node_conforms"
402    }
403
404    fn severity(&self) -> Severity {
405        Severity::Error
406    }
407
408    fn check(&self, model: &Model, ctx: &ValidationContext) -> Vec<Violation> {
409        check_graph_schemas(
410            &model.graph,
411            &model.graph.opset_imports,
412            &model.metadata.graph_name,
413            ctx,
414            self.id(),
415        )
416    }
417}
418
419/// Node input/output element types must satisfy and consistently bind the type
420/// variables declared by the resolved operator schema (ONNX_RS §8.2).
421pub struct TypeConstraintSatisfiedRule;
422
423impl ValidationRule for TypeConstraintSatisfiedRule {
424    fn id(&self) -> &str {
425        "schema.type_constraint_satisfied"
426    }
427
428    fn severity(&self) -> Severity {
429        Severity::Error
430    }
431
432    fn check(&self, model: &Model, ctx: &ValidationContext) -> Vec<Violation> {
433        check_graph_type_constraints(
434            &model.graph,
435            &model.graph.opset_imports,
436            &model.metadata.graph_name,
437            ctx,
438            self.id(),
439        )
440    }
441}
442
443fn check_graph_type_constraints(
444    graph: &Graph,
445    opset_imports: &HashMap<String, u64>,
446    graph_name: &str,
447    ctx: &ValidationContext,
448    rule_id: &str,
449) -> Vec<Violation> {
450    let mut violations = Vec::new();
451    for (_node_id, node) in graph.nodes.iter() {
452        let Some(opset) = imported_opset(opset_imports, &node.domain) else {
453            continue;
454        };
455        let Some(schema) = ctx.schemas().lookup(&node.op_type, &node.domain, opset) else {
456            continue;
457        };
458        let mut bindings = HashMap::new();
459        for (type_param, value_id) in schema_value_bindings(schema, node) {
460            let Some(value) = graph.try_value(value_id) else {
461                continue;
462            };
463            if !graph.value_type_is_known(value_id) {
464                continue;
465            }
466            if let Some(expected) = concrete_tensor_dtype(type_param) {
467                if value.dtype != expected {
468                    violations.push(node_violation(
469                        rule_id,
470                        graph_name,
471                        node,
472                        format!(
473                            "concrete type '{}' requires {:?} for value '{}', found {:?}",
474                            type_param,
475                            expected,
476                            value_label(value_id, value.name.as_deref()),
477                            value.dtype
478                        ),
479                    ));
480                }
481                continue;
482            }
483            let Some(constraint) = schema
484                .type_constraints
485                .iter()
486                .find(|constraint| constraint.type_param == type_param)
487            else {
488                continue;
489            };
490            if !constraint.allowed.contains(&value.dtype) {
491                violations.push(node_violation(
492                    rule_id,
493                    graph_name,
494                    node,
495                    format!(
496                        "type parameter '{}' does not allow {:?} for value '{}'",
497                        type_param,
498                        value.dtype,
499                        value_label(value_id, value.name.as_deref())
500                    ),
501                ));
502                continue;
503            }
504            match bindings.entry(type_param) {
505                std::collections::hash_map::Entry::Vacant(entry) => {
506                    entry.insert(value.dtype);
507                }
508                std::collections::hash_map::Entry::Occupied(entry)
509                    if *entry.get() != value.dtype =>
510                {
511                    violations.push(node_violation(
512                        rule_id,
513                        graph_name,
514                        node,
515                        format!(
516                            "type parameter '{}' is bound to both {:?} and {:?}",
517                            type_param,
518                            entry.get(),
519                            value.dtype
520                        ),
521                    ));
522                }
523                std::collections::hash_map::Entry::Occupied(_) => {}
524            }
525        }
526    }
527    for subgraph in graph.subgraphs.values() {
528        violations.extend(check_graph_type_constraints(
529            subgraph,
530            opset_imports,
531            graph_name,
532            ctx,
533            rule_id,
534        ));
535    }
536    violations
537}
538
539fn concrete_tensor_dtype(type_str: &str) -> Option<onnx_runtime_ir::DataType> {
540    let dtype = type_str.strip_prefix("tensor(")?.strip_suffix(')')?;
541    Some(match dtype {
542        "float" => onnx_runtime_ir::DataType::Float32,
543        "double" => onnx_runtime_ir::DataType::Float64,
544        "float16" => onnx_runtime_ir::DataType::Float16,
545        "bfloat16" => onnx_runtime_ir::DataType::BFloat16,
546        "uint8" => onnx_runtime_ir::DataType::Uint8,
547        "uint16" => onnx_runtime_ir::DataType::Uint16,
548        "uint32" => onnx_runtime_ir::DataType::Uint32,
549        "uint64" => onnx_runtime_ir::DataType::Uint64,
550        "int8" => onnx_runtime_ir::DataType::Int8,
551        "int16" => onnx_runtime_ir::DataType::Int16,
552        "int32" => onnx_runtime_ir::DataType::Int32,
553        "int64" => onnx_runtime_ir::DataType::Int64,
554        "bool" => onnx_runtime_ir::DataType::Bool,
555        "string" => onnx_runtime_ir::DataType::String,
556        "complex64" => onnx_runtime_ir::DataType::Complex64,
557        "complex128" => onnx_runtime_ir::DataType::Complex128,
558        "float8e4m3fn" => onnx_runtime_ir::DataType::Float8E4M3FN,
559        "float8e4m3fnuz" => onnx_runtime_ir::DataType::Float8E4M3FNUZ,
560        "float8e5m2" => onnx_runtime_ir::DataType::Float8E5M2,
561        "float8e5m2fnuz" => onnx_runtime_ir::DataType::Float8E5M2FNUZ,
562        "uint4" => onnx_runtime_ir::DataType::Uint4,
563        "int4" => onnx_runtime_ir::DataType::Int4,
564        "float4e2m1" => onnx_runtime_ir::DataType::Float4E2M1,
565        "float8e8m0" => onnx_runtime_ir::DataType::Float8E8M0,
566        "uint2" => onnx_runtime_ir::DataType::Uint2,
567        "int2" => onnx_runtime_ir::DataType::Int2,
568        _ => return None,
569    })
570}
571
572fn schema_value_bindings<'a>(
573    schema: &'a OpSchema,
574    node: &'a onnx_runtime_ir::Node,
575) -> Vec<(&'a str, ValueId)> {
576    let mut bindings = Vec::new();
577    for (index, spec) in schema.inputs.iter().enumerate() {
578        let slots = if spec.variadic {
579            node.inputs.get(index..).unwrap_or_default()
580        } else {
581            node.inputs
582                .get(index..index.saturating_add(1))
583                .unwrap_or_default()
584        };
585        bindings.extend(
586            slots
587                .iter()
588                .filter_map(|slot| *slot)
589                .map(|value_id| (spec.type_str.as_str(), value_id)),
590        );
591    }
592    for (index, spec) in schema.outputs.iter().enumerate() {
593        let values = if spec.variadic {
594            node.outputs.get(index..).unwrap_or_default()
595        } else {
596            node.outputs
597                .get(index..index.saturating_add(1))
598                .unwrap_or_default()
599        };
600        bindings.extend(
601            values
602                .iter()
603                .copied()
604                .map(|value_id| (spec.type_str.as_str(), value_id)),
605        );
606    }
607    bindings
608}
609
610/// Initializer tensor element types must match their graph value declarations
611/// (ONNX_RS §8.2 `InitializerTypeMatchesDeclaredRule`).
612pub struct InitializerTypeMatchesDeclaredRule;
613
614impl ValidationRule for InitializerTypeMatchesDeclaredRule {
615    fn id(&self) -> &str {
616        "type.initializer_matches_declared"
617    }
618
619    fn severity(&self) -> Severity {
620        Severity::Error
621    }
622
623    fn check(&self, model: &Model, _ctx: &ValidationContext) -> Vec<Violation> {
624        check_graph_initializer_types(&model.graph, self.id())
625    }
626}
627
628fn check_graph_initializer_types(graph: &Graph, rule_id: &str) -> Vec<Violation> {
629    let mut violations = Vec::new();
630    for (&value_id, initializer) in &graph.initializers {
631        match graph.try_value(value_id) {
632            None => violations.push(Violation {
633                rule_id: rule_id.to_string(),
634                severity: Severity::Error,
635                message: format!("initializer references missing value id {}", value_id.0),
636                location: ViolationLocation::Value {
637                    value_name: format!("<value#{}>", value_id.0),
638                },
639            }),
640            Some(value) if value.dtype != initializer.dtype() => violations.push(Violation {
641                rule_id: rule_id.to_string(),
642                severity: Severity::Error,
643                message: format!(
644                    "initializer '{}' has dtype {:?} but its declared value type is {:?}",
645                    value_label(value_id, value.name.as_deref()),
646                    initializer.dtype(),
647                    value.dtype
648                ),
649                location: ViolationLocation::Value {
650                    value_name: value_label(value_id, value.name.as_deref()),
651                },
652            }),
653            Some(value)
654                if graph.value_shape_is_known(value_id)
655                    && initializer_shape_mismatch(&value.shape, initializer.dims()) =>
656            {
657                violations.push(Violation {
658                    rule_id: rule_id.to_string(),
659                    severity: Severity::Error,
660                    message: format!(
661                        "initializer '{}' has shape {:?} but its declared value shape is {:?}",
662                        value_label(value_id, value.name.as_deref()),
663                        initializer.dims(),
664                        value.shape
665                    ),
666                    location: ViolationLocation::Value {
667                        value_name: value_label(value_id, value.name.as_deref()),
668                    },
669                })
670            }
671            Some(_) => {}
672        }
673    }
674    for subgraph in graph.subgraphs.values() {
675        violations.extend(check_graph_initializer_types(subgraph, rule_id));
676    }
677    violations
678}
679
680fn initializer_shape_mismatch(declared: &[onnx_runtime_ir::Dim], actual: &[usize]) -> bool {
681    declared.len() != actual.len()
682        || declared
683            .iter()
684            .zip(actual)
685            .any(|(declared, &actual)| declared.as_static().is_some_and(|dim| dim != actual))
686}
687
688/// The ONNX IR version is required and must not exceed the version implemented
689/// by this v1.20-bound checker (IR 13).
690pub struct IrVersionSupportedRule;
691
692impl ValidationRule for IrVersionSupportedRule {
693    fn id(&self) -> &str {
694        "ir.version_supported"
695    }
696
697    fn severity(&self) -> Severity {
698        Severity::Error
699    }
700
701    fn check(&self, model: &Model, _ctx: &ValidationContext) -> Vec<Violation> {
702        if (1..=13).contains(&model.metadata.ir_version) {
703            return Vec::new();
704        }
705        let message = if model.metadata.ir_version < 1 {
706            format!(
707                "ir_version {} is invalid; ONNX IR versions start at 1",
708                model.metadata.ir_version
709            )
710        } else {
711            format!(
712                "ir_version {} is newer than this ONNX v1.20 checker (IR 13)",
713                model.metadata.ir_version
714            )
715        };
716        vec![Violation {
717            rule_id: self.id().to_string(),
718            severity: self.severity(),
719            message,
720            location: ViolationLocation::Model,
721        }]
722    }
723}
724
725/// Validate fields and invariants whose legality changed at a particular ONNX
726/// IR version. Training-only fields are intentionally ignored.
727pub struct IrVersionFeatureRule;
728
729impl ValidationRule for IrVersionFeatureRule {
730    fn id(&self) -> &str {
731        "ir.version_gated_features"
732    }
733
734    fn severity(&self) -> Severity {
735        Severity::Error
736    }
737
738    fn check(&self, model: &Model, _ctx: &ValidationContext) -> Vec<Violation> {
739        let ir_version = model.metadata.ir_version;
740        let mut violations = Vec::new();
741        if ir_version >= 3 && model.graph.opset_imports.is_empty() {
742            violations.push(model_violation(
743                self.id(),
744                format!("model with IR version {ir_version} must specify opset_import"),
745            ));
746        } else if ir_version < 3 && !model.graph.opset_imports.is_empty() {
747            violations.push(model_violation(
748                self.id(),
749                "models with IR version below 3 must not specify opset_import",
750            ));
751        }
752        let Some(proto) = model.retained_proto() else {
753            return violations;
754        };
755        if ir_version < 8 && !proto.functions.is_empty() {
756            violations.push(model_violation(
757                self.id(),
758                "ModelProto.functions requires IR version 8 or newer",
759            ));
760        }
761        if let Some(graph) = &proto.graph {
762            check_graph_ir_features(graph, ir_version, self.id(), &mut violations);
763        }
764        for function in &proto.functions {
765            check_function_ir_features(function, ir_version, self.id(), &mut violations);
766        }
767        violations
768    }
769}
770
771fn check_graph_ir_features(
772    graph: &GraphProto,
773    ir_version: i64,
774    rule_id: &str,
775    violations: &mut Vec<Violation>,
776) {
777    let location = ViolationLocation::Graph {
778        graph_name: graph.name.clone(),
779    };
780    if ir_version <= 3 {
781        let inputs = graph
782            .input
783            .iter()
784            .map(|value| value.name.as_str())
785            .collect::<HashSet<_>>();
786        for initializer in &graph.initializer {
787            if !inputs.contains(initializer.name.as_str()) {
788                violations.push(Violation {
789                    rule_id: rule_id.to_string(),
790                    severity: Severity::Error,
791                    message: format!(
792                        "initializer '{}' must also be a graph input for IR version {ir_version}",
793                        initializer.name
794                    ),
795                    location: location.clone(),
796                });
797            }
798        }
799    }
800    if ir_version < 5 && !graph.quantization_annotation.is_empty() {
801        violations.push(Violation {
802            rule_id: rule_id.to_string(),
803            severity: Severity::Error,
804            message: "GraphProto.quantization_annotation requires IR version 5 or newer".into(),
805            location: location.clone(),
806        });
807    }
808    if ir_version < 6 && !graph.sparse_initializer.is_empty() {
809        violations.push(Violation {
810            rule_id: rule_id.to_string(),
811            severity: Severity::Error,
812            message: "GraphProto.sparse_initializer requires IR version 6 or newer".into(),
813            location: location.clone(),
814        });
815    }
816    if ir_version < 10 && !graph.metadata_props.is_empty() {
817        violations.push(Violation {
818            rule_id: rule_id.to_string(),
819            severity: Severity::Error,
820            message: "GraphProto.metadata_props requires IR version 10 or newer".into(),
821            location: location.clone(),
822        });
823    }
824    for value in graph
825        .input
826        .iter()
827        .chain(&graph.output)
828        .chain(&graph.value_info)
829    {
830        check_value_ir_features(value, ir_version, rule_id, violations);
831    }
832    for tensor in &graph.initializer {
833        check_tensor_ir_features(tensor, ir_version, rule_id, violations);
834    }
835    for sparse in &graph.sparse_initializer {
836        check_sparse_ir_features(sparse, ir_version, rule_id, violations);
837    }
838    for node in &graph.node {
839        check_node_ir_features(node, ir_version, &graph.name, rule_id, violations);
840    }
841}
842
843fn check_function_ir_features(
844    function: &FunctionProto,
845    ir_version: i64,
846    rule_id: &str,
847    violations: &mut Vec<Violation>,
848) {
849    if ir_version < 9 && !function.attribute_proto.is_empty() {
850        violations.push(model_violation(
851            rule_id,
852            format!(
853                "FunctionProto '{}' attribute_proto requires IR version 9 or newer",
854                function.name
855            ),
856        ));
857    }
858    if ir_version < 10 {
859        if !function.overload.is_empty() {
860            violations.push(model_violation(
861                rule_id,
862                format!(
863                    "FunctionProto '{}' overload requires IR version 10 or newer",
864                    function.name
865                ),
866            ));
867        }
868        if !function.value_info.is_empty() {
869            violations.push(model_violation(
870                rule_id,
871                format!(
872                    "FunctionProto '{}' value_info requires IR version 10 or newer",
873                    function.name
874                ),
875            ));
876        }
877        if !function.metadata_props.is_empty() {
878            violations.push(model_violation(
879                rule_id,
880                format!(
881                    "FunctionProto '{}' metadata_props requires IR version 10 or newer",
882                    function.name
883                ),
884            ));
885        }
886    }
887    for value in &function.value_info {
888        check_value_ir_features(value, ir_version, rule_id, violations);
889    }
890    for attribute in &function.attribute_proto {
891        check_attribute_ir_features(
892            attribute,
893            ir_version,
894            ViolationLocation::Model,
895            rule_id,
896            violations,
897        );
898    }
899    for node in &function.node {
900        check_node_ir_features(node, ir_version, &function.name, rule_id, violations);
901    }
902}
903
904fn check_node_ir_features(
905    node: &NodeProto,
906    ir_version: i64,
907    graph_name: &str,
908    rule_id: &str,
909    violations: &mut Vec<Violation>,
910) {
911    let location = ViolationLocation::Node {
912        graph_name: graph_name.to_string(),
913        node_name: proto_node_label(node),
914    };
915    if ir_version < 10 && (!node.overload.is_empty() || !node.metadata_props.is_empty()) {
916        violations.push(Violation {
917            rule_id: rule_id.to_string(),
918            severity: Severity::Error,
919            message: "NodeProto overload and metadata_props require IR version 10 or newer".into(),
920            location: location.clone(),
921        });
922    }
923    for attribute in &node.attribute {
924        check_attribute_ir_features(attribute, ir_version, location.clone(), rule_id, violations);
925        if let Some(graph) = &attribute.g {
926            check_graph_ir_features(graph, ir_version, rule_id, violations);
927        }
928        for graph in &attribute.graphs {
929            check_graph_ir_features(graph, ir_version, rule_id, violations);
930        }
931    }
932    if ir_version < 11 && !node.device_configurations.is_empty() {
933        violations.push(Violation {
934            rule_id: rule_id.to_string(),
935            severity: Severity::Error,
936            message: "NodeProto.device_configurations requires IR version 11 or newer".into(),
937            location,
938        });
939    }
940}
941
942fn check_attribute_ir_features(
943    attribute: &AttributeProto,
944    ir_version: i64,
945    location: ViolationLocation,
946    rule_id: &str,
947    violations: &mut Vec<Violation>,
948) {
949    if ir_version < 6 && (attribute.sparse_tensor.is_some() || !attribute.sparse_tensors.is_empty())
950    {
951        violations.push(Violation {
952            rule_id: rule_id.to_string(),
953            severity: Severity::Error,
954            message: "sparse tensor attributes require IR version 6 or newer".into(),
955            location: location.clone(),
956        });
957    }
958    if let Some(tensor) = &attribute.t {
959        check_tensor_ir_features(tensor, ir_version, rule_id, violations);
960    }
961    for tensor in &attribute.tensors {
962        check_tensor_ir_features(tensor, ir_version, rule_id, violations);
963    }
964    if let Some(sparse) = &attribute.sparse_tensor {
965        check_sparse_ir_features(sparse, ir_version, rule_id, violations);
966    }
967    for sparse in &attribute.sparse_tensors {
968        check_sparse_ir_features(sparse, ir_version, rule_id, violations);
969    }
970    if let Some(value) = &attribute.tp {
971        check_type_ir_features(value, ir_version, location.clone(), rule_id, violations);
972    }
973    for value in &attribute.type_protos {
974        check_type_ir_features(value, ir_version, location.clone(), rule_id, violations);
975    }
976}
977
978fn check_value_ir_features(
979    value: &ValueInfoProto,
980    ir_version: i64,
981    rule_id: &str,
982    violations: &mut Vec<Violation>,
983) {
984    let location = ViolationLocation::Value {
985        value_name: value.name.clone(),
986    };
987    if ir_version < 10 && !value.metadata_props.is_empty() {
988        violations.push(Violation {
989            rule_id: rule_id.to_string(),
990            severity: Severity::Error,
991            message: "ValueInfoProto.metadata_props requires IR version 10 or newer".into(),
992            location: location.clone(),
993        });
994    }
995    if let Some(value_type) = &value.r#type {
996        check_type_ir_features(value_type, ir_version, location, rule_id, violations);
997    }
998}
999
1000fn check_type_ir_features(
1001    value: &TypeProto,
1002    ir_version: i64,
1003    location: ViolationLocation,
1004    rule_id: &str,
1005    violations: &mut Vec<Violation>,
1006) {
1007    match value.value.as_ref() {
1008        Some(type_proto::Value::TensorType(tensor)) => {
1009            check_dtype_ir_feature(tensor.elem_type, ir_version, location, rule_id, violations);
1010        }
1011        Some(type_proto::Value::SparseTensorType(tensor)) => {
1012            if ir_version < 8 {
1013                violations.push(Violation {
1014                    rule_id: rule_id.to_string(),
1015                    severity: Severity::Error,
1016                    message: "TypeProto.SparseTensor requires IR version 8 or newer".into(),
1017                    location: location.clone(),
1018                });
1019            }
1020            check_dtype_ir_feature(tensor.elem_type, ir_version, location, rule_id, violations);
1021        }
1022        Some(type_proto::Value::OptionalType(optional)) => {
1023            if ir_version < 8 {
1024                violations.push(Violation {
1025                    rule_id: rule_id.to_string(),
1026                    severity: Severity::Error,
1027                    message: "TypeProto.Optional requires IR version 8 or newer".into(),
1028                    location: location.clone(),
1029                });
1030            }
1031            if let Some(elem_type) = &optional.elem_type {
1032                check_type_ir_features(elem_type, ir_version, location, rule_id, violations);
1033            }
1034        }
1035        Some(type_proto::Value::SequenceType(sequence)) => {
1036            if let Some(elem_type) = &sequence.elem_type {
1037                check_type_ir_features(elem_type, ir_version, location, rule_id, violations);
1038            }
1039        }
1040        Some(type_proto::Value::MapType(map)) => {
1041            if let Some(value_type) = &map.value_type {
1042                check_type_ir_features(value_type, ir_version, location, rule_id, violations);
1043            }
1044        }
1045        Some(type_proto::Value::OpaqueType(_)) | None => {}
1046    }
1047}
1048
1049fn check_sparse_ir_features(
1050    sparse: &SparseTensorProto,
1051    ir_version: i64,
1052    rule_id: &str,
1053    violations: &mut Vec<Violation>,
1054) {
1055    if let Some(values) = &sparse.values {
1056        check_tensor_ir_features(values, ir_version, rule_id, violations);
1057    }
1058    if let Some(indices) = &sparse.indices {
1059        check_tensor_ir_features(indices, ir_version, rule_id, violations);
1060    }
1061}
1062
1063fn check_tensor_ir_features(
1064    tensor: &TensorProto,
1065    ir_version: i64,
1066    rule_id: &str,
1067    violations: &mut Vec<Violation>,
1068) {
1069    let location = ViolationLocation::Value {
1070        value_name: tensor.name.clone(),
1071    };
1072    if ir_version < 10 && !tensor.metadata_props.is_empty() {
1073        violations.push(Violation {
1074            rule_id: rule_id.to_string(),
1075            severity: Severity::Error,
1076            message: "TensorProto.metadata_props requires IR version 10 or newer".into(),
1077            location: location.clone(),
1078        });
1079    }
1080    check_dtype_ir_feature(tensor.data_type, ir_version, location, rule_id, violations);
1081}
1082
1083fn check_dtype_ir_feature(
1084    dtype: i32,
1085    ir_version: i64,
1086    location: ViolationLocation,
1087    rule_id: &str,
1088    violations: &mut Vec<Violation>,
1089) {
1090    let required = match tensor_proto::DataType::try_from(dtype).ok() {
1091        Some(tensor_proto::DataType::Bfloat16) => 4,
1092        Some(
1093            tensor_proto::DataType::Float8e4m3fn
1094            | tensor_proto::DataType::Float8e4m3fnuz
1095            | tensor_proto::DataType::Float8e5m2
1096            | tensor_proto::DataType::Float8e5m2fnuz,
1097        ) => 9,
1098        Some(tensor_proto::DataType::Uint4 | tensor_proto::DataType::Int4) => 10,
1099        Some(tensor_proto::DataType::Float4e2m1) => 11,
1100        Some(tensor_proto::DataType::Float8e8m0) => 12,
1101        Some(tensor_proto::DataType::Uint2 | tensor_proto::DataType::Int2) => 13,
1102        _ => return,
1103    };
1104    if ir_version < required {
1105        violations.push(Violation {
1106            rule_id: rule_id.to_string(),
1107            severity: Severity::Error,
1108            message: format!(
1109                "TensorProto data type {dtype} requires IR version {required} or newer"
1110            ),
1111            location,
1112        });
1113    }
1114}
1115
1116/// Validate model-local functions using the v1.20 checker topology/import
1117/// rules plus signature, default-attribute, and unique-ID rules.
1118pub struct FunctionProtoValidityRule;
1119
1120impl ValidationRule for FunctionProtoValidityRule {
1121    fn id(&self) -> &str {
1122        "proto.function_valid"
1123    }
1124
1125    fn severity(&self) -> Severity {
1126        Severity::Error
1127    }
1128
1129    fn check(&self, model: &Model, ctx: &ValidationContext) -> Vec<Violation> {
1130        let Some(proto) = model.retained_proto() else {
1131            return Vec::new();
1132        };
1133        if proto.ir_version < 8 {
1134            return Vec::new();
1135        }
1136        check_model_functions(proto, ctx, self.id())
1137    }
1138}
1139
1140type FunctionKey = (String, String, String);
1141
1142fn function_key(function: &FunctionProto) -> FunctionKey {
1143    (
1144        normalize_domain(&function.domain).to_string(),
1145        function.name.clone(),
1146        function.overload.clone(),
1147    )
1148}
1149
1150fn node_function_key(node: &NodeProto) -> FunctionKey {
1151    (
1152        normalize_domain(&node.domain).to_string(),
1153        node.op_type.clone(),
1154        node.overload.clone(),
1155    )
1156}
1157
1158fn check_model_functions(
1159    model: &ModelProto,
1160    ctx: &ValidationContext,
1161    rule_id: &str,
1162) -> Vec<Violation> {
1163    let mut violations = Vec::new();
1164    let mut functions = HashMap::new();
1165    for function in &model.functions {
1166        let key = function_key(function);
1167        if functions.insert(key.clone(), function).is_some() {
1168            violations.push(model_violation(
1169                rule_id,
1170                format!(
1171                    "model-local function '{}::{}' overload '{}' is not unique",
1172                    key.0, key.1, key.2
1173                ),
1174            ));
1175        }
1176    }
1177
1178    let mut model_opsets = opset_map(&model.opset_import);
1179    for function in &model.functions {
1180        for import in &function.opset_import {
1181            model_opsets
1182                .entry(normalize_domain(&import.domain).to_string())
1183                .or_insert(import.version);
1184        }
1185    }
1186
1187    for function in &model.functions {
1188        check_function_proto(function, &model_opsets, ctx, rule_id, &mut violations);
1189    }
1190    check_function_recursion(&functions, rule_id, &mut violations);
1191    violations
1192}
1193
1194fn opset_map(
1195    imports: &[onnx_runtime_loader::proto::onnx::OperatorSetIdProto],
1196) -> HashMap<String, i64> {
1197    imports
1198        .iter()
1199        .map(|import| (normalize_domain(&import.domain).to_string(), import.version))
1200        .collect()
1201}
1202
1203fn check_function_proto(
1204    function: &FunctionProto,
1205    model_opsets: &HashMap<String, i64>,
1206    ctx: &ValidationContext,
1207    rule_id: &str,
1208    violations: &mut Vec<Violation>,
1209) {
1210    if function.name.is_empty() {
1211        violations.push(model_violation(
1212            rule_id,
1213            "FunctionProto.name must be non-empty",
1214        ));
1215    }
1216    check_unique_function_names(
1217        &function.input,
1218        "input",
1219        &function.name,
1220        rule_id,
1221        violations,
1222    );
1223    check_unique_function_names(
1224        &function.output,
1225        "output",
1226        &function.name,
1227        rule_id,
1228        violations,
1229    );
1230    check_unique_function_names(
1231        &function.attribute,
1232        "attribute",
1233        &function.name,
1234        rule_id,
1235        violations,
1236    );
1237
1238    let required_attrs = function
1239        .attribute
1240        .iter()
1241        .map(String::as_str)
1242        .collect::<HashSet<_>>();
1243    let mut default_attrs = HashSet::new();
1244    for attribute in &function.attribute_proto {
1245        if !default_attrs.insert(attribute.name.as_str()) {
1246            violations.push(model_violation(
1247                rule_id,
1248                format!(
1249                    "function '{}' has duplicate default attribute '{}'",
1250                    function.name, attribute.name
1251                ),
1252            ));
1253        }
1254        if required_attrs.contains(attribute.name.as_str()) {
1255            violations.push(model_violation(
1256                rule_id,
1257                format!(
1258                    "function '{}' attribute '{}' appears in both attribute and attribute_proto",
1259                    function.name, attribute.name
1260                ),
1261            ));
1262        }
1263    }
1264    let formal_attrs = required_attrs
1265        .union(&default_attrs)
1266        .copied()
1267        .collect::<HashSet<_>>();
1268    for node in &function.node {
1269        check_function_attribute_refs(node, &formal_attrs, &function.name, rule_id, violations);
1270    }
1271
1272    let function_opsets = opset_map(&function.opset_import);
1273    let mut local = HashSet::new();
1274    for input in &function.input {
1275        local.insert(input.clone());
1276    }
1277    check_proto_nodes(
1278        &function.node,
1279        &mut local,
1280        &HashSet::new(),
1281        &function.name,
1282        model_opsets,
1283        &function_opsets,
1284        ctx,
1285        rule_id,
1286        violations,
1287    );
1288}
1289
1290fn check_unique_function_names(
1291    names: &[String],
1292    kind: &str,
1293    function_name: &str,
1294    rule_id: &str,
1295    violations: &mut Vec<Violation>,
1296) {
1297    let mut seen = HashSet::new();
1298    for name in names {
1299        if !seen.insert(name.as_str()) {
1300            violations.push(model_violation(
1301                rule_id,
1302                format!("function '{function_name}' has duplicate {kind} '{name}'"),
1303            ));
1304        }
1305    }
1306}
1307
1308#[allow(clippy::too_many_arguments)]
1309fn check_proto_nodes(
1310    nodes: &[NodeProto],
1311    local: &mut HashSet<String>,
1312    outer: &HashSet<String>,
1313    graph_name: &str,
1314    model_opsets: &HashMap<String, i64>,
1315    function_opsets: &HashMap<String, i64>,
1316    ctx: &ValidationContext,
1317    rule_id: &str,
1318    violations: &mut Vec<Violation>,
1319) {
1320    for node in nodes {
1321        let location = ViolationLocation::Node {
1322            graph_name: graph_name.to_string(),
1323            node_name: proto_node_label(node),
1324        };
1325        for input in &node.input {
1326            if !input.is_empty() && !local.contains(input) && !outer.contains(input) {
1327                violations.push(Violation {
1328                    rule_id: rule_id.to_string(),
1329                    severity: Severity::Error,
1330                    message: format!(
1331                        "input '{input}' is neither a function/graph input nor an output of a previous node"
1332                    ),
1333                    location: location.clone(),
1334                });
1335            }
1336        }
1337        if node.op_type.is_empty() {
1338            violations.push(Violation {
1339                rule_id: rule_id.to_string(),
1340                severity: Severity::Error,
1341                message: "NodeProto.op_type must be non-empty".into(),
1342                location: location.clone(),
1343            });
1344        }
1345        if node.input.is_empty() && node.output.is_empty() {
1346            violations.push(Violation {
1347                rule_id: rule_id.to_string(),
1348                severity: Severity::Error,
1349                message: "function-body node must have at least one input or output".into(),
1350                location: location.clone(),
1351            });
1352        }
1353        check_function_node_schema(
1354            node,
1355            graph_name,
1356            model_opsets,
1357            function_opsets,
1358            ctx,
1359            rule_id,
1360            violations,
1361        );
1362        let mut visible = outer.clone();
1363        visible.extend(local.iter().cloned());
1364        for attribute in &node.attribute {
1365            if let Some(graph) = &attribute.g {
1366                check_function_subgraph(
1367                    graph,
1368                    &visible,
1369                    model_opsets,
1370                    function_opsets,
1371                    ctx,
1372                    rule_id,
1373                    violations,
1374                );
1375            }
1376            for graph in &attribute.graphs {
1377                check_function_subgraph(
1378                    graph,
1379                    &visible,
1380                    model_opsets,
1381                    function_opsets,
1382                    ctx,
1383                    rule_id,
1384                    violations,
1385                );
1386            }
1387        }
1388        for output in &node.output {
1389            if output.is_empty() {
1390                continue;
1391            }
1392            if local.contains(output) || outer.contains(output) {
1393                violations.push(Violation {
1394                    rule_id: rule_id.to_string(),
1395                    severity: Severity::Error,
1396                    message: format!(
1397                        "output '{output}' violates single static assignment in function body"
1398                    ),
1399                    location: location.clone(),
1400                });
1401            } else {
1402                local.insert(output.clone());
1403            }
1404        }
1405    }
1406}
1407
1408#[allow(clippy::too_many_arguments)]
1409fn check_function_subgraph(
1410    graph: &GraphProto,
1411    outer: &HashSet<String>,
1412    model_opsets: &HashMap<String, i64>,
1413    function_opsets: &HashMap<String, i64>,
1414    ctx: &ValidationContext,
1415    rule_id: &str,
1416    violations: &mut Vec<Violation>,
1417) {
1418    if graph.name.is_empty() {
1419        violations.push(Violation {
1420            rule_id: rule_id.to_string(),
1421            severity: Severity::Error,
1422            message: "function-body subgraph name must be non-empty".into(),
1423            location: ViolationLocation::Graph {
1424                graph_name: graph.name.clone(),
1425            },
1426        });
1427    }
1428    let mut local = HashSet::new();
1429    for input in &graph.input {
1430        if !local.insert(input.name.clone()) {
1431            violations.push(Violation {
1432                rule_id: rule_id.to_string(),
1433                severity: Severity::Error,
1434                message: format!("subgraph input '{}' is not unique", input.name),
1435                location: ViolationLocation::Graph {
1436                    graph_name: graph.name.clone(),
1437                },
1438            });
1439        }
1440    }
1441    for initializer in &graph.initializer {
1442        local.insert(initializer.name.clone());
1443    }
1444    for sparse in &graph.sparse_initializer {
1445        if let Some(values) = &sparse.values {
1446            local.insert(values.name.clone());
1447        }
1448    }
1449    check_proto_nodes(
1450        &graph.node,
1451        &mut local,
1452        outer,
1453        &graph.name,
1454        model_opsets,
1455        function_opsets,
1456        ctx,
1457        rule_id,
1458        violations,
1459    );
1460    for output in &graph.output {
1461        if !local.contains(&output.name) {
1462            violations.push(Violation {
1463                rule_id: rule_id.to_string(),
1464                severity: Severity::Error,
1465                message: format!(
1466                    "subgraph output '{}' is not defined in the subgraph",
1467                    output.name
1468                ),
1469                location: ViolationLocation::Graph {
1470                    graph_name: graph.name.clone(),
1471                },
1472            });
1473        }
1474    }
1475}
1476
1477fn check_function_node_schema(
1478    node: &NodeProto,
1479    graph_name: &str,
1480    model_opsets: &HashMap<String, i64>,
1481    function_opsets: &HashMap<String, i64>,
1482    ctx: &ValidationContext,
1483    rule_id: &str,
1484    violations: &mut Vec<Violation>,
1485) {
1486    let domain = normalize_domain(&node.domain);
1487    let location = ViolationLocation::Node {
1488        graph_name: graph_name.to_string(),
1489        node_name: proto_node_label(node),
1490    };
1491    let Some(&function_version) = function_opsets.get(domain) else {
1492        violations.push(Violation {
1493            rule_id: rule_id.to_string(),
1494            severity: Severity::Error,
1495            message: format!("no opset import for function-body domain '{domain}'"),
1496            location,
1497        });
1498        return;
1499    };
1500    if let Some(&model_version) = model_opsets.get(domain)
1501        && model_version != function_version
1502    {
1503        let function_schema = u64::try_from(function_version)
1504            .ok()
1505            .and_then(|version| ctx.schemas().lookup(&node.op_type, domain, version));
1506        let model_schema = u64::try_from(model_version)
1507            .ok()
1508            .and_then(|version| ctx.schemas().lookup(&node.op_type, domain, version));
1509        if !(function_schema.is_none() && model_schema.is_none())
1510            && function_schema.map(|schema| schema.since_version)
1511                != model_schema.map(|schema| schema.since_version)
1512        {
1513            violations.push(Violation {
1514                rule_id: rule_id.to_string(),
1515                severity: Severity::Error,
1516                message: format!(
1517                    "function opset {function_version} for '{domain}::{}' is incompatible with model opset {model_version}",
1518                    node.op_type
1519                ),
1520                location: location.clone(),
1521            });
1522        }
1523    }
1524
1525    let Some(version) = u64::try_from(function_version).ok() else {
1526        violations.push(Violation {
1527            rule_id: rule_id.to_string(),
1528            severity: Severity::Error,
1529            message: format!(
1530                "function-body domain '{domain}' has invalid negative opset version {function_version}"
1531            ),
1532            location,
1533        });
1534        return;
1535    };
1536    let Some(schema) = ctx.schemas().lookup(&node.op_type, domain, version) else {
1537        if domain == "ai.onnx" || domain == "ai.onnx.ml" {
1538            violations.push(Violation {
1539                rule_id: rule_id.to_string(),
1540                severity: Severity::Error,
1541                message: format!(
1542                    "no schema registered for function-body op '{domain}::{}' at opset {version}",
1543                    node.op_type
1544                ),
1545                location,
1546            });
1547        }
1548        return;
1549    };
1550    check_proto_node_arity(schema, node, location.clone(), rule_id, violations);
1551    check_proto_node_attributes(schema, node, location, rule_id, violations);
1552}
1553
1554fn check_proto_node_arity(
1555    schema: &OpSchema,
1556    node: &NodeProto,
1557    location: ViolationLocation,
1558    rule_id: &str,
1559    violations: &mut Vec<Violation>,
1560) {
1561    let min_inputs = schema
1562        .inputs
1563        .iter()
1564        .map(|spec| {
1565            if spec.variadic {
1566                spec.min_arity
1567            } else {
1568                usize::from(!spec.optional)
1569            }
1570        })
1571        .sum();
1572    let max_inputs =
1573        (!schema.inputs.iter().any(|spec| spec.variadic)).then_some(schema.inputs.len());
1574    if node.input.len() < min_inputs || max_inputs.is_some_and(|max| node.input.len() > max) {
1575        violations.push(Violation {
1576            rule_id: rule_id.to_string(),
1577            severity: Severity::Error,
1578            message: arity_message("input", node.input.len(), min_inputs, max_inputs),
1579            location: location.clone(),
1580        });
1581    }
1582    for (index, spec) in schema.inputs.iter().enumerate() {
1583        if !spec.optional && !spec.variadic && node.input.get(index).is_some_and(String::is_empty) {
1584            violations.push(Violation {
1585                rule_id: rule_id.to_string(),
1586                severity: Severity::Error,
1587                message: format!(
1588                    "required input '{}' at position {index} is omitted",
1589                    spec.name
1590                ),
1591                location: location.clone(),
1592            });
1593        }
1594    }
1595    let min_outputs = schema
1596        .outputs
1597        .iter()
1598        .map(|spec| {
1599            if spec.variadic {
1600                spec.min_arity
1601            } else {
1602                usize::from(!spec.optional)
1603            }
1604        })
1605        .sum();
1606    let max_outputs =
1607        (!schema.outputs.iter().any(|spec| spec.variadic)).then_some(schema.outputs.len());
1608    if node.output.len() < min_outputs || max_outputs.is_some_and(|max| node.output.len() > max) {
1609        violations.push(Violation {
1610            rule_id: rule_id.to_string(),
1611            severity: Severity::Error,
1612            message: arity_message("output", node.output.len(), min_outputs, max_outputs),
1613            location,
1614        });
1615    }
1616}
1617
1618fn check_proto_node_attributes(
1619    schema: &OpSchema,
1620    node: &NodeProto,
1621    location: ViolationLocation,
1622    rule_id: &str,
1623    violations: &mut Vec<Violation>,
1624) {
1625    for spec in &schema.attributes {
1626        match node
1627            .attribute
1628            .iter()
1629            .find(|attribute| attribute.name == spec.name)
1630        {
1631            None if spec.required && spec.default.is_none() => violations.push(Violation {
1632                rule_id: rule_id.to_string(),
1633                severity: Severity::Error,
1634                message: format!("required attribute '{}' is missing", spec.name),
1635                location: location.clone(),
1636            }),
1637            Some(attribute) if attribute_proto_type(attribute.r#type) != Some(spec.attr_type) => {
1638                violations.push(Violation {
1639                    rule_id: rule_id.to_string(),
1640                    severity: Severity::Error,
1641                    message: format!(
1642                        "attribute '{}' has discriminator {} but schema requires {:?}",
1643                        spec.name, attribute.r#type, spec.attr_type
1644                    ),
1645                    location: location.clone(),
1646                });
1647            }
1648            _ => {}
1649        }
1650    }
1651    for attribute in &node.attribute {
1652        if !schema
1653            .attributes
1654            .iter()
1655            .any(|spec| spec.name == attribute.name)
1656        {
1657            violations.push(Violation {
1658                rule_id: rule_id.to_string(),
1659                severity: Severity::Error,
1660                message: format!(
1661                    "attribute '{}' is not declared by the schema",
1662                    attribute.name
1663                ),
1664                location: location.clone(),
1665            });
1666        }
1667    }
1668}
1669
1670fn attribute_proto_type(value: i32) -> Option<AttributeType> {
1671    Some(
1672        match attribute_proto::AttributeType::try_from(value).ok()? {
1673            attribute_proto::AttributeType::Float => AttributeType::Float,
1674            attribute_proto::AttributeType::Int => AttributeType::Int,
1675            attribute_proto::AttributeType::String => AttributeType::String,
1676            attribute_proto::AttributeType::Tensor => AttributeType::Tensor,
1677            attribute_proto::AttributeType::Graph => AttributeType::Graph,
1678            attribute_proto::AttributeType::SparseTensor => AttributeType::SparseTensor,
1679            attribute_proto::AttributeType::TypeProto => AttributeType::TypeProto,
1680            attribute_proto::AttributeType::Floats => AttributeType::Floats,
1681            attribute_proto::AttributeType::Ints => AttributeType::Ints,
1682            attribute_proto::AttributeType::Strings => AttributeType::Strings,
1683            attribute_proto::AttributeType::Tensors => AttributeType::Tensors,
1684            attribute_proto::AttributeType::Graphs => AttributeType::Graphs,
1685            attribute_proto::AttributeType::SparseTensors => AttributeType::SparseTensors,
1686            attribute_proto::AttributeType::TypeProtos => AttributeType::TypeProtos,
1687            attribute_proto::AttributeType::Undefined => return None,
1688        },
1689    )
1690}
1691
1692fn check_function_attribute_refs(
1693    node: &NodeProto,
1694    formal_attrs: &HashSet<&str>,
1695    function_name: &str,
1696    rule_id: &str,
1697    violations: &mut Vec<Violation>,
1698) {
1699    for attribute in &node.attribute {
1700        if !attribute.ref_attr_name.is_empty()
1701            && !formal_attrs.contains(attribute.ref_attr_name.as_str())
1702        {
1703            violations.push(model_violation(
1704                rule_id,
1705                format!(
1706                    "function '{function_name}' node attribute '{}' references undeclared function attribute '{}'",
1707                    attribute.name, attribute.ref_attr_name
1708                ),
1709            ));
1710        }
1711        if let Some(graph) = &attribute.g {
1712            for nested in &graph.node {
1713                check_function_attribute_refs(
1714                    nested,
1715                    formal_attrs,
1716                    function_name,
1717                    rule_id,
1718                    violations,
1719                );
1720            }
1721        }
1722        for graph in &attribute.graphs {
1723            for nested in &graph.node {
1724                check_function_attribute_refs(
1725                    nested,
1726                    formal_attrs,
1727                    function_name,
1728                    rule_id,
1729                    violations,
1730                );
1731            }
1732        }
1733    }
1734}
1735
1736fn check_function_recursion(
1737    functions: &HashMap<FunctionKey, &FunctionProto>,
1738    rule_id: &str,
1739    violations: &mut Vec<Violation>,
1740) {
1741    fn callees(
1742        node: &NodeProto,
1743        functions: &HashMap<FunctionKey, &FunctionProto>,
1744        out: &mut Vec<FunctionKey>,
1745    ) {
1746        let key = node_function_key(node);
1747        if functions.contains_key(&key) {
1748            out.push(key);
1749        }
1750        for attribute in &node.attribute {
1751            if let Some(graph) = &attribute.g {
1752                for nested in &graph.node {
1753                    callees(nested, functions, out);
1754                }
1755            }
1756            for graph in &attribute.graphs {
1757                for nested in &graph.node {
1758                    callees(nested, functions, out);
1759                }
1760            }
1761        }
1762    }
1763
1764    fn visit(
1765        key: &FunctionKey,
1766        functions: &HashMap<FunctionKey, &FunctionProto>,
1767        states: &mut HashMap<FunctionKey, u8>,
1768        stack: &mut Vec<FunctionKey>,
1769        rule_id: &str,
1770        violations: &mut Vec<Violation>,
1771    ) {
1772        match states.get(key).copied().unwrap_or(0) {
1773            2 => return,
1774            1 => {
1775                let start = stack.iter().position(|item| item == key).unwrap_or(0);
1776                let mut chain = stack[start..]
1777                    .iter()
1778                    .map(|item| format!("{}::{}:{}", item.0, item.1, item.2))
1779                    .collect::<Vec<_>>();
1780                chain.push(format!("{}::{}:{}", key.0, key.1, key.2));
1781                violations.push(model_violation(
1782                    rule_id,
1783                    format!(
1784                        "model-local functions are recursive: {}",
1785                        chain.join(" -> ")
1786                    ),
1787                ));
1788                return;
1789            }
1790            _ => {}
1791        }
1792        states.insert(key.clone(), 1);
1793        stack.push(key.clone());
1794        let mut next = Vec::new();
1795        if let Some(function) = functions.get(key) {
1796            for node in &function.node {
1797                callees(node, functions, &mut next);
1798            }
1799        }
1800        for callee in next {
1801            visit(&callee, functions, states, stack, rule_id, violations);
1802        }
1803        stack.pop();
1804        states.insert(key.clone(), 2);
1805    }
1806
1807    let mut states = HashMap::new();
1808    let mut stack = Vec::new();
1809    for key in functions.keys() {
1810        visit(key, functions, &mut states, &mut stack, rule_id, violations);
1811    }
1812}
1813
1814fn proto_node_label(node: &NodeProto) -> String {
1815    if node.name.is_empty() {
1816        format!("<{}>", node.op_type)
1817    } else {
1818        node.name.clone()
1819    }
1820}
1821
1822/// Metadata maps are encoded as repeated entries and therefore require an
1823/// explicit distinct-key check at every protobuf scope.
1824pub struct MetadataKeysUniqueRule;
1825
1826impl ValidationRule for MetadataKeysUniqueRule {
1827    fn id(&self) -> &str {
1828        "proto.metadata_keys_unique"
1829    }
1830
1831    fn severity(&self) -> Severity {
1832        Severity::Error
1833    }
1834
1835    fn check(&self, model: &Model, _ctx: &ValidationContext) -> Vec<Violation> {
1836        let Some(proto) = model.retained_proto() else {
1837            return Vec::new();
1838        };
1839        let mut violations = Vec::new();
1840        check_unique_entries(
1841            &proto.metadata_props,
1842            "ModelProto.metadata_props",
1843            ViolationLocation::Model,
1844            self.id(),
1845            &mut violations,
1846        );
1847        if let Some(graph) = &proto.graph {
1848            check_graph_metadata(graph, self.id(), &mut violations);
1849        }
1850        for function in &proto.functions {
1851            check_unique_entries(
1852                &function.metadata_props,
1853                "FunctionProto.metadata_props",
1854                ViolationLocation::Model,
1855                self.id(),
1856                &mut violations,
1857            );
1858            for value in &function.value_info {
1859                check_value_metadata(value, self.id(), &mut violations);
1860            }
1861            for node in &function.node {
1862                check_node_metadata(node, &function.name, self.id(), &mut violations);
1863            }
1864        }
1865        violations
1866    }
1867}
1868
1869/// Validate attribute names, discriminators, union payloads, and per-node
1870/// attribute-name uniqueness.
1871pub struct AttributeProtoValidityRule;
1872
1873impl ValidationRule for AttributeProtoValidityRule {
1874    fn id(&self) -> &str {
1875        "proto.attribute_valid"
1876    }
1877
1878    fn severity(&self) -> Severity {
1879        Severity::Error
1880    }
1881
1882    fn check(&self, model: &Model, _ctx: &ValidationContext) -> Vec<Violation> {
1883        let Some(proto) = model.retained_proto() else {
1884            return Vec::new();
1885        };
1886        let mut violations = Vec::new();
1887        if let Some(graph) = &proto.graph {
1888            check_graph_attributes(graph, proto.ir_version, self.id(), &mut violations);
1889        }
1890        for function in &proto.functions {
1891            check_attribute_list(
1892                &function.attribute_proto,
1893                ViolationLocation::Model,
1894                proto.ir_version,
1895                self.id(),
1896                &mut violations,
1897            );
1898            for node in &function.node {
1899                check_node_attributes_proto(
1900                    node,
1901                    &function.name,
1902                    proto.ir_version,
1903                    self.id(),
1904                    &mut violations,
1905                );
1906            }
1907        }
1908        violations
1909    }
1910}
1911
1912/// Validate every retained `TypeProto`, including container requirements and
1913/// ONNX-ML opaque types.
1914pub struct ProtoTypeValidityRule;
1915
1916impl ValidationRule for ProtoTypeValidityRule {
1917    fn id(&self) -> &str {
1918        "proto.type_valid"
1919    }
1920
1921    fn severity(&self) -> Severity {
1922        Severity::Error
1923    }
1924
1925    fn check(&self, model: &Model, _ctx: &ValidationContext) -> Vec<Violation> {
1926        let Some(proto) = model.retained_proto() else {
1927            return Vec::new();
1928        };
1929        let mut violations = Vec::new();
1930        if let Some(graph) = &proto.graph {
1931            check_graph_types(graph, true, self.id(), &mut violations);
1932        }
1933        for function in &proto.functions {
1934            for value in &function.value_info {
1935                check_value_type(value, false, self.id(), &mut violations);
1936            }
1937            for node in &function.node {
1938                check_node_types(node, &function.name, self.id(), &mut violations);
1939            }
1940            for attribute in &function.attribute_proto {
1941                check_attribute_types(
1942                    attribute,
1943                    ViolationLocation::Model,
1944                    self.id(),
1945                    &mut violations,
1946                );
1947            }
1948        }
1949        violations
1950    }
1951}
1952
1953/// Validate dense tensor dimensions, storage-field selection, payload size,
1954/// segment bounds, and external-data constraints.
1955pub struct TensorPayloadValidityRule;
1956
1957impl ValidationRule for TensorPayloadValidityRule {
1958    fn id(&self) -> &str {
1959        "proto.tensor_payload_valid"
1960    }
1961
1962    fn severity(&self) -> Severity {
1963        Severity::Error
1964    }
1965
1966    fn check(&self, model: &Model, _ctx: &ValidationContext) -> Vec<Violation> {
1967        let Some(proto) = model.retained_proto() else {
1968            return Vec::new();
1969        };
1970        let mut violations = Vec::new();
1971        if let Some(graph) = &proto.graph {
1972            visit_graph_tensors(graph, self.id(), &mut violations);
1973        }
1974        for function in &proto.functions {
1975            for node in &function.node {
1976                visit_node_tensors(node, &function.name, self.id(), &mut violations);
1977            }
1978            for attribute in &function.attribute_proto {
1979                visit_attribute_tensors(
1980                    attribute,
1981                    ViolationLocation::Model,
1982                    self.id(),
1983                    &mut violations,
1984                );
1985            }
1986        }
1987        violations
1988    }
1989}
1990
1991/// Validate sparse tensor COO structure, index type/shape, bounds, ordering,
1992/// and uniqueness.
1993pub struct SparseTensorValidityRule;
1994
1995impl ValidationRule for SparseTensorValidityRule {
1996    fn id(&self) -> &str {
1997        "proto.sparse_tensor_valid"
1998    }
1999
2000    fn severity(&self) -> Severity {
2001        Severity::Error
2002    }
2003
2004    fn check(&self, model: &Model, _ctx: &ValidationContext) -> Vec<Violation> {
2005        let Some(proto) = model.retained_proto() else {
2006            return Vec::new();
2007        };
2008        let mut violations = Vec::new();
2009        if let Some(graph) = &proto.graph {
2010            visit_graph_sparse_tensors(graph, self.id(), &mut violations);
2011        }
2012        for function in &proto.functions {
2013            for node in &function.node {
2014                visit_node_sparse_tensors(node, &function.name, self.id(), &mut violations);
2015            }
2016            for attribute in &function.attribute_proto {
2017                visit_attribute_sparse_tensors(
2018                    attribute,
2019                    ViolationLocation::Model,
2020                    self.id(),
2021                    &mut violations,
2022                );
2023            }
2024        }
2025        violations
2026    }
2027}
2028
2029fn check_graph_attributes(
2030    graph: &GraphProto,
2031    ir_version: i64,
2032    rule_id: &str,
2033    violations: &mut Vec<Violation>,
2034) {
2035    for node in &graph.node {
2036        check_node_attributes_proto(node, &graph.name, ir_version, rule_id, violations);
2037    }
2038}
2039
2040fn check_node_attributes_proto(
2041    node: &NodeProto,
2042    graph_name: &str,
2043    ir_version: i64,
2044    rule_id: &str,
2045    violations: &mut Vec<Violation>,
2046) {
2047    let location = ViolationLocation::Node {
2048        graph_name: graph_name.to_string(),
2049        node_name: if node.name.is_empty() {
2050            format!("<{}>", node.op_type)
2051        } else {
2052            node.name.clone()
2053        },
2054    };
2055    if node.op_type.is_empty() {
2056        violations.push(Violation {
2057            rule_id: rule_id.to_string(),
2058            severity: Severity::Error,
2059            message: "NodeProto.op_type must be present".into(),
2060            location: location.clone(),
2061        });
2062    }
2063    check_attribute_list(
2064        &node.attribute,
2065        location.clone(),
2066        ir_version,
2067        rule_id,
2068        violations,
2069    );
2070    for attribute in &node.attribute {
2071        if let Some(graph) = &attribute.g {
2072            check_graph_attributes(graph, ir_version, rule_id, violations);
2073        }
2074        for graph in &attribute.graphs {
2075            check_graph_attributes(graph, ir_version, rule_id, violations);
2076        }
2077    }
2078}
2079
2080fn check_attribute_list(
2081    attributes: &[AttributeProto],
2082    location: ViolationLocation,
2083    ir_version: i64,
2084    rule_id: &str,
2085    violations: &mut Vec<Violation>,
2086) {
2087    let mut names = HashSet::new();
2088    for attribute in attributes {
2089        if attribute.name.is_empty() {
2090            violations.push(Violation {
2091                rule_id: rule_id.to_string(),
2092                severity: Severity::Error,
2093                message: "AttributeProto.name must be present".into(),
2094                location: location.clone(),
2095            });
2096        } else if !names.insert(attribute.name.as_str()) {
2097            violations.push(Violation {
2098                rule_id: rule_id.to_string(),
2099                severity: Severity::Error,
2100                message: format!("attribute name '{}' is not unique", attribute.name),
2101                location: location.clone(),
2102            });
2103        }
2104        check_attribute_proto(attribute, location.clone(), ir_version, rule_id, violations);
2105    }
2106}
2107
2108fn check_attribute_proto(
2109    attribute: &AttributeProto,
2110    location: ViolationLocation,
2111    ir_version: i64,
2112    rule_id: &str,
2113    violations: &mut Vec<Violation>,
2114) {
2115    let expected = attribute_proto::AttributeType::try_from(attribute.r#type)
2116        .ok()
2117        .filter(|value| *value != attribute_proto::AttributeType::Undefined);
2118    if ir_version >= 2 && expected.is_none() {
2119        violations.push(Violation {
2120            rule_id: rule_id.to_string(),
2121            severity: Severity::Error,
2122            message: format!(
2123                "attribute '{}' has invalid or undefined discriminator {}",
2124                attribute.name, attribute.r#type
2125            ),
2126            location,
2127        });
2128        return;
2129    }
2130    let populated = [
2131        (attribute.f != 0.0, attribute_proto::AttributeType::Float),
2132        (attribute.i != 0, attribute_proto::AttributeType::Int),
2133        (
2134            !attribute.s.is_empty(),
2135            attribute_proto::AttributeType::String,
2136        ),
2137        (
2138            attribute.t.is_some(),
2139            attribute_proto::AttributeType::Tensor,
2140        ),
2141        (attribute.g.is_some(), attribute_proto::AttributeType::Graph),
2142        (
2143            !attribute.floats.is_empty(),
2144            attribute_proto::AttributeType::Floats,
2145        ),
2146        (
2147            !attribute.ints.is_empty(),
2148            attribute_proto::AttributeType::Ints,
2149        ),
2150        (
2151            !attribute.strings.is_empty(),
2152            attribute_proto::AttributeType::Strings,
2153        ),
2154        (
2155            !attribute.tensors.is_empty(),
2156            attribute_proto::AttributeType::Tensors,
2157        ),
2158        (
2159            !attribute.graphs.is_empty(),
2160            attribute_proto::AttributeType::Graphs,
2161        ),
2162        (
2163            attribute.tp.is_some(),
2164            attribute_proto::AttributeType::TypeProto,
2165        ),
2166        (
2167            !attribute.type_protos.is_empty(),
2168            attribute_proto::AttributeType::TypeProtos,
2169        ),
2170        (
2171            attribute.sparse_tensor.is_some(),
2172            attribute_proto::AttributeType::SparseTensor,
2173        ),
2174        (
2175            !attribute.sparse_tensors.is_empty(),
2176            attribute_proto::AttributeType::SparseTensors,
2177        ),
2178    ]
2179    .into_iter()
2180    .filter_map(|(present, kind)| present.then_some(kind))
2181    .collect::<Vec<_>>();
2182    if !attribute.ref_attr_name.is_empty() {
2183        if !populated.is_empty() {
2184            violations.push(Violation {
2185                rule_id: rule_id.to_string(),
2186                severity: Severity::Error,
2187                message: format!(
2188                    "referenced attribute '{}' must not contain a value payload",
2189                    attribute.name
2190                ),
2191                location,
2192            });
2193        }
2194        return;
2195    }
2196    if let Some(expected) = expected {
2197        if let Some(actual) = populated.iter().find(|&&actual| actual != expected) {
2198            violations.push(Violation {
2199                rule_id: rule_id.to_string(),
2200                severity: Severity::Error,
2201                message: format!(
2202                    "attribute '{}' discriminator {:?} conflicts with populated {:?} payload",
2203                    attribute.name, expected, actual
2204                ),
2205                location: location.clone(),
2206            });
2207        }
2208        let required_message_missing = matches!(
2209            expected,
2210            attribute_proto::AttributeType::Tensor
2211                | attribute_proto::AttributeType::Graph
2212                | attribute_proto::AttributeType::SparseTensor
2213                | attribute_proto::AttributeType::TypeProto
2214        ) && !populated.contains(&expected);
2215        if required_message_missing {
2216            violations.push(Violation {
2217                rule_id: rule_id.to_string(),
2218                severity: Severity::Error,
2219                message: format!(
2220                    "attribute '{}' discriminator {:?} requires its message payload",
2221                    attribute.name, expected
2222                ),
2223                location,
2224            });
2225        }
2226    }
2227}
2228
2229fn check_unique_entries(
2230    entries: &[StringStringEntryProto],
2231    field: &str,
2232    location: ViolationLocation,
2233    rule_id: &str,
2234    violations: &mut Vec<Violation>,
2235) {
2236    let mut keys = HashSet::new();
2237    for entry in entries {
2238        if !keys.insert(entry.key.as_str()) {
2239            violations.push(Violation {
2240                rule_id: rule_id.to_string(),
2241                severity: Severity::Error,
2242                message: format!("{field} contains duplicate key '{}'", entry.key),
2243                location: location.clone(),
2244            });
2245        }
2246    }
2247}
2248
2249fn check_graph_metadata(graph: &GraphProto, rule_id: &str, violations: &mut Vec<Violation>) {
2250    let location = ViolationLocation::Graph {
2251        graph_name: graph.name.clone(),
2252    };
2253    check_unique_entries(
2254        &graph.metadata_props,
2255        "GraphProto.metadata_props",
2256        location.clone(),
2257        rule_id,
2258        violations,
2259    );
2260    for value in graph
2261        .input
2262        .iter()
2263        .chain(&graph.output)
2264        .chain(&graph.value_info)
2265    {
2266        check_value_metadata(value, rule_id, violations);
2267    }
2268    for tensor in &graph.initializer {
2269        check_tensor_metadata(tensor, rule_id, violations);
2270    }
2271    for sparse in &graph.sparse_initializer {
2272        if let Some(values) = &sparse.values {
2273            check_tensor_metadata(values, rule_id, violations);
2274        }
2275        if let Some(indices) = &sparse.indices {
2276            check_tensor_metadata(indices, rule_id, violations);
2277        }
2278    }
2279    for annotation in &graph.quantization_annotation {
2280        check_unique_entries(
2281            &annotation.quant_parameter_tensor_names,
2282            "TensorAnnotation.quant_parameter_tensor_names",
2283            ViolationLocation::Value {
2284                value_name: annotation.tensor_name.clone(),
2285            },
2286            rule_id,
2287            violations,
2288        );
2289    }
2290    for node in &graph.node {
2291        check_node_metadata(node, &graph.name, rule_id, violations);
2292    }
2293}
2294
2295fn check_node_metadata(
2296    node: &NodeProto,
2297    graph_name: &str,
2298    rule_id: &str,
2299    violations: &mut Vec<Violation>,
2300) {
2301    let location = ViolationLocation::Node {
2302        graph_name: graph_name.to_string(),
2303        node_name: if node.name.is_empty() {
2304            format!("<{}>", node.op_type)
2305        } else {
2306            node.name.clone()
2307        },
2308    };
2309    check_unique_entries(
2310        &node.metadata_props,
2311        "NodeProto.metadata_props",
2312        location,
2313        rule_id,
2314        violations,
2315    );
2316    for attribute in &node.attribute {
2317        if let Some(graph) = &attribute.g {
2318            check_graph_metadata(graph, rule_id, violations);
2319        }
2320        for graph in &attribute.graphs {
2321            check_graph_metadata(graph, rule_id, violations);
2322        }
2323        if let Some(tensor) = &attribute.t {
2324            check_tensor_metadata(tensor, rule_id, violations);
2325        }
2326        for tensor in &attribute.tensors {
2327            check_tensor_metadata(tensor, rule_id, violations);
2328        }
2329        if let Some(sparse) = &attribute.sparse_tensor {
2330            if let Some(values) = &sparse.values {
2331                check_tensor_metadata(values, rule_id, violations);
2332            }
2333            if let Some(indices) = &sparse.indices {
2334                check_tensor_metadata(indices, rule_id, violations);
2335            }
2336        }
2337        for sparse in &attribute.sparse_tensors {
2338            if let Some(values) = &sparse.values {
2339                check_tensor_metadata(values, rule_id, violations);
2340            }
2341            if let Some(indices) = &sparse.indices {
2342                check_tensor_metadata(indices, rule_id, violations);
2343            }
2344        }
2345    }
2346}
2347
2348fn check_value_metadata(value: &ValueInfoProto, rule_id: &str, violations: &mut Vec<Violation>) {
2349    check_unique_entries(
2350        &value.metadata_props,
2351        "ValueInfoProto.metadata_props",
2352        ViolationLocation::Value {
2353            value_name: value.name.clone(),
2354        },
2355        rule_id,
2356        violations,
2357    );
2358}
2359
2360fn check_tensor_metadata(tensor: &TensorProto, rule_id: &str, violations: &mut Vec<Violation>) {
2361    check_unique_entries(
2362        &tensor.metadata_props,
2363        "TensorProto.metadata_props",
2364        ViolationLocation::Value {
2365            value_name: tensor.name.clone(),
2366        },
2367        rule_id,
2368        violations,
2369    );
2370}
2371
2372fn check_graph_types(
2373    graph: &GraphProto,
2374    top_level: bool,
2375    rule_id: &str,
2376    violations: &mut Vec<Violation>,
2377) {
2378    for value in &graph.input {
2379        check_value_type(value, top_level, rule_id, violations);
2380    }
2381    for value in &graph.output {
2382        check_value_type(value, top_level, rule_id, violations);
2383    }
2384    for value in &graph.value_info {
2385        check_value_type(value, false, rule_id, violations);
2386    }
2387    for node in &graph.node {
2388        check_node_types(node, &graph.name, rule_id, violations);
2389    }
2390}
2391
2392fn check_node_types(
2393    node: &NodeProto,
2394    graph_name: &str,
2395    rule_id: &str,
2396    violations: &mut Vec<Violation>,
2397) {
2398    let location = ViolationLocation::Node {
2399        graph_name: graph_name.to_string(),
2400        node_name: if node.name.is_empty() {
2401            format!("<{}>", node.op_type)
2402        } else {
2403            node.name.clone()
2404        },
2405    };
2406    for attribute in &node.attribute {
2407        check_attribute_types(attribute, location.clone(), rule_id, violations);
2408        if let Some(graph) = &attribute.g {
2409            check_graph_types(graph, false, rule_id, violations);
2410        }
2411        for graph in &attribute.graphs {
2412            check_graph_types(graph, false, rule_id, violations);
2413        }
2414    }
2415}
2416
2417fn check_attribute_types(
2418    attribute: &AttributeProto,
2419    location: ViolationLocation,
2420    rule_id: &str,
2421    violations: &mut Vec<Violation>,
2422) {
2423    if let Some(value) = &attribute.tp {
2424        check_type_proto(value, location.clone(), false, rule_id, violations);
2425    }
2426    for value in &attribute.type_protos {
2427        check_type_proto(value, location.clone(), false, rule_id, violations);
2428    }
2429}
2430
2431fn check_value_type(
2432    value: &ValueInfoProto,
2433    require_type: bool,
2434    rule_id: &str,
2435    violations: &mut Vec<Violation>,
2436) {
2437    let location = ViolationLocation::Value {
2438        value_name: value.name.clone(),
2439    };
2440    if value.name.is_empty() {
2441        violations.push(Violation {
2442            rule_id: rule_id.to_string(),
2443            severity: Severity::Error,
2444            message: "ValueInfoProto.name must be present".into(),
2445            location: location.clone(),
2446        });
2447    }
2448    match &value.r#type {
2449        Some(r#type) => check_type_proto(r#type, location, require_type, rule_id, violations),
2450        None if require_type => violations.push(Violation {
2451            rule_id: rule_id.to_string(),
2452            severity: Severity::Error,
2453            message: "top-level graph inputs and outputs must declare a TypeProto".into(),
2454            location,
2455        }),
2456        None => {}
2457    }
2458}
2459
2460fn check_type_proto(
2461    value: &TypeProto,
2462    location: ViolationLocation,
2463    require_shape: bool,
2464    rule_id: &str,
2465    violations: &mut Vec<Violation>,
2466) {
2467    let invalid = |message: String, violations: &mut Vec<Violation>| {
2468        violations.push(Violation {
2469            rule_id: rule_id.to_string(),
2470            severity: Severity::Error,
2471            message,
2472            location: location.clone(),
2473        });
2474    };
2475    match value.value.as_ref() {
2476        None => invalid("TypeProto must select a value variant".into(), violations),
2477        Some(type_proto::Value::TensorType(tensor)) => {
2478            check_tensor_type(
2479                tensor.elem_type,
2480                tensor.shape.as_ref(),
2481                require_shape,
2482                &invalid,
2483                violations,
2484            );
2485        }
2486        Some(type_proto::Value::SparseTensorType(tensor)) => {
2487            check_tensor_type(
2488                tensor.elem_type,
2489                tensor.shape.as_ref(),
2490                require_shape,
2491                &invalid,
2492                violations,
2493            );
2494        }
2495        Some(type_proto::Value::SequenceType(sequence)) => match &sequence.elem_type {
2496            Some(elem_type) => {
2497                check_type_proto(elem_type, location.clone(), false, rule_id, violations)
2498            }
2499            None => invalid(
2500                "TypeProto.Sequence.elem_type must be present".into(),
2501                violations,
2502            ),
2503        },
2504        Some(type_proto::Value::MapType(map)) => {
2505            let key = tensor_proto::DataType::try_from(map.key_type).ok();
2506            if !matches!(
2507                key,
2508                Some(
2509                    tensor_proto::DataType::Uint8
2510                        | tensor_proto::DataType::Int8
2511                        | tensor_proto::DataType::Uint16
2512                        | tensor_proto::DataType::Int16
2513                        | tensor_proto::DataType::Int32
2514                        | tensor_proto::DataType::Int64
2515                        | tensor_proto::DataType::String
2516                        | tensor_proto::DataType::Uint32
2517                        | tensor_proto::DataType::Uint64
2518                )
2519            ) {
2520                invalid(
2521                    format!(
2522                        "TypeProto.Map.key_type {} must be an integral or string dtype",
2523                        map.key_type
2524                    ),
2525                    violations,
2526                );
2527            }
2528            match &map.value_type {
2529                Some(value_type) => {
2530                    check_type_proto(value_type, location.clone(), false, rule_id, violations)
2531                }
2532                None => invalid(
2533                    "TypeProto.Map.value_type must be present".into(),
2534                    violations,
2535                ),
2536            }
2537        }
2538        Some(type_proto::Value::OptionalType(optional)) => match &optional.elem_type {
2539            Some(elem_type) => {
2540                if !matches!(
2541                    elem_type.value,
2542                    Some(
2543                        type_proto::Value::TensorType(_)
2544                            | type_proto::Value::SequenceType(_)
2545                            | type_proto::Value::MapType(_)
2546                    )
2547                ) {
2548                    invalid(
2549                        "TypeProto.Optional.elem_type must be a tensor, sequence, or map".into(),
2550                        violations,
2551                    );
2552                }
2553                check_type_proto(elem_type, location.clone(), false, rule_id, violations);
2554            }
2555            None => invalid(
2556                "TypeProto.Optional.elem_type must be present".into(),
2557                violations,
2558            ),
2559        },
2560        Some(type_proto::Value::OpaqueType(_)) => {}
2561    }
2562}
2563
2564fn check_tensor_type(
2565    elem_type: i32,
2566    shape: Option<&TensorShapeProto>,
2567    require_shape: bool,
2568    invalid: &impl Fn(String, &mut Vec<Violation>),
2569    violations: &mut Vec<Violation>,
2570) {
2571    if tensor_proto::DataType::try_from(elem_type)
2572        .ok()
2573        .is_none_or(|dtype| dtype == tensor_proto::DataType::Undefined)
2574    {
2575        invalid(
2576            format!("tensor elem_type {elem_type} must be a defined ONNX dtype"),
2577            violations,
2578        );
2579    }
2580    if require_shape && shape.is_none() {
2581        invalid(
2582            "top-level tensor and sparse tensor types must declare a shape".into(),
2583            violations,
2584        );
2585    }
2586    if let Some(shape) = shape {
2587        for (index, dim) in shape.dim.iter().enumerate() {
2588            if matches!(
2589                dim.value,
2590                Some(onnx_runtime_loader::proto::onnx::tensor_shape_proto::dimension::Value::DimValue(value))
2591                    if value < 0
2592            ) {
2593                invalid(
2594                    format!("tensor shape dimension {index} must not be negative"),
2595                    violations,
2596                );
2597            }
2598        }
2599    }
2600}
2601
2602fn visit_graph_tensors(graph: &GraphProto, rule_id: &str, violations: &mut Vec<Violation>) {
2603    for tensor in &graph.initializer {
2604        check_tensor_payload(tensor, rule_id, violations);
2605    }
2606    for sparse in &graph.sparse_initializer {
2607        if let Some(values) = &sparse.values {
2608            check_tensor_payload(values, rule_id, violations);
2609        }
2610        if let Some(indices) = &sparse.indices {
2611            check_tensor_payload(indices, rule_id, violations);
2612        }
2613    }
2614    for node in &graph.node {
2615        visit_node_tensors(node, &graph.name, rule_id, violations);
2616    }
2617}
2618
2619fn visit_node_tensors(
2620    node: &NodeProto,
2621    graph_name: &str,
2622    rule_id: &str,
2623    violations: &mut Vec<Violation>,
2624) {
2625    let location = ViolationLocation::Node {
2626        graph_name: graph_name.to_string(),
2627        node_name: if node.name.is_empty() {
2628            format!("<{}>", node.op_type)
2629        } else {
2630            node.name.clone()
2631        },
2632    };
2633    for attribute in &node.attribute {
2634        visit_attribute_tensors(attribute, location.clone(), rule_id, violations);
2635        if let Some(graph) = &attribute.g {
2636            visit_graph_tensors(graph, rule_id, violations);
2637        }
2638        for graph in &attribute.graphs {
2639            visit_graph_tensors(graph, rule_id, violations);
2640        }
2641    }
2642}
2643
2644fn visit_attribute_tensors(
2645    attribute: &AttributeProto,
2646    _location: ViolationLocation,
2647    rule_id: &str,
2648    violations: &mut Vec<Violation>,
2649) {
2650    if let Some(tensor) = &attribute.t {
2651        check_tensor_payload(tensor, rule_id, violations);
2652    }
2653    for tensor in &attribute.tensors {
2654        check_tensor_payload(tensor, rule_id, violations);
2655    }
2656    if let Some(sparse) = &attribute.sparse_tensor {
2657        if let Some(values) = &sparse.values {
2658            check_tensor_payload(values, rule_id, violations);
2659        }
2660        if let Some(indices) = &sparse.indices {
2661            check_tensor_payload(indices, rule_id, violations);
2662        }
2663    }
2664    for sparse in &attribute.sparse_tensors {
2665        if let Some(values) = &sparse.values {
2666            check_tensor_payload(values, rule_id, violations);
2667        }
2668        if let Some(indices) = &sparse.indices {
2669            check_tensor_payload(indices, rule_id, violations);
2670        }
2671    }
2672}
2673
2674fn check_tensor_payload(tensor: &TensorProto, rule_id: &str, violations: &mut Vec<Violation>) {
2675    let location = ViolationLocation::Value {
2676        value_name: tensor.name.clone(),
2677    };
2678    let mut report = |message: String| {
2679        violations.push(Violation {
2680            rule_id: rule_id.to_string(),
2681            severity: Severity::Error,
2682            message,
2683            location: location.clone(),
2684        });
2685    };
2686    let Some(dtype) = tensor_proto::DataType::try_from(tensor.data_type)
2687        .ok()
2688        .filter(|dtype| *dtype != tensor_proto::DataType::Undefined)
2689    else {
2690        report(format!(
2691            "TensorProto.data_type {} must be a defined ONNX dtype",
2692            tensor.data_type
2693        ));
2694        return;
2695    };
2696    let Some(full_count) = checked_numel(&tensor.dims) else {
2697        report(
2698            "TensorProto dimensions must be non-negative and their product must fit usize".into(),
2699        );
2700        return;
2701    };
2702    let count = if let Some(segment) = &tensor.segment {
2703        if segment.begin < 0 || segment.end < segment.begin {
2704            report("TensorProto.segment must satisfy 0 <= begin <= end".into());
2705            return;
2706        }
2707        let Ok(begin) = usize::try_from(segment.begin) else {
2708            report("TensorProto.segment.begin does not fit usize".into());
2709            return;
2710        };
2711        let Ok(end) = usize::try_from(segment.end) else {
2712            report("TensorProto.segment.end does not fit usize".into());
2713            return;
2714        };
2715        if end > full_count {
2716            report(format!(
2717                "TensorProto.segment end {end} exceeds element count {full_count}"
2718            ));
2719        }
2720        end.saturating_sub(begin)
2721    } else {
2722        full_count
2723    };
2724
2725    let populated = [
2726        !tensor.float_data.is_empty(),
2727        !tensor.int32_data.is_empty(),
2728        !tensor.string_data.is_empty(),
2729        !tensor.int64_data.is_empty(),
2730        !tensor.raw_data.is_empty(),
2731        !tensor.double_data.is_empty(),
2732        !tensor.uint64_data.is_empty(),
2733    ]
2734    .into_iter()
2735    .filter(|present| *present)
2736    .count();
2737    let data_location = tensor_proto::DataLocation::try_from(tensor.data_location).ok();
2738    if data_location.is_none() {
2739        report(format!(
2740            "TensorProto.data_location {} is not valid",
2741            tensor.data_location
2742        ));
2743        return;
2744    }
2745    if data_location == Some(tensor_proto::DataLocation::External) {
2746        if populated != 0 {
2747            report("external TensorProto must not contain embedded payload fields".into());
2748        }
2749        let mut external = HashMap::new();
2750        for entry in &tensor.external_data {
2751            if external
2752                .insert(entry.key.as_str(), entry.value.as_str())
2753                .is_some()
2754            {
2755                report(format!(
2756                    "TensorProto.external_data contains duplicate key '{}'",
2757                    entry.key
2758                ));
2759            }
2760        }
2761        if external
2762            .get("location")
2763            .is_none_or(|value| value.is_empty())
2764        {
2765            report("external TensorProto requires a non-empty location entry".into());
2766        }
2767        let offset = parse_external_usize(external.get("offset").copied(), "offset", &mut report);
2768        let length = parse_external_usize(external.get("length").copied(), "length", &mut report);
2769        if let (Some(offset), Some(length)) = (offset, length)
2770            && offset.checked_add(length).is_none()
2771        {
2772            report("external TensorProto offset + length overflows usize".into());
2773        }
2774        return;
2775    }
2776    if !tensor.external_data.is_empty() {
2777        report("inline TensorProto must not contain external_data entries".into());
2778    }
2779    if populated > 1 {
2780        report("TensorProto must use exactly one embedded payload field".into());
2781        return;
2782    }
2783    if count > 0 && populated == 0 {
2784        report("non-empty TensorProto is missing its payload".into());
2785        return;
2786    }
2787    if populated == 0 {
2788        return;
2789    }
2790    let actual_expected = match dtype {
2791        _ if !tensor.raw_data.is_empty() => storage_bytes(dtype, count)
2792            .map(|expected| (tensor.raw_data.len(), expected, "raw_data")),
2793        tensor_proto::DataType::Float => Some((tensor.float_data.len(), count, "float_data")),
2794        tensor_proto::DataType::Complex64 => count
2795            .checked_mul(2)
2796            .map(|expected| (tensor.float_data.len(), expected, "float_data")),
2797        tensor_proto::DataType::Double => Some((tensor.double_data.len(), count, "double_data")),
2798        tensor_proto::DataType::Complex128 => count
2799            .checked_mul(2)
2800            .map(|expected| (tensor.double_data.len(), expected, "double_data")),
2801        tensor_proto::DataType::Int64 => Some((tensor.int64_data.len(), count, "int64_data")),
2802        tensor_proto::DataType::String => Some((tensor.string_data.len(), count, "string_data")),
2803        tensor_proto::DataType::Uint32 | tensor_proto::DataType::Uint64 => {
2804            Some((tensor.uint64_data.len(), count, "uint64_data"))
2805        }
2806        tensor_proto::DataType::Uint4
2807        | tensor_proto::DataType::Int4
2808        | tensor_proto::DataType::Float4e2m1 => count
2809            .checked_add(1)
2810            .map(|value| (tensor.int32_data.len(), value / 2, "int32_data")),
2811        tensor_proto::DataType::Uint2 | tensor_proto::DataType::Int2 => count
2812            .checked_add(3)
2813            .map(|value| (tensor.int32_data.len(), value / 4, "int32_data")),
2814        _ => Some((tensor.int32_data.len(), count, "int32_data")),
2815    };
2816    match actual_expected {
2817        Some((actual, expected, field)) if actual != expected => report(format!(
2818            "TensorProto.{field} contains {actual} entries/bytes but {expected} are required"
2819        )),
2820        None => report("TensorProto payload size arithmetic overflowed usize".into()),
2821        _ => {}
2822    }
2823}
2824
2825fn parse_external_usize(
2826    value: Option<&str>,
2827    key: &str,
2828    report: &mut impl FnMut(String),
2829) -> Option<usize> {
2830    let value = value?;
2831    match value.parse::<usize>() {
2832        Ok(value) => Some(value),
2833        Err(_) => {
2834            report(format!(
2835                "TensorProto.external_data '{key}' must be an unsigned integer"
2836            ));
2837            None
2838        }
2839    }
2840}
2841
2842fn checked_numel(dims: &[i64]) -> Option<usize> {
2843    dims.iter().try_fold(1usize, |count, &dim| {
2844        let dim = usize::try_from(dim).ok()?;
2845        count.checked_mul(dim)
2846    })
2847}
2848
2849fn storage_bytes(dtype: tensor_proto::DataType, count: usize) -> Option<usize> {
2850    let bits = match dtype {
2851        tensor_proto::DataType::Uint2 | tensor_proto::DataType::Int2 => 2,
2852        tensor_proto::DataType::Uint4
2853        | tensor_proto::DataType::Int4
2854        | tensor_proto::DataType::Float4e2m1 => 4,
2855        tensor_proto::DataType::Uint8
2856        | tensor_proto::DataType::Int8
2857        | tensor_proto::DataType::Bool
2858        | tensor_proto::DataType::Float8e4m3fn
2859        | tensor_proto::DataType::Float8e4m3fnuz
2860        | tensor_proto::DataType::Float8e5m2
2861        | tensor_proto::DataType::Float8e5m2fnuz
2862        | tensor_proto::DataType::Float8e8m0 => 8,
2863        tensor_proto::DataType::Uint16
2864        | tensor_proto::DataType::Int16
2865        | tensor_proto::DataType::Float16
2866        | tensor_proto::DataType::Bfloat16 => 16,
2867        tensor_proto::DataType::Float
2868        | tensor_proto::DataType::Int32
2869        | tensor_proto::DataType::Uint32 => 32,
2870        tensor_proto::DataType::Double
2871        | tensor_proto::DataType::Int64
2872        | tensor_proto::DataType::Uint64
2873        | tensor_proto::DataType::Complex64 => 64,
2874        tensor_proto::DataType::Complex128 => 128,
2875        tensor_proto::DataType::String | tensor_proto::DataType::Undefined => return None,
2876    };
2877    count
2878        .checked_mul(bits)
2879        .and_then(|bits| bits.checked_add(7))
2880        .map(|bits| bits / 8)
2881}
2882
2883fn visit_graph_sparse_tensors(graph: &GraphProto, rule_id: &str, violations: &mut Vec<Violation>) {
2884    for sparse in &graph.sparse_initializer {
2885        check_sparse_tensor(sparse, true, rule_id, violations);
2886    }
2887    for node in &graph.node {
2888        visit_node_sparse_tensors(node, &graph.name, rule_id, violations);
2889    }
2890}
2891
2892fn visit_node_sparse_tensors(
2893    node: &NodeProto,
2894    graph_name: &str,
2895    rule_id: &str,
2896    violations: &mut Vec<Violation>,
2897) {
2898    let location = ViolationLocation::Node {
2899        graph_name: graph_name.to_string(),
2900        node_name: if node.name.is_empty() {
2901            format!("<{}>", node.op_type)
2902        } else {
2903            node.name.clone()
2904        },
2905    };
2906    for attribute in &node.attribute {
2907        visit_attribute_sparse_tensors(attribute, location.clone(), rule_id, violations);
2908        if let Some(graph) = &attribute.g {
2909            visit_graph_sparse_tensors(graph, rule_id, violations);
2910        }
2911        for graph in &attribute.graphs {
2912            visit_graph_sparse_tensors(graph, rule_id, violations);
2913        }
2914    }
2915}
2916
2917fn visit_attribute_sparse_tensors(
2918    attribute: &AttributeProto,
2919    _location: ViolationLocation,
2920    rule_id: &str,
2921    violations: &mut Vec<Violation>,
2922) {
2923    if let Some(sparse) = &attribute.sparse_tensor {
2924        check_sparse_tensor(sparse, false, rule_id, violations);
2925    }
2926    for sparse in &attribute.sparse_tensors {
2927        check_sparse_tensor(sparse, false, rule_id, violations);
2928    }
2929}
2930
2931fn check_sparse_tensor(
2932    sparse: &SparseTensorProto,
2933    require_name: bool,
2934    rule_id: &str,
2935    violations: &mut Vec<Violation>,
2936) {
2937    let name = sparse
2938        .values
2939        .as_ref()
2940        .map(|values| values.name.clone())
2941        .unwrap_or_default();
2942    let location = ViolationLocation::Value { value_name: name };
2943    let mut report = |message: String| {
2944        violations.push(Violation {
2945            rule_id: rule_id.to_string(),
2946            severity: Severity::Error,
2947            message,
2948            location: location.clone(),
2949        });
2950    };
2951    let Some(values) = &sparse.values else {
2952        report("SparseTensorProto.values must be present".into());
2953        return;
2954    };
2955    if require_name && values.name.is_empty() {
2956        report("sparse initializer values must have a non-empty name".into());
2957    }
2958    if sparse.dims.is_empty() || sparse.dims.iter().any(|&dim| dim <= 0) {
2959        report("SparseTensorProto must have positive rank and dimensions".into());
2960        return;
2961    }
2962    let Some(dense_count) = checked_numel(&sparse.dims) else {
2963        report("SparseTensorProto dimensions must fit usize".into());
2964        return;
2965    };
2966    if values.dims.len() != 1 {
2967        report("SparseTensorProto.values must have shape [NNZ]".into());
2968        return;
2969    }
2970    let Ok(nnz) = usize::try_from(values.dims[0]) else {
2971        report("SparseTensorProto NNZ must be non-negative".into());
2972        return;
2973    };
2974    if nnz > dense_count {
2975        report(format!(
2976            "SparseTensorProto NNZ {nnz} exceeds dense element count {dense_count}"
2977        ));
2978    }
2979    let Some(indices) = &sparse.indices else {
2980        if nnz != 0 {
2981            report("SparseTensorProto.indices must be present when NNZ is nonzero".into());
2982        }
2983        return;
2984    };
2985    if indices.data_type != tensor_proto::DataType::Int64 as i32 {
2986        report("SparseTensorProto.indices must have INT64 dtype".into());
2987        return;
2988    }
2989    let rank = sparse.dims.len();
2990    let coordinate = match indices.dims.as_slice() {
2991        [count] if usize::try_from(*count).ok() == Some(nnz) => false,
2992        [count, width]
2993            if usize::try_from(*count).ok() == Some(nnz)
2994                && usize::try_from(*width).ok() == Some(rank) =>
2995        {
2996            true
2997        }
2998        _ => {
2999            report(format!(
3000                "SparseTensorProto.indices shape must be [{nnz}] or [{nnz}, {rank}]"
3001            ));
3002            return;
3003        }
3004    };
3005    let Some(index_values) = tensor_i64_values(indices) else {
3006        report("SparseTensorProto.indices must use int64_data or raw_data".into());
3007        return;
3008    };
3009    let expected_indices = if coordinate {
3010        nnz.checked_mul(rank)
3011    } else {
3012        Some(nnz)
3013    };
3014    if expected_indices != Some(index_values.len()) {
3015        report("SparseTensorProto.indices payload count does not match its shape".into());
3016        return;
3017    }
3018    if coordinate {
3019        let mut previous: Option<&[i64]> = None;
3020        for tuple in index_values.chunks(rank.max(1)) {
3021            if tuple
3022                .iter()
3023                .zip(&sparse.dims)
3024                .any(|(&index, &dim)| index < 0 || index >= dim)
3025            {
3026                report("SparseTensorProto coordinate index is out of bounds".into());
3027            }
3028            if previous.is_some_and(|prior| prior >= tuple) {
3029                report(
3030                    "SparseTensorProto coordinate indices must be lexicographically increasing"
3031                        .into(),
3032                );
3033            }
3034            previous = Some(tuple);
3035        }
3036    } else {
3037        let Ok(dense_count) = i64::try_from(dense_count) else {
3038            report("SparseTensorProto dense element count does not fit i64".into());
3039            return;
3040        };
3041        let mut previous = None;
3042        for &index in &index_values {
3043            if index < 0 || index >= dense_count {
3044                report("SparseTensorProto linear index is out of bounds".into());
3045            }
3046            if previous.is_some_and(|prior| prior >= index) {
3047                report("SparseTensorProto linear indices must be strictly increasing".into());
3048            }
3049            previous = Some(index);
3050        }
3051    }
3052}
3053
3054fn tensor_i64_values(tensor: &TensorProto) -> Option<Vec<i64>> {
3055    if !tensor.int64_data.is_empty() {
3056        return Some(tensor.int64_data.clone());
3057    }
3058    if !tensor.raw_data.len().is_multiple_of(8) {
3059        return None;
3060    }
3061    Some(
3062        tensor
3063            .raw_data
3064            .chunks_exact(8)
3065            .map(|bytes| i64::from_le_bytes(bytes.try_into().expect("chunk size is eight")))
3066            .collect(),
3067    )
3068}
3069
3070/// Validate the IR v11+ multi-device configuration and sharding annotations.
3071///
3072/// This rule operates on the retained protobuf because the execution IR
3073/// deliberately treats distributed annotations as backend hints and does not
3074/// project them onto runtime nodes.
3075pub struct MultiDeviceConfigurationRule;
3076
3077impl ValidationRule for MultiDeviceConfigurationRule {
3078    fn id(&self) -> &str {
3079        "multidevice.configuration_valid"
3080    }
3081
3082    fn severity(&self) -> Severity {
3083        Severity::Error
3084    }
3085
3086    fn check(&self, model: &Model, _ctx: &ValidationContext) -> Vec<Violation> {
3087        let Some(proto) = model.retained_proto() else {
3088            return Vec::new();
3089        };
3090        check_multi_device_model(proto, self.id())
3091    }
3092}
3093
3094fn check_multi_device_model(model: &ModelProto, rule_id: &str) -> Vec<Violation> {
3095    let mut violations = Vec::new();
3096    if model.ir_version < 11 && model_has_multi_device_annotations(model) {
3097        violations.push(model_violation(
3098            rule_id,
3099            format!(
3100                "multi-device annotations require IR version 11 or newer, but model declares {}",
3101                model.ir_version
3102            ),
3103        ));
3104    }
3105    let mut configurations = HashMap::new();
3106    for configuration in &model.configuration {
3107        if configuration.name.is_empty() {
3108            violations.push(model_violation(
3109                rule_id,
3110                "device configuration name must be present",
3111            ));
3112        } else if configurations
3113            .insert(configuration.name.as_str(), configuration)
3114            .is_some()
3115        {
3116            violations.push(model_violation(
3117                rule_id,
3118                format!(
3119                    "device configuration name '{}' is not unique",
3120                    configuration.name
3121                ),
3122            ));
3123        }
3124        if configuration.num_devices <= 0 {
3125            violations.push(model_violation(
3126                rule_id,
3127                format!(
3128                    "device configuration '{}' must declare a positive num_devices",
3129                    configuration.name
3130                ),
3131            ));
3132        }
3133        if !configuration.device.is_empty()
3134            && configuration.device.len() != configuration.num_devices.max(0) as usize
3135        {
3136            violations.push(model_violation(
3137                rule_id,
3138                format!(
3139                    "device configuration '{}' names {} devices but num_devices is {}",
3140                    configuration.name,
3141                    configuration.device.len(),
3142                    configuration.num_devices
3143                ),
3144            ));
3145        }
3146    }
3147
3148    if let Some(graph) = &model.graph {
3149        check_multi_device_graph(graph, &configurations, rule_id, &mut violations);
3150    }
3151    for training in &model.training_info {
3152        if let Some(graph) = &training.initialization {
3153            check_multi_device_graph(graph, &configurations, rule_id, &mut violations);
3154        }
3155        if let Some(graph) = &training.algorithm {
3156            check_multi_device_graph(graph, &configurations, rule_id, &mut violations);
3157        }
3158    }
3159    for function in &model.functions {
3160        for node in &function.node {
3161            check_multi_device_node(
3162                node,
3163                &function.name,
3164                None,
3165                &configurations,
3166                rule_id,
3167                &mut violations,
3168            );
3169        }
3170    }
3171    violations
3172}
3173
3174fn check_multi_device_graph<'a>(
3175    graph: &GraphProto,
3176    configurations: &HashMap<&'a str, &'a DeviceConfigurationProto>,
3177    rule_id: &str,
3178    violations: &mut Vec<Violation>,
3179) {
3180    let graph_name = graph.name.as_str();
3181    for node in &graph.node {
3182        check_multi_device_node(
3183            node,
3184            graph_name,
3185            Some(graph),
3186            configurations,
3187            rule_id,
3188            violations,
3189        );
3190        for attribute in &node.attribute {
3191            if let Some(subgraph) = &attribute.g {
3192                check_multi_device_graph(subgraph, configurations, rule_id, violations);
3193            }
3194            for subgraph in &attribute.graphs {
3195                check_multi_device_graph(subgraph, configurations, rule_id, violations);
3196            }
3197        }
3198    }
3199}
3200
3201fn check_multi_device_node<'a>(
3202    node: &NodeProto,
3203    graph_name: &str,
3204    graph: Option<&GraphProto>,
3205    configurations: &HashMap<&'a str, &'a DeviceConfigurationProto>,
3206    rule_id: &str,
3207    violations: &mut Vec<Violation>,
3208) {
3209    for device_configuration in &node.device_configurations {
3210        let configuration = configurations
3211            .get(device_configuration.configuration_id.as_str())
3212            .copied();
3213        if configuration.is_none() {
3214            violations.push(proto_node_violation(
3215                rule_id,
3216                graph_name,
3217                node,
3218                format!(
3219                    "device configuration id '{}' does not match a ModelProto.configuration name",
3220                    device_configuration.configuration_id
3221                ),
3222            ));
3223        }
3224
3225        for sharding in &device_configuration.sharding_spec {
3226            if sharding.tensor_name.is_empty()
3227                || !node
3228                    .input
3229                    .iter()
3230                    .chain(&node.output)
3231                    .any(|name| name == &sharding.tensor_name)
3232            {
3233                violations.push(proto_node_violation(
3234                    rule_id,
3235                    graph_name,
3236                    node,
3237                    format!(
3238                        "sharding tensor '{}' is not a named node input or output",
3239                        sharding.tensor_name
3240                    ),
3241                ));
3242            }
3243            if let Some(configuration) = configuration {
3244                check_sharding_devices(
3245                    sharding,
3246                    configuration,
3247                    graph_name,
3248                    node,
3249                    rule_id,
3250                    violations,
3251                );
3252            }
3253
3254            let rank = graph.and_then(|graph| tensor_rank(graph, &sharding.tensor_name));
3255            for sharded_dim in &sharding.sharded_dim {
3256                if let Some(rank) = rank {
3257                    let rank = rank as i64;
3258                    if sharded_dim.axis < -rank || sharded_dim.axis >= rank {
3259                        violations.push(proto_node_violation(
3260                            rule_id,
3261                            graph_name,
3262                            node,
3263                            format!(
3264                                "sharding axis {} for tensor '{}' is outside [-{}, {}]",
3265                                sharded_dim.axis,
3266                                sharding.tensor_name,
3267                                rank,
3268                                rank - 1
3269                            ),
3270                        ));
3271                    }
3272                }
3273
3274                for simple in &sharded_dim.simple_sharding {
3275                    if simple.num_shards <= 0 {
3276                        violations.push(proto_node_violation(
3277                            rule_id,
3278                            graph_name,
3279                            node,
3280                            format!(
3281                                "sharded axis {} for tensor '{}' must declare a positive num_shards",
3282                                sharded_dim.axis, sharding.tensor_name
3283                            ),
3284                        ));
3285                    }
3286                }
3287            }
3288        }
3289    }
3290}
3291
3292fn check_sharding_devices(
3293    sharding: &onnx_runtime_loader::proto::onnx::ShardingSpecProto,
3294    configuration: &DeviceConfigurationProto,
3295    graph_name: &str,
3296    node: &NodeProto,
3297    rule_id: &str,
3298    violations: &mut Vec<Violation>,
3299) {
3300    let num_devices = i64::from(configuration.num_devices);
3301    let mut groups = HashMap::new();
3302    for group in &sharding.index_to_device_group_map {
3303        if groups.insert(group.key, group).is_some() {
3304            violations.push(proto_node_violation(
3305                rule_id,
3306                graph_name,
3307                node,
3308                format!("device group key {} is not unique", group.key),
3309            ));
3310        }
3311        if !sharding.device.contains(&group.key) {
3312            violations.push(proto_node_violation(
3313                rule_id,
3314                graph_name,
3315                node,
3316                format!(
3317                    "device group key {} is not referenced by ShardingSpecProto.device",
3318                    group.key
3319                ),
3320            ));
3321        }
3322        if group.value.is_empty() {
3323            violations.push(proto_node_violation(
3324                rule_id,
3325                graph_name,
3326                node,
3327                format!(
3328                    "device group {} must contain at least one device",
3329                    group.key
3330                ),
3331            ));
3332        }
3333        let mut members = HashSet::new();
3334        for &member in &group.value {
3335            if member < 0 || member >= num_devices {
3336                violations.push(proto_node_violation(
3337                    rule_id,
3338                    graph_name,
3339                    node,
3340                    format!(
3341                        "device group {} member {} is outside [0, {})",
3342                        group.key, member, configuration.num_devices
3343                    ),
3344                ));
3345            } else if !members.insert(member) {
3346                violations.push(proto_node_violation(
3347                    rule_id,
3348                    graph_name,
3349                    node,
3350                    format!(
3351                        "device group {} contains duplicate member {}",
3352                        group.key, member
3353                    ),
3354                ));
3355            }
3356        }
3357    }
3358    for &device in &sharding.device {
3359        if !groups.contains_key(&device) && (device < 0 || device >= num_devices) {
3360            violations.push(proto_node_violation(
3361                rule_id,
3362                graph_name,
3363                node,
3364                format!(
3365                    "sharding device {} is neither a group key nor within [0, {})",
3366                    device, configuration.num_devices
3367                ),
3368            ));
3369        }
3370    }
3371}
3372
3373fn tensor_rank(graph: &GraphProto, name: &str) -> Option<usize> {
3374    graph
3375        .initializer
3376        .iter()
3377        .find(|tensor| tensor.name == name)
3378        .map(|tensor| tensor.dims.len())
3379        .or_else(|| {
3380            graph
3381                .sparse_initializer
3382                .iter()
3383                .find(|tensor| {
3384                    tensor
3385                        .values
3386                        .as_ref()
3387                        .is_some_and(|values| values.name == name)
3388                })
3389                .map(|tensor| tensor.dims.len())
3390        })
3391        .or_else(|| {
3392            graph
3393                .input
3394                .iter()
3395                .chain(&graph.output)
3396                .chain(&graph.value_info)
3397                .find(|value| value.name == name)
3398                .and_then(|value| value.r#type.as_ref())
3399                .and_then(type_rank)
3400        })
3401}
3402
3403fn model_has_multi_device_annotations(model: &ModelProto) -> bool {
3404    !model.configuration.is_empty()
3405        || model
3406            .graph
3407            .as_ref()
3408            .is_some_and(graph_has_device_annotations)
3409        || model.training_info.iter().any(|training| {
3410            training
3411                .initialization
3412                .as_ref()
3413                .is_some_and(graph_has_device_annotations)
3414                || training
3415                    .algorithm
3416                    .as_ref()
3417                    .is_some_and(graph_has_device_annotations)
3418        })
3419        || model
3420            .functions
3421            .iter()
3422            .any(|function| function.node.iter().any(node_has_device_annotations))
3423}
3424
3425fn graph_has_device_annotations(graph: &GraphProto) -> bool {
3426    graph.node.iter().any(node_has_device_annotations)
3427}
3428
3429fn node_has_device_annotations(node: &NodeProto) -> bool {
3430    !node.device_configurations.is_empty()
3431        || node.attribute.iter().any(|attribute| {
3432            attribute
3433                .g
3434                .as_ref()
3435                .is_some_and(graph_has_device_annotations)
3436                || attribute.graphs.iter().any(graph_has_device_annotations)
3437        })
3438}
3439
3440fn type_rank(value: &onnx_runtime_loader::proto::onnx::TypeProto) -> Option<usize> {
3441    match value.value.as_ref()? {
3442        type_proto::Value::TensorType(tensor) => tensor.shape.as_ref().map(shape_rank),
3443        type_proto::Value::SparseTensorType(tensor) => tensor.shape.as_ref().map(shape_rank),
3444        _ => None,
3445    }
3446}
3447
3448fn shape_rank(shape: &TensorShapeProto) -> usize {
3449    shape.dim.len()
3450}
3451
3452fn model_violation(rule_id: &str, message: impl Into<String>) -> Violation {
3453    Violation {
3454        rule_id: rule_id.to_string(),
3455        severity: Severity::Error,
3456        message: message.into(),
3457        location: ViolationLocation::Model,
3458    }
3459}
3460
3461fn proto_node_violation(
3462    rule_id: &str,
3463    graph_name: &str,
3464    node: &NodeProto,
3465    message: impl Into<String>,
3466) -> Violation {
3467    Violation {
3468        rule_id: rule_id.to_string(),
3469        severity: Severity::Error,
3470        message: message.into(),
3471        location: ViolationLocation::Node {
3472            graph_name: graph_name.to_string(),
3473            node_name: if node.name.is_empty() {
3474                format!("<{}>", node.op_type)
3475            } else {
3476                node.name.clone()
3477            },
3478        },
3479    }
3480}
3481
3482fn check_graph_schemas(
3483    graph: &Graph,
3484    opset_imports: &HashMap<String, u64>,
3485    graph_name: &str,
3486    ctx: &ValidationContext,
3487    rule_id: &str,
3488) -> Vec<Violation> {
3489    let mut violations = Vec::new();
3490    for (_node_id, node) in graph.nodes.iter() {
3491        let Some(opset) = imported_opset(opset_imports, &node.domain) else {
3492            continue;
3493        };
3494        let Some(schema) = ctx.schemas().lookup(&node.op_type, &node.domain, opset) else {
3495            let reason = if ctx.schemas().contains_operator(&node.op_type, &node.domain) {
3496                format!("has no schema valid at opset {opset}")
3497            } else {
3498                "is not present in the schema registry".to_string()
3499            };
3500            violations.push(node_violation(
3501                rule_id,
3502                graph_name,
3503                node,
3504                format!(
3505                    "node '{}' ({}::{}) {reason}",
3506                    node_label(node),
3507                    normalize_domain(&node.domain),
3508                    node.op_type
3509                ),
3510            ));
3511            continue;
3512        };
3513        check_node_arity(schema, node, graph_name, rule_id, &mut violations);
3514        check_node_attributes(schema, node, graph_name, rule_id, &mut violations);
3515    }
3516    for subgraph in graph.subgraphs.values() {
3517        violations.extend(check_graph_schemas(
3518            subgraph,
3519            opset_imports,
3520            graph_name,
3521            ctx,
3522            rule_id,
3523        ));
3524    }
3525    violations
3526}
3527
3528fn imported_opset(opsets: &HashMap<String, u64>, domain: &str) -> Option<u64> {
3529    if domain.is_empty() || domain == "ai.onnx" {
3530        opsets.get("").or_else(|| opsets.get("ai.onnx")).copied()
3531    } else {
3532        opsets.get(domain).copied()
3533    }
3534}
3535
3536fn check_node_arity(
3537    schema: &OpSchema,
3538    node: &onnx_runtime_ir::Node,
3539    graph_name: &str,
3540    rule_id: &str,
3541    violations: &mut Vec<Violation>,
3542) {
3543    let min_inputs = schema
3544        .inputs
3545        .iter()
3546        .map(|spec| {
3547            if spec.variadic {
3548                spec.min_arity
3549            } else {
3550                usize::from(!spec.optional)
3551            }
3552        })
3553        .sum();
3554    let max_inputs =
3555        (!schema.inputs.iter().any(|spec| spec.variadic)).then_some(schema.inputs.len());
3556    if node.inputs.len() < min_inputs || max_inputs.is_some_and(|max| node.inputs.len() > max) {
3557        violations.push(node_violation(
3558            rule_id,
3559            graph_name,
3560            node,
3561            arity_message("input", node.inputs.len(), min_inputs, max_inputs),
3562        ));
3563    }
3564    for (index, spec) in schema.inputs.iter().enumerate() {
3565        if !spec.optional && !spec.variadic && node.inputs.get(index).is_some_and(Option::is_none) {
3566            violations.push(node_violation(
3567                rule_id,
3568                graph_name,
3569                node,
3570                format!(
3571                    "required input '{}' at position {index} is omitted",
3572                    spec.name
3573                ),
3574            ));
3575        }
3576    }
3577
3578    let min_outputs = schema
3579        .outputs
3580        .iter()
3581        .map(|spec| {
3582            if spec.variadic {
3583                spec.min_arity
3584            } else {
3585                usize::from(!spec.optional)
3586            }
3587        })
3588        .sum();
3589    let max_outputs =
3590        (!schema.outputs.iter().any(|spec| spec.variadic)).then_some(schema.outputs.len());
3591    if node.outputs.len() < min_outputs || max_outputs.is_some_and(|max| node.outputs.len() > max) {
3592        violations.push(node_violation(
3593            rule_id,
3594            graph_name,
3595            node,
3596            arity_message("output", node.outputs.len(), min_outputs, max_outputs),
3597        ));
3598    }
3599}
3600
3601fn arity_message(kind: &str, actual: usize, min: usize, max: Option<usize>) -> String {
3602    match max {
3603        Some(max) if min == max => {
3604            format!("has {actual} {kind}s but schema requires exactly {min}")
3605        }
3606        Some(max) => format!("has {actual} {kind}s but schema permits {min}..={max}"),
3607        None => format!("has {actual} {kind}s but schema requires at least {min}"),
3608    }
3609}
3610
3611fn check_node_attributes(
3612    schema: &OpSchema,
3613    node: &onnx_runtime_ir::Node,
3614    graph_name: &str,
3615    rule_id: &str,
3616    violations: &mut Vec<Violation>,
3617) {
3618    for spec in &schema.attributes {
3619        match node.attributes.get(&spec.name) {
3620            None if spec.required && spec.default.is_none() => {
3621                violations.push(node_violation(
3622                    rule_id,
3623                    graph_name,
3624                    node,
3625                    format!("required attribute '{}' is missing", spec.name),
3626                ));
3627            }
3628            Some(value) if !attribute_matches(value, spec.attr_type) => {
3629                violations.push(node_violation(
3630                    rule_id,
3631                    graph_name,
3632                    node,
3633                    format!(
3634                        "attribute '{}' has type {} but schema requires {:?}",
3635                        spec.name,
3636                        attribute_name(value),
3637                        spec.attr_type
3638                    ),
3639                ));
3640            }
3641            _ => {}
3642        }
3643    }
3644    for (name, value) in &node.attributes {
3645        if !schema.attributes.iter().any(|spec| spec.name == *name) {
3646            violations.push(node_violation(
3647                rule_id,
3648                graph_name,
3649                node,
3650                format!(
3651                    "attribute '{name}' of type {} is not declared by the schema",
3652                    attribute_name(value)
3653                ),
3654            ));
3655        }
3656    }
3657}
3658
3659fn attribute_matches(value: &Attribute, expected: AttributeType) -> bool {
3660    matches!(
3661        (value, expected),
3662        (Attribute::Int(_), AttributeType::Int)
3663            | (Attribute::Float(_), AttributeType::Float)
3664            | (Attribute::String(_), AttributeType::String)
3665            | (Attribute::Tensor(_), AttributeType::Tensor)
3666            | (Attribute::Graph(_), AttributeType::Graph)
3667            | (Attribute::SparseTensor(_), AttributeType::SparseTensor)
3668            | (Attribute::TypeProto(_), AttributeType::TypeProto)
3669            | (Attribute::Ints(_), AttributeType::Ints)
3670            | (Attribute::Floats(_), AttributeType::Floats)
3671            | (Attribute::Strings(_), AttributeType::Strings)
3672            | (Attribute::Graphs(_), AttributeType::Graphs)
3673            | (Attribute::Tensors(_), AttributeType::Tensors)
3674            | (Attribute::SparseTensors(_), AttributeType::SparseTensors)
3675            | (Attribute::TypeProtos(_), AttributeType::TypeProtos)
3676    )
3677}
3678
3679fn attribute_name(value: &Attribute) -> &'static str {
3680    match value {
3681        Attribute::Int(_) => "int",
3682        Attribute::Float(_) => "float",
3683        Attribute::String(_) => "string",
3684        Attribute::Tensor(_) => "tensor",
3685        Attribute::Tensors(_) => "tensors",
3686        Attribute::SparseTensor(_) => "sparse_tensor",
3687        Attribute::SparseTensors(_) => "sparse_tensors",
3688        Attribute::Graph(_) => "graph",
3689        Attribute::Graphs(_) => "graphs",
3690        Attribute::TypeProto(_) => "type_proto",
3691        Attribute::TypeProtos(_) => "type_protos",
3692        Attribute::Ints(_) => "ints",
3693        Attribute::Floats(_) => "floats",
3694        Attribute::Strings(_) => "strings",
3695    }
3696}
3697
3698fn node_violation(
3699    rule_id: &str,
3700    graph_name: &str,
3701    node: &onnx_runtime_ir::Node,
3702    message: String,
3703) -> Violation {
3704    Violation {
3705        rule_id: rule_id.to_string(),
3706        severity: Severity::Error,
3707        message,
3708        location: ViolationLocation::Node {
3709            graph_name: graph_name.to_string(),
3710            node_name: node_label(node),
3711        },
3712    }
3713}
3714
3715/// A stable display label for a node: its name, or `<op_type#id>` if anonymous.
3716fn node_label(node: &onnx_runtime_ir::Node) -> String {
3717    if node.name.is_empty() {
3718        format!("<{}#{}>", node.op_type, node.id.0)
3719    } else {
3720        node.name.clone()
3721    }
3722}
3723
3724fn value_label(value_id: ValueId, name: Option<&str>) -> String {
3725    match name {
3726        Some(name) if !name.is_empty() => name.to_string(),
3727        _ => format!("<value#{}>", value_id.0),
3728    }
3729}
3730
3731#[cfg(test)]
3732mod tests {
3733    use super::*;
3734    use crate::schema::SchemaRegistry;
3735    use onnx_runtime_ir::{
3736        Attribute, DataType, Dim, Node, NodeId, TensorData, WeightRef, static_shape,
3737    };
3738    use onnx_runtime_loader::proto::onnx::{
3739        FunctionProto, OperatorSetIdProto, StringStringEntryProto,
3740    };
3741
3742    fn if_model(subgraph: Graph, output_count: usize, include_else_branch: bool) -> Model {
3743        let mut graph = Graph::new();
3744        graph.opset_imports.insert(String::new(), 21);
3745        let cond = graph.create_named_value("cond", DataType::Bool, static_shape([]));
3746        let outputs = (0..output_count)
3747            .map(|index| {
3748                graph.create_named_value(
3749                    format!("out{index}"),
3750                    DataType::Float32,
3751                    static_shape([1]),
3752                )
3753            })
3754            .collect::<Vec<_>>();
3755        graph.add_input(cond);
3756        let mut node = Node::new(NodeId(0), "If", vec![Some(cond)], outputs.clone());
3757        node.attributes.insert(
3758            "then_branch".into(),
3759            Attribute::Graph(Box::new(subgraph.clone())),
3760        );
3761        if include_else_branch {
3762            node.attributes.insert(
3763                "else_branch".into(),
3764                Attribute::Graph(Box::new(subgraph.clone())),
3765            );
3766        }
3767        let node_id = graph.insert_node(node);
3768        graph
3769            .subgraphs
3770            .insert((node_id, "then_branch".into()), subgraph.clone());
3771        if include_else_branch {
3772            graph
3773                .subgraphs
3774                .insert((node_id, "else_branch".into()), subgraph);
3775        }
3776        for output in outputs {
3777            graph.add_output(output);
3778        }
3779        Model::new(graph)
3780    }
3781
3782    fn nested_model(subgraph: Graph) -> Model {
3783        if_model(subgraph, 1, true)
3784    }
3785
3786    fn one_node_model(op_type: &str, inputs: usize, outputs: usize) -> Model {
3787        let mut graph = Graph::new();
3788        graph.opset_imports.insert(String::new(), 21);
3789        let input_ids = (0..inputs)
3790            .map(|index| {
3791                graph.create_named_value(
3792                    format!("input{index}"),
3793                    DataType::Float32,
3794                    static_shape([1]),
3795                )
3796            })
3797            .collect::<Vec<_>>();
3798        let output_ids = (0..outputs)
3799            .map(|index| {
3800                graph.create_named_value(
3801                    format!("output{index}"),
3802                    DataType::Float32,
3803                    static_shape([1]),
3804                )
3805            })
3806            .collect::<Vec<_>>();
3807        graph.insert_node(Node::new(
3808            NodeId(0),
3809            op_type,
3810            input_ids.iter().copied().map(Some).collect(),
3811            output_ids.clone(),
3812        ));
3813        for input in input_ids {
3814            graph.add_input(input);
3815        }
3816        for output in output_ids {
3817            graph.add_output(output);
3818        }
3819        Model::new(graph)
3820    }
3821
3822    fn assert_error(rule_id: &str, violations: &[Violation], location: ViolationLocation) {
3823        assert_eq!(violations.len(), 1, "{violations:?}");
3824        assert_eq!(violations[0].rule_id, rule_id);
3825        assert_eq!(violations[0].severity, Severity::Error);
3826        assert_eq!(violations[0].location, location);
3827    }
3828
3829    fn retained_model(proto: ModelProto) -> Model {
3830        retained_model_at(proto, 13)
3831    }
3832
3833    fn retained_model_at(mut proto: ModelProto, ir_version: i64) -> Model {
3834        proto.ir_version = ir_version;
3835        if ir_version >= 3 && proto.opset_import.is_empty() {
3836            proto.opset_import.push(OperatorSetIdProto {
3837                domain: String::new(),
3838                version: 24,
3839            });
3840        }
3841        if proto.graph.is_none() {
3842            proto.graph = Some(GraphProto {
3843                name: "graph".into(),
3844                ..Default::default()
3845            });
3846        }
3847        Model::from_proto(proto).unwrap()
3848    }
3849
3850    fn function(name: &str, nodes: Vec<NodeProto>) -> FunctionProto {
3851        FunctionProto {
3852            name: name.into(),
3853            input: vec!["X".into()],
3854            output: vec!["Y".into()],
3855            node: nodes,
3856            opset_import: vec![OperatorSetIdProto {
3857                domain: String::new(),
3858                version: 24,
3859            }],
3860            domain: "local.test".into(),
3861            ..Default::default()
3862        }
3863    }
3864
3865    fn local_function_call_model(function: FunctionProto, call: NodeProto) -> Model {
3866        let graph_inputs = call
3867            .input
3868            .iter()
3869            .filter(|name| !name.is_empty())
3870            .map(|name| ValueInfoProto {
3871                name: name.clone(),
3872                ..Default::default()
3873            })
3874            .collect();
3875        let graph_outputs = call
3876            .output
3877            .iter()
3878            .filter(|name| !name.is_empty())
3879            .map(|name| ValueInfoProto {
3880                name: name.clone(),
3881                ..Default::default()
3882            })
3883            .collect();
3884        retained_model(ModelProto {
3885            opset_import: vec![
3886                OperatorSetIdProto {
3887                    domain: String::new(),
3888                    version: 24,
3889                },
3890                OperatorSetIdProto {
3891                    domain: "local.test".into(),
3892                    version: 1,
3893                },
3894            ],
3895            graph: Some(GraphProto {
3896                name: "graph".into(),
3897                input: graph_inputs,
3898                output: graph_outputs,
3899                node: vec![call],
3900                ..Default::default()
3901            }),
3902            functions: vec![function],
3903            ..Default::default()
3904        })
3905    }
3906
3907    #[test]
3908    fn metadata_rule_rejects_duplicate_keys() {
3909        let model = retained_model(ModelProto {
3910            metadata_props: vec![
3911                StringStringEntryProto {
3912                    key: "owner".into(),
3913                    value: "one".into(),
3914                },
3915                StringStringEntryProto {
3916                    key: "owner".into(),
3917                    value: "two".into(),
3918                },
3919            ],
3920            ..Default::default()
3921        });
3922        let violations = MetadataKeysUniqueRule.check(&model, &ValidationContext::default());
3923        assert_eq!(violations.len(), 1);
3924        assert!(violations[0].message.contains("duplicate key 'owner'"));
3925    }
3926
3927    #[test]
3928    fn attribute_rule_checks_discriminator_union_and_references() {
3929        let location = ViolationLocation::Model;
3930        let mut violations = Vec::new();
3931        check_attribute_proto(
3932            &AttributeProto {
3933                name: "weight".into(),
3934                r#type: attribute_proto::AttributeType::Tensor as i32,
3935                i: 7,
3936                ..Default::default()
3937            },
3938            location.clone(),
3939            13,
3940            "test",
3941            &mut violations,
3942        );
3943        assert_eq!(violations.len(), 2, "{violations:?}");
3944
3945        violations.clear();
3946        check_attribute_proto(
3947            &AttributeProto {
3948                name: "alpha".into(),
3949                ref_attr_name: "alpha".into(),
3950                r#type: attribute_proto::AttributeType::Float as i32,
3951                ..Default::default()
3952            },
3953            location,
3954            13,
3955            "test",
3956            &mut violations,
3957        );
3958        assert!(violations.is_empty(), "{violations:?}");
3959    }
3960
3961    #[test]
3962    fn attribute_type_is_required_starting_with_ir_v2() {
3963        let attribute = AttributeProto {
3964            name: "axis".into(),
3965            i: 1,
3966            ..Default::default()
3967        };
3968        let mut violations = Vec::new();
3969        check_attribute_proto(
3970            &attribute,
3971            ViolationLocation::Model,
3972            1,
3973            "test",
3974            &mut violations,
3975        );
3976        assert!(violations.is_empty(), "{violations:?}");
3977
3978        check_attribute_proto(
3979            &attribute,
3980            ViolationLocation::Model,
3981            2,
3982            "test",
3983            &mut violations,
3984        );
3985        assert_eq!(violations.len(), 1, "{violations:?}");
3986        assert!(violations[0].message.contains("undefined discriminator"));
3987    }
3988
3989    #[test]
3990    fn type_rule_accepts_opaque_and_rejects_invalid_containers() {
3991        let mut violations = Vec::new();
3992        check_type_proto(
3993            &TypeProto {
3994                value: Some(type_proto::Value::OpaqueType(type_proto::Opaque {
3995                    domain: "example".into(),
3996                    name: "State".into(),
3997                })),
3998                ..Default::default()
3999            },
4000            ViolationLocation::Model,
4001            false,
4002            "test",
4003            &mut violations,
4004        );
4005        assert!(violations.is_empty());
4006
4007        check_type_proto(
4008            &TypeProto {
4009                value: Some(type_proto::Value::MapType(Box::new(type_proto::Map {
4010                    key_type: tensor_proto::DataType::Float as i32,
4011                    value_type: None,
4012                }))),
4013                ..Default::default()
4014            },
4015            ViolationLocation::Model,
4016            false,
4017            "test",
4018            &mut violations,
4019        );
4020        assert_eq!(violations.len(), 2, "{violations:?}");
4021    }
4022
4023    #[test]
4024    fn top_level_tensor_requires_shape_but_nested_tensor_does_not() {
4025        let tensor_without_shape = TypeProto {
4026            value: Some(type_proto::Value::TensorType(type_proto::Tensor {
4027                elem_type: tensor_proto::DataType::Float as i32,
4028                shape: None,
4029            })),
4030            ..Default::default()
4031        };
4032        let mut violations = Vec::new();
4033        check_value_type(
4034            &ValueInfoProto {
4035                name: "input".into(),
4036                r#type: Some(tensor_without_shape.clone()),
4037                ..Default::default()
4038            },
4039            true,
4040            "test",
4041            &mut violations,
4042        );
4043        assert_eq!(violations.len(), 1, "{violations:?}");
4044        assert!(violations[0].message.contains("must declare a shape"));
4045
4046        violations.clear();
4047        check_value_type(
4048            &ValueInfoProto {
4049                name: "sequence".into(),
4050                r#type: Some(TypeProto {
4051                    value: Some(type_proto::Value::SequenceType(Box::new(
4052                        type_proto::Sequence {
4053                            elem_type: Some(Box::new(tensor_without_shape)),
4054                        },
4055                    ))),
4056                    ..Default::default()
4057                }),
4058                ..Default::default()
4059            },
4060            true,
4061            "test",
4062            &mut violations,
4063        );
4064        assert!(violations.is_empty(), "{violations:?}");
4065    }
4066
4067    #[test]
4068    fn tensor_payload_rule_checks_sizes_and_external_offsets() {
4069        let mut violations = Vec::new();
4070        check_tensor_payload(
4071            &TensorProto {
4072                dims: vec![2],
4073                data_type: tensor_proto::DataType::Float as i32,
4074                raw_data: vec![0; 4],
4075                name: "short".into(),
4076                ..Default::default()
4077            },
4078            "test",
4079            &mut violations,
4080        );
4081        assert_eq!(violations.len(), 1);
4082        assert!(violations[0].message.contains("8 are required"));
4083
4084        violations.clear();
4085        check_tensor_payload(
4086            &TensorProto {
4087                dims: vec![1],
4088                data_type: tensor_proto::DataType::Float as i32,
4089                data_location: tensor_proto::DataLocation::External as i32,
4090                external_data: vec![
4091                    StringStringEntryProto {
4092                        key: "location".into(),
4093                        value: "weights.bin".into(),
4094                    },
4095                    StringStringEntryProto {
4096                        key: "offset".into(),
4097                        value: usize::MAX.to_string(),
4098                    },
4099                    StringStringEntryProto {
4100                        key: "length".into(),
4101                        value: "1".into(),
4102                    },
4103                ],
4104                ..Default::default()
4105            },
4106            "test",
4107            &mut violations,
4108        );
4109        assert!(
4110            violations
4111                .iter()
4112                .any(|violation| violation.message.contains("offset + length overflows"))
4113        );
4114    }
4115
4116    #[test]
4117    fn tensor_payload_rule_accepts_unused_packed_bits_but_checks_length() {
4118        let mut tensor = TensorProto {
4119            dims: vec![3],
4120            data_type: tensor_proto::DataType::Uint4 as i32,
4121            raw_data: vec![0x21, 0xf3],
4122            ..Default::default()
4123        };
4124        let mut violations = Vec::new();
4125        check_tensor_payload(&tensor, "test", &mut violations);
4126        assert!(violations.is_empty(), "{violations:?}");
4127
4128        tensor.raw_data.pop();
4129        check_tensor_payload(&tensor, "test", &mut violations);
4130        assert!(
4131            violations
4132                .iter()
4133                .any(|violation| violation.message.contains("but 2 are required")),
4134            "{violations:?}"
4135        );
4136    }
4137
4138    #[test]
4139    fn sparse_tensor_rule_checks_order_and_bounds() {
4140        let sparse = SparseTensorProto {
4141            values: Some(TensorProto {
4142                dims: vec![2],
4143                data_type: tensor_proto::DataType::Float as i32,
4144                float_data: vec![1.0, 2.0],
4145                name: "sparse".into(),
4146                ..Default::default()
4147            }),
4148            indices: Some(TensorProto {
4149                dims: vec![2],
4150                data_type: tensor_proto::DataType::Int64 as i32,
4151                int64_data: vec![1, 1],
4152                ..Default::default()
4153            }),
4154            dims: vec![2, 2],
4155        };
4156        let mut violations = Vec::new();
4157        check_sparse_tensor(&sparse, true, "test", &mut violations);
4158        assert_eq!(violations.len(), 1, "{violations:?}");
4159        assert!(violations[0].message.contains("strictly increasing"));
4160
4161        let mut valid = sparse;
4162        valid.indices.as_mut().unwrap().int64_data = vec![0, 3];
4163        violations.clear();
4164        check_sparse_tensor(&valid, true, "test", &mut violations);
4165        assert!(violations.is_empty(), "{violations:?}");
4166    }
4167
4168    #[test]
4169    fn sparse_tensor_indices_are_optional_only_for_zero_nnz() {
4170        let mut sparse = SparseTensorProto {
4171            values: Some(TensorProto {
4172                dims: vec![0],
4173                data_type: tensor_proto::DataType::Float as i32,
4174                name: "empty_sparse".into(),
4175                ..Default::default()
4176            }),
4177            indices: None,
4178            dims: vec![2, 3],
4179        };
4180        let mut violations = Vec::new();
4181        check_sparse_tensor(&sparse, true, "test", &mut violations);
4182        assert!(violations.is_empty(), "{violations:?}");
4183
4184        sparse.values.as_mut().unwrap().dims = vec![1];
4185        check_sparse_tensor(&sparse, true, "test", &mut violations);
4186        assert_eq!(violations.len(), 1, "{violations:?}");
4187        assert!(violations[0].message.contains("when NNZ is nonzero"));
4188    }
4189
4190    #[test]
4191    fn sparse_tensor_requires_positive_rank_and_dimensions() {
4192        let mut sparse = SparseTensorProto {
4193            values: Some(TensorProto {
4194                dims: vec![0],
4195                data_type: tensor_proto::DataType::Float as i32,
4196                name: "empty_sparse".into(),
4197                ..Default::default()
4198            }),
4199            indices: None,
4200            dims: Vec::new(),
4201        };
4202        let mut violations = Vec::new();
4203        check_sparse_tensor(&sparse, true, "test", &mut violations);
4204        assert_eq!(violations.len(), 1, "{violations:?}");
4205        assert!(
4206            violations[0]
4207                .message
4208                .contains("positive rank and dimensions")
4209        );
4210
4211        sparse.dims = vec![2, 0];
4212        violations.clear();
4213        check_sparse_tensor(&sparse, true, "test", &mut violations);
4214        assert_eq!(violations.len(), 1, "{violations:?}");
4215        assert!(
4216            violations[0]
4217                .message
4218                .contains("positive rank and dimensions")
4219        );
4220    }
4221
4222    #[test]
4223    fn declared_graph_io_passes() {
4224        let rule = InputOutputDeclaredRule;
4225        let mut graph = Graph::new();
4226        let input = graph.create_named_value("X", DataType::Float32, static_shape([1]));
4227        let output = graph.create_named_value("Y", DataType::Float32, static_shape([1]));
4228        graph.add_input(input);
4229        graph.add_output(output);
4230
4231        assert!(
4232            rule.check(&Model::new(graph), &ValidationContext::default())
4233                .is_empty()
4234        );
4235    }
4236
4237    #[test]
4238    fn unnamed_graph_io_is_flagged() {
4239        let rule = InputOutputDeclaredRule;
4240        let mut graph = Graph::new();
4241        let input = graph.create_value(DataType::Float32, static_shape([1]));
4242        graph.add_input(input);
4243
4244        let violations = rule.check(&Model::new(graph), &ValidationContext::default());
4245        assert_error(
4246            rule.id(),
4247            &violations,
4248            ViolationLocation::Value {
4249                value_name: format!("<value#{}>", input.0),
4250            },
4251        );
4252    }
4253
4254    #[test]
4255    fn connected_node_inputs_pass() {
4256        let rule = NoUnconnectedNodesRule;
4257        let mut graph = Graph::new();
4258        let input = graph.create_named_value("X", DataType::Float32, static_shape([1]));
4259        let output = graph.create_named_value("Y", DataType::Float32, static_shape([1]));
4260        let final_output = graph.create_named_value("Z", DataType::Float32, static_shape([1]));
4261        graph.add_input(input);
4262        let node_id = graph.insert_node(Node::new(
4263            NodeId(0),
4264            "Relu",
4265            vec![Some(input)],
4266            vec![output],
4267        ));
4268        graph.insert_node(Node::new(
4269            NodeId(0),
4270            "Identity",
4271            vec![Some(output)],
4272            vec![final_output],
4273        ));
4274        let mut subgraph = Graph::new();
4275        let capture = subgraph.create_named_value("X", DataType::Float32, static_shape([1]));
4276        let subgraph_output =
4277            subgraph.create_named_value("subgraph_output", DataType::Float32, static_shape([1]));
4278        subgraph.insert_node(Node::new(
4279            NodeId(0),
4280            "Relu",
4281            vec![Some(capture)],
4282            vec![subgraph_output],
4283        ));
4284        subgraph.add_output(subgraph_output);
4285        graph.subgraphs.insert((node_id, "body".into()), subgraph);
4286        graph.add_output(final_output);
4287
4288        assert!(
4289            rule.check(&Model::new(graph), &ValidationContext::default())
4290                .is_empty()
4291        );
4292    }
4293
4294    #[test]
4295    fn omitted_optional_node_output_passes() {
4296        let rule = NoUnconnectedNodesRule;
4297        let mut graph = Graph::new();
4298        let input = graph.create_named_value("X", DataType::Float32, static_shape([1]));
4299        let omitted = graph.create_value(DataType::Float32, Vec::new());
4300        graph.add_input(input);
4301        graph.insert_node(Node::new(
4302            NodeId(0),
4303            "OptionalOutput",
4304            vec![Some(input)],
4305            vec![omitted],
4306        ));
4307
4308        assert!(
4309            rule.check(&Model::new(graph), &ValidationContext::default())
4310                .is_empty()
4311        );
4312    }
4313
4314    #[test]
4315    fn captured_node_output_passes() {
4316        let rule = NoUnconnectedNodesRule;
4317        let mut graph = Graph::new();
4318        let input = graph.create_named_value("X", DataType::Float32, static_shape([1]));
4319        let captured = graph.create_named_value("captured", DataType::Float32, static_shape([1]));
4320        graph.add_input(input);
4321        let node_id = graph.insert_node(Node::new(
4322            NodeId(0),
4323            "Relu",
4324            vec![Some(input)],
4325            vec![captured],
4326        ));
4327
4328        let mut subgraph = Graph::new();
4329        let capture = subgraph.create_named_value("captured", DataType::Float32, static_shape([1]));
4330        let output = subgraph.create_named_value("Y", DataType::Float32, static_shape([1]));
4331        subgraph.insert_node(Node::new(
4332            NodeId(0),
4333            "Relu",
4334            vec![Some(capture)],
4335            vec![output],
4336        ));
4337        subgraph.add_output(output);
4338        graph.subgraphs.insert((node_id, "body".into()), subgraph);
4339
4340        assert!(
4341            rule.check(&Model::new(graph), &ValidationContext::default())
4342                .is_empty()
4343        );
4344    }
4345
4346    #[test]
4347    fn dangling_node_output_is_flagged() {
4348        let rule = NoUnconnectedNodesRule;
4349        let mut graph = Graph::new();
4350        let input = graph.create_named_value("X", DataType::Float32, static_shape([1]));
4351        let dangling = graph.create_named_value("dangling", DataType::Float32, static_shape([1]));
4352        graph.add_input(input);
4353        let mut node = Node::new(NodeId(0), "Relu", vec![Some(input)], vec![dangling]);
4354        node.name = "relu".into();
4355        graph.insert_node(node);
4356
4357        let violations = rule.check(&Model::new(graph), &ValidationContext::default());
4358        assert_error(
4359            rule.id(),
4360            &violations,
4361            ViolationLocation::Node {
4362                graph_name: String::new(),
4363                node_name: "relu".into(),
4364            },
4365        );
4366    }
4367
4368    #[test]
4369    fn undefined_node_input_is_flagged() {
4370        let rule = NoUnconnectedNodesRule;
4371        let mut graph = Graph::new();
4372        let dangling = graph.create_named_value("dangling", DataType::Float32, static_shape([1]));
4373        let output = graph.create_named_value("Y", DataType::Float32, static_shape([1]));
4374        let mut node = Node::new(NodeId(0), "Relu", vec![Some(dangling)], vec![output]);
4375        node.name = "relu".into();
4376        graph.insert_node(node);
4377        graph.add_output(output);
4378
4379        let violations = rule.check(&Model::new(graph), &ValidationContext::default());
4380        assert_error(
4381            rule.id(),
4382            &violations,
4383            ViolationLocation::Node {
4384                graph_name: String::new(),
4385                node_name: "relu".into(),
4386            },
4387        );
4388    }
4389
4390    #[test]
4391    fn schema_type_constraints_accept_consistent_allowed_types() {
4392        let rule = TypeConstraintSatisfiedRule;
4393        let model = one_node_model("Add", 2, 1);
4394
4395        assert!(rule.check(&model, &ValidationContext::default()).is_empty());
4396    }
4397
4398    #[test]
4399    fn schema_type_constraints_skip_unknown_placeholder_types() {
4400        let rule = TypeConstraintSatisfiedRule;
4401        let mut graph = Graph::new();
4402        graph.opset_imports.insert(String::new(), 21);
4403        let input = graph.create_named_value("X", DataType::Int64, static_shape([1]));
4404        let output = graph.create_named_value("Y", DataType::Float32, Vec::new());
4405        graph.mark_value_type_unknown(output);
4406        graph.mark_value_shape_unknown(output);
4407        graph.add_input(input);
4408        graph.insert_node(Node::new(
4409            NodeId(0),
4410            "Identity",
4411            vec![Some(input)],
4412            vec![output],
4413        ));
4414        graph.add_output(output);
4415
4416        assert!(
4417            rule.check(&Model::new(graph), &ValidationContext::default())
4418                .is_empty()
4419        );
4420    }
4421
4422    #[test]
4423    fn schema_type_constraint_violation_is_flagged() {
4424        let rule = TypeConstraintSatisfiedRule;
4425        let mut graph = Graph::new();
4426        graph.opset_imports.insert(String::new(), 21);
4427        let input = graph.create_named_value("X", DataType::Int64, static_shape([1]));
4428        let output = graph.create_named_value("Y", DataType::Int64, static_shape([1]));
4429        graph.add_input(input);
4430        let mut node = Node::new(NodeId(0), "Relu", vec![Some(input)], vec![output]);
4431        node.name = "relu".into();
4432        graph.insert_node(node);
4433        graph.add_output(output);
4434
4435        let violations = rule.check(&Model::new(graph), &ValidationContext::default());
4436        assert!(
4437            violations.iter().all(|violation| {
4438                violation.rule_id == rule.id()
4439                    && violation.severity == Severity::Error
4440                    && violation.location
4441                        == ViolationLocation::Node {
4442                            graph_name: String::new(),
4443                            node_name: "relu".into(),
4444                        }
4445            }),
4446            "{violations:?}"
4447        );
4448        assert_eq!(violations.len(), 2);
4449    }
4450
4451    #[test]
4452    fn concrete_schema_input_type_is_enforced() {
4453        let mut graph = Graph::new();
4454        graph.opset_imports.insert(String::new(), 24);
4455        let data = graph.create_named_value("data", DataType::Float32, static_shape([2]));
4456        let shape = graph.create_named_value("shape", DataType::Float32, static_shape([1]));
4457        let output = graph.create_named_value("output", DataType::Float32, static_shape([2]));
4458        graph.add_input(data);
4459        graph.add_input(shape);
4460        graph.insert_node(Node::new(
4461            NodeId(0),
4462            "Reshape",
4463            vec![Some(data), Some(shape)],
4464            vec![output],
4465        ));
4466        graph.add_output(output);
4467        let mut model = Model::new(graph);
4468        let violations = TypeConstraintSatisfiedRule.check(&model, &ValidationContext::default());
4469        assert_eq!(violations.len(), 1, "{violations:?}");
4470        assert!(violations[0].message.contains("tensor(int64)"));
4471
4472        model.graph.value_mut(shape).dtype = DataType::Int64;
4473        assert!(
4474            TypeConstraintSatisfiedRule
4475                .check(&model, &ValidationContext::default())
4476                .is_empty()
4477        );
4478    }
4479
4480    #[test]
4481    fn matching_initializer_type_passes() {
4482        let rule = InitializerTypeMatchesDeclaredRule;
4483        let mut graph = Graph::new();
4484        let value = graph.create_named_value("W", DataType::Float32, static_shape([1]));
4485        graph.set_initializer(
4486            value,
4487            WeightRef::Inline(TensorData::from_raw(DataType::Float32, vec![1], vec![0; 4])),
4488        );
4489
4490        assert!(
4491            rule.check(&Model::new(graph), &ValidationContext::default())
4492                .is_empty()
4493        );
4494    }
4495
4496    #[test]
4497    fn initializer_symbolic_and_unknown_declared_shapes_pass() {
4498        let rule = InitializerTypeMatchesDeclaredRule;
4499        let mut graph = Graph::new();
4500        let batch = graph.create_symbol(Some("batch".into()));
4501        let symbolic = graph.create_named_value(
4502            "symbolic",
4503            DataType::Float32,
4504            vec![Dim::Symbolic(batch), Dim::Static(3)],
4505        );
4506        graph.set_initializer(
4507            symbolic,
4508            WeightRef::Inline(TensorData::from_raw(
4509                DataType::Float32,
4510                vec![2, 3],
4511                vec![0; 24],
4512            )),
4513        );
4514        let unknown = graph.create_named_value("unknown", DataType::Float32, Vec::new());
4515        graph.mark_value_shape_unknown(unknown);
4516        graph.set_initializer(
4517            unknown,
4518            WeightRef::Inline(TensorData::from_raw(
4519                DataType::Float32,
4520                vec![4],
4521                vec![0; 16],
4522            )),
4523        );
4524
4525        assert!(
4526            rule.check(&Model::new(graph), &ValidationContext::default())
4527                .is_empty()
4528        );
4529    }
4530
4531    #[test]
4532    fn mismatched_initializer_shape_is_flagged() {
4533        let rule = InitializerTypeMatchesDeclaredRule;
4534        for dims in [vec![2, 4], vec![2, 3, 1]] {
4535            let mut graph = Graph::new();
4536            let value = graph.create_named_value("W", DataType::Float32, static_shape([2, 3]));
4537            graph.set_initializer(
4538                value,
4539                WeightRef::Inline(TensorData::from_raw(
4540                    DataType::Float32,
4541                    dims.clone(),
4542                    Vec::new(),
4543                )),
4544            );
4545
4546            let violations = rule.check(&Model::new(graph), &ValidationContext::default());
4547            assert_error(
4548                rule.id(),
4549                &violations,
4550                ViolationLocation::Value {
4551                    value_name: "W".into(),
4552                },
4553            );
4554        }
4555    }
4556
4557    #[test]
4558    fn mismatched_initializer_type_is_flagged() {
4559        let rule = InitializerTypeMatchesDeclaredRule;
4560        let mut graph = Graph::new();
4561        let value = graph.create_named_value("W", DataType::Float32, static_shape([1]));
4562        graph.set_initializer(
4563            value,
4564            WeightRef::Inline(TensorData::from_raw(DataType::Int64, vec![1], vec![0; 8])),
4565        );
4566
4567        let violations = rule.check(&Model::new(graph), &ValidationContext::default());
4568        assert_error(
4569            rule.id(),
4570            &violations,
4571            ViolationLocation::Value {
4572                value_name: "W".into(),
4573            },
4574        );
4575    }
4576
4577    #[test]
4578    fn supported_ir_versions_pass_and_future_version_fails() {
4579        let rule = IrVersionSupportedRule;
4580        for ir_version in [1, 10, 13] {
4581            let mut model = Model::new(Graph::new());
4582            model.metadata.ir_version = ir_version;
4583            assert!(
4584                rule.check(&model, &ValidationContext::default()).is_empty(),
4585                "ir_version {ir_version}"
4586            );
4587        }
4588        let mut model = Model::new(Graph::new());
4589        model.metadata.ir_version = 14;
4590        let violations = rule.check(&model, &ValidationContext::default());
4591        assert_eq!(violations.len(), 1);
4592        assert!(violations[0].message.contains("newer than"));
4593    }
4594
4595    #[test]
4596    fn absent_ir_version_is_flagged() {
4597        let rule = IrVersionSupportedRule;
4598        let mut model = Model::new(Graph::new());
4599        model.metadata.ir_version = 0;
4600
4601        let violations = rule.check(&model, &ValidationContext::default());
4602        assert_error(rule.id(), &violations, ViolationLocation::Model);
4603    }
4604
4605    #[test]
4606    fn ir_feature_rule_checks_opset_initializer_and_dtype_gates() {
4607        let rule = IrVersionFeatureRule;
4608        let model = retained_model_at(
4609            ModelProto {
4610                opset_import: vec![OperatorSetIdProto {
4611                    domain: String::new(),
4612                    version: 1,
4613                }],
4614                ..Default::default()
4615            },
4616            2,
4617        );
4618        assert!(
4619            rule.check(&model, &ValidationContext::default())
4620                .iter()
4621                .any(|violation| violation.message.contains("must not specify opset_import"))
4622        );
4623
4624        let initializer = TensorProto {
4625            dims: vec![1],
4626            data_type: tensor_proto::DataType::Float as i32,
4627            float_data: vec![1.0],
4628            name: "W".into(),
4629            ..Default::default()
4630        };
4631        let model = retained_model_at(
4632            ModelProto {
4633                graph: Some(GraphProto {
4634                    name: "graph".into(),
4635                    initializer: vec![initializer],
4636                    ..Default::default()
4637                }),
4638                ..Default::default()
4639            },
4640            3,
4641        );
4642        assert!(
4643            rule.check(&model, &ValidationContext::default())
4644                .iter()
4645                .any(|violation| violation.message.contains("must also be a graph input"))
4646        );
4647
4648        let model = retained_model_at(
4649            ModelProto {
4650                graph: Some(GraphProto {
4651                    name: "graph".into(),
4652                    initializer: vec![TensorProto {
4653                        dims: vec![1],
4654                        data_type: tensor_proto::DataType::Uint2 as i32,
4655                        raw_data: vec![0],
4656                        name: "packed".into(),
4657                        ..Default::default()
4658                    }],
4659                    ..Default::default()
4660                }),
4661                ..Default::default()
4662            },
4663            12,
4664        );
4665        assert!(
4666            rule.check(&model, &ValidationContext::default())
4667                .iter()
4668                .any(|violation| violation.message.contains("requires IR version 13"))
4669        );
4670    }
4671
4672    #[test]
4673    fn ir_feature_rule_accepts_model_metadata_before_ir10() {
4674        let model = retained_model_at(
4675            ModelProto {
4676                metadata_props: vec![StringStringEntryProto {
4677                    key: "owner".into(),
4678                    value: "onnx".into(),
4679                }],
4680                ..Default::default()
4681            },
4682            7,
4683        );
4684        let violations = IrVersionFeatureRule.check(&model, &ValidationContext::default());
4685        assert!(violations.is_empty(), "{violations:?}");
4686    }
4687
4688    #[test]
4689    fn function_rule_accepts_valid_signature_and_body() {
4690        let model = retained_model(ModelProto {
4691            functions: vec![function(
4692                "Pass",
4693                vec![NodeProto {
4694                    input: vec!["X".into()],
4695                    output: vec!["Y".into()],
4696                    op_type: "Identity".into(),
4697                    ..Default::default()
4698                }],
4699            )],
4700            ..Default::default()
4701        });
4702        let violations = FunctionProtoValidityRule.check(&model, &ValidationContext::default());
4703        assert!(violations.is_empty(), "{violations:?}");
4704    }
4705
4706    #[test]
4707    fn function_rule_accepts_empty_default_domain() {
4708        let mut default_domain = function(
4709            "Pass",
4710            vec![NodeProto {
4711                input: vec!["X".into()],
4712                output: vec!["Y".into()],
4713                op_type: "Identity".into(),
4714                ..Default::default()
4715            }],
4716        );
4717        default_domain.domain.clear();
4718        let model = retained_model(ModelProto {
4719            functions: vec![default_domain],
4720            ..Default::default()
4721        });
4722        let violations = FunctionProtoValidityRule.check(&model, &ValidationContext::default());
4723        assert!(violations.is_empty(), "{violations:?}");
4724    }
4725
4726    #[test]
4727    fn function_rule_checks_defaults_topology_imports_and_unique_ids() {
4728        let mut invalid = function(
4729            "Broken",
4730            vec![NodeProto {
4731                input: vec!["missing".into()],
4732                output: vec!["Y".into()],
4733                op_type: "Identity".into(),
4734                ..Default::default()
4735            }],
4736        );
4737        invalid.attribute = vec!["alpha".into()];
4738        invalid.attribute_proto = vec![AttributeProto {
4739            name: "alpha".into(),
4740            r#type: attribute_proto::AttributeType::Float as i32,
4741            ..Default::default()
4742        }];
4743        invalid.opset_import.clear();
4744        let model = retained_model(ModelProto {
4745            functions: vec![invalid.clone(), invalid],
4746            ..Default::default()
4747        });
4748        let violations = FunctionProtoValidityRule.check(&model, &ValidationContext::default());
4749        for expected in [
4750            "is not unique",
4751            "both attribute and attribute_proto",
4752            "neither a function/graph input",
4753            "no opset import",
4754        ] {
4755            assert!(
4756                violations
4757                    .iter()
4758                    .any(|violation| violation.message.contains(expected)),
4759                "{expected}: {violations:?}"
4760            );
4761        }
4762    }
4763
4764    #[test]
4765    fn function_rule_rejects_undeclared_attribute_refs_and_recursion() {
4766        let make_call = |callee: &str| NodeProto {
4767            input: vec!["X".into()],
4768            output: vec!["Y".into()],
4769            op_type: callee.into(),
4770            domain: "local.test".into(),
4771            attribute: vec![AttributeProto {
4772                name: "alpha".into(),
4773                ref_attr_name: "undeclared".into(),
4774                r#type: attribute_proto::AttributeType::Float as i32,
4775                ..Default::default()
4776            }],
4777            ..Default::default()
4778        };
4779        let mut first = function("First", vec![make_call("Second")]);
4780        let mut second = function("Second", vec![make_call("First")]);
4781        for function in [&mut first, &mut second] {
4782            function.opset_import.push(OperatorSetIdProto {
4783                domain: "local.test".into(),
4784                version: 1,
4785            });
4786        }
4787        let model = retained_model(ModelProto {
4788            functions: vec![first, second],
4789            ..Default::default()
4790        });
4791        let violations = FunctionProtoValidityRule.check(&model, &ValidationContext::default());
4792        assert!(
4793            violations
4794                .iter()
4795                .any(|violation| violation.message.contains("undeclared function attribute"))
4796        );
4797        assert!(
4798            violations
4799                .iter()
4800                .any(|violation| violation.message.contains("are recursive"))
4801        );
4802    }
4803
4804    #[test]
4805    fn function_rule_accepts_consistent_call_with_omitted_default_attribute() {
4806        let mut callee = function(
4807            "Affine",
4808            vec![NodeProto {
4809                input: vec!["X".into()],
4810                output: vec!["Y".into()],
4811                op_type: "Identity".into(),
4812                ..Default::default()
4813            }],
4814        );
4815        callee.attribute = vec!["alpha".into()];
4816        callee.attribute_proto = vec![AttributeProto {
4817            name: "beta".into(),
4818            r#type: attribute_proto::AttributeType::Float as i32,
4819            f: 1.0,
4820            ..Default::default()
4821        }];
4822        let model = local_function_call_model(
4823            callee,
4824            NodeProto {
4825                input: vec!["X".into()],
4826                output: vec!["Y".into()],
4827                op_type: "Affine".into(),
4828                domain: "local.test".into(),
4829                attribute: vec![AttributeProto {
4830                    name: "alpha".into(),
4831                    r#type: attribute_proto::AttributeType::Float as i32,
4832                    ..Default::default()
4833                }],
4834                ..Default::default()
4835            },
4836        );
4837        let violations = FunctionProtoValidityRule.check(&model, &ValidationContext::default());
4838        assert!(violations.is_empty(), "{violations:?}");
4839    }
4840
4841    #[test]
4842    fn function_rule_accepts_call_site_arity_and_attribute_mismatches() {
4843        let callee = function(
4844            "Affine",
4845            vec![NodeProto {
4846                input: vec!["X".into()],
4847                output: vec!["Y".into()],
4848                op_type: "Identity".into(),
4849                ..Default::default()
4850            }],
4851        );
4852        let mut caller = function(
4853            "Caller",
4854            vec![NodeProto {
4855                input: vec!["X".into(), "X".into()],
4856                output: vec!["Y".into(), "unused".into()],
4857                op_type: "Affine".into(),
4858                domain: "local.test".into(),
4859                attribute: vec![AttributeProto {
4860                    name: "gamma".into(),
4861                    r#type: attribute_proto::AttributeType::Float as i32,
4862                    ..Default::default()
4863                }],
4864                ..Default::default()
4865            }],
4866        );
4867        caller.opset_import.push(OperatorSetIdProto {
4868            domain: "local.test".into(),
4869            version: 1,
4870        });
4871        let mut callee = callee;
4872        callee.attribute = vec!["alpha".into()];
4873        callee.attribute_proto = vec![AttributeProto {
4874            name: "beta".into(),
4875            r#type: attribute_proto::AttributeType::Float as i32,
4876            f: 1.0,
4877            ..Default::default()
4878        }];
4879        let model = retained_model(ModelProto {
4880            functions: vec![callee, caller],
4881            ..Default::default()
4882        });
4883        let violations = FunctionProtoValidityRule.check(&model, &ValidationContext::default());
4884        assert!(violations.is_empty(), "{violations:?}");
4885    }
4886
4887    #[test]
4888    fn missing_opset_import_is_flagged() {
4889        let mut g = Graph::new();
4890        // Deliberately declare no opset import.
4891        let x = g.create_named_value("X", DataType::Float32, static_shape([2]));
4892        let y = g.create_named_value("Y", DataType::Float32, static_shape([2]));
4893        g.add_input(x);
4894        let mut node = Node::new(NodeId(0), "Relu", vec![Some(x)], vec![y]);
4895        node.name = "r".to_string();
4896        g.insert_node(node);
4897        g.add_output(y);
4898
4899        let result = Model::new(g).validate();
4900        assert!(!result.is_valid());
4901        assert!(
4902            result
4903                .violations
4904                .iter()
4905                .any(|v| v.rule_id == "ir.opset_import_present")
4906        );
4907    }
4908
4909    #[test]
4910    fn present_opset_import_passes() {
4911        let mut g = Graph::new();
4912        g.opset_imports.insert(String::new(), 21);
4913        let x = g.create_named_value("X", DataType::Float32, static_shape([2]));
4914        let y = g.create_named_value("Y", DataType::Float32, static_shape([2]));
4915        g.add_input(x);
4916        let node = Node::new(NodeId(0), "Relu", vec![Some(x)], vec![y]);
4917        g.insert_node(node);
4918        g.add_output(y);
4919
4920        let result = Model::new(g).validate();
4921        assert!(result.is_valid(), "{:?}", result.violations);
4922    }
4923
4924    #[test]
4925    fn duplicate_value_name_is_flagged() {
4926        let rule = DuplicateValueNameRule;
4927        let mut g = Graph::new();
4928        g.opset_imports.insert(String::new(), 21);
4929        // Two distinct values that share the name "dup".
4930        g.create_named_value("dup", DataType::Float32, static_shape([1]));
4931        g.create_named_value("dup", DataType::Float32, static_shape([1]));
4932        let model = Model::new(g);
4933        let violations = rule.check(&model, &ValidationContext::default());
4934        assert_eq!(violations.len(), 1);
4935        assert_eq!(
4936            violations[0].location,
4937            ViolationLocation::Value {
4938                value_name: "dup".to_string()
4939            }
4940        );
4941    }
4942
4943    #[test]
4944    fn duplicate_value_name_inside_subgraph_is_flagged() {
4945        let mut subgraph = Graph::new();
4946        subgraph.create_named_value("nested_dup", DataType::Float32, static_shape([1]));
4947        subgraph.create_named_value("nested_dup", DataType::Float32, static_shape([1]));
4948
4949        let result = nested_model(subgraph).validate();
4950        assert!(result.violations.iter().any(|violation| {
4951            violation.rule_id == "structure.duplicate_value_name"
4952                && violation.location
4953                    == ViolationLocation::Value {
4954                        value_name: "nested_dup".into(),
4955                    }
4956        }));
4957    }
4958
4959    #[test]
4960    fn missing_opset_import_inside_subgraph_is_flagged() {
4961        let mut subgraph = Graph::new();
4962        let x = subgraph.create_named_value("x", DataType::Float32, static_shape([1]));
4963        let y = subgraph.create_named_value("y", DataType::Float32, static_shape([1]));
4964        subgraph.add_input(x);
4965        let mut node = Node::new(NodeId(0), "Custom", vec![Some(x)], vec![y]);
4966        node.domain = "example.custom".into();
4967        subgraph.insert_node(node);
4968        subgraph.add_output(y);
4969
4970        let result = nested_model(subgraph).validate();
4971        assert!(
4972            result
4973                .violations
4974                .iter()
4975                .any(|violation| violation.rule_id == "ir.opset_import_present")
4976        );
4977    }
4978
4979    #[test]
4980    fn valid_nested_model_passes() {
4981        let mut subgraph = Graph::new();
4982        let x = subgraph.create_named_value("x", DataType::Float32, static_shape([1]));
4983        let y = subgraph.create_named_value("y", DataType::Float32, static_shape([1]));
4984        subgraph.add_input(x);
4985        subgraph.insert_node(Node::new(NodeId(0), "Relu", vec![Some(x)], vec![y]));
4986        subgraph.add_output(y);
4987
4988        let result = nested_model(subgraph).validate();
4989        assert!(result.is_valid(), "{:?}", result.violations);
4990    }
4991
4992    #[test]
4993    fn if_requires_else_branch() {
4994        let result = if_model(Graph::new(), 1, false).validate();
4995        assert!(result.violations.iter().any(|violation| {
4996            violation.rule_id == "schema.node_conforms"
4997                && violation
4998                    .message
4999                    .contains("required attribute 'else_branch'")
5000        }));
5001    }
5002
5003    #[test]
5004    fn if_variadic_outputs_require_at_least_one_value() {
5005        let result = if_model(Graph::new(), 0, true).validate();
5006        assert!(result.violations.iter().any(|violation| {
5007            violation.rule_id == "schema.node_conforms"
5008                && violation
5009                    .message
5010                    .contains("has 0 outputs but schema requires at least 1")
5011        }));
5012
5013        let result = if_model(Graph::new(), 1, true).validate();
5014        assert!(result.is_valid(), "{:?}", result.violations);
5015    }
5016
5017    #[test]
5018    fn schema_rule_accepts_conforming_node() {
5019        let result = one_node_model("Add", 2, 1).validate();
5020        assert!(result.is_valid(), "{:?}", result.violations);
5021    }
5022
5023    #[test]
5024    fn round_four_optional_inputs_and_attributes_may_be_omitted() {
5025        for (op_type, inputs) in [
5026            ("ReduceMax", 1),
5027            ("ReduceMin", 1),
5028            ("ReduceProd", 1),
5029            ("ReduceL1", 1),
5030            ("ReduceL2", 1),
5031            ("ReduceLogSum", 1),
5032            ("ReduceLogSumExp", 1),
5033            ("ReduceSumSquare", 1),
5034            ("ArgMax", 1),
5035            ("ArgMin", 1),
5036            ("LogSoftmax", 1),
5037            ("RMSNormalization", 2),
5038        ] {
5039            let mut model = one_node_model(op_type, inputs, 1);
5040            model.graph.opset_imports.insert(String::new(), 24);
5041            if matches!(op_type, "ArgMax" | "ArgMin") {
5042                let output = model.graph.outputs[0];
5043                model.graph.value_mut(output).dtype = DataType::Int64;
5044            }
5045            let result = model.validate();
5046            assert!(
5047                result.is_valid(),
5048                "{op_type}: optional fields should be omittable: {:?}",
5049                result.violations
5050            );
5051        }
5052    }
5053
5054    #[test]
5055    fn round_five_schemas_accept_official_minimal_forms() {
5056        for (op_type, inputs, outputs, int64_inputs, bool_inputs, int64_outputs, bool_outputs) in [
5057            ("GatherElements", 2, 1, vec![1], vec![], vec![], vec![]),
5058            ("GatherND", 2, 1, vec![1], vec![], vec![], vec![]),
5059            ("Equal", 2, 1, vec![], vec![], vec![], vec![0]),
5060            ("Greater", 2, 1, vec![], vec![], vec![], vec![0]),
5061            ("Less", 2, 1, vec![], vec![], vec![], vec![0]),
5062            ("And", 2, 1, vec![], vec![0, 1], vec![], vec![0]),
5063            ("Or", 2, 1, vec![], vec![0, 1], vec![], vec![0]),
5064            ("Not", 1, 1, vec![], vec![0], vec![], vec![0]),
5065            ("Shape", 1, 1, vec![], vec![], vec![0], vec![]),
5066            ("Size", 1, 1, vec![], vec![], vec![0], vec![]),
5067            ("NonZero", 1, 1, vec![], vec![], vec![0], vec![]),
5068            ("Range", 3, 1, vec![], vec![], vec![], vec![]),
5069            ("Split", 1, 2, vec![], vec![], vec![], vec![]),
5070        ] {
5071            let mut model = one_node_model(op_type, inputs, outputs);
5072            model.graph.opset_imports.insert(String::new(), 25);
5073            for index in int64_inputs {
5074                let value = model.graph.inputs[index];
5075                model.graph.value_mut(value).dtype = DataType::Int64;
5076            }
5077
5078            for index in bool_inputs {
5079                let value = model.graph.inputs[index];
5080                model.graph.value_mut(value).dtype = DataType::Bool;
5081            }
5082            for index in int64_outputs {
5083                let value = model.graph.outputs[index];
5084                model.graph.value_mut(value).dtype = DataType::Int64;
5085            }
5086            for index in bool_outputs {
5087                let value = model.graph.outputs[index];
5088                model.graph.value_mut(value).dtype = DataType::Bool;
5089            }
5090
5091            let result = model.validate();
5092            assert!(
5093                result.is_valid(),
5094                "{op_type}: optional fields should be omittable: {:?}",
5095                result.violations
5096            );
5097        }
5098
5099        let mut cast = one_node_model("Cast", 1, 1);
5100        cast.graph.opset_imports.insert(String::new(), 25);
5101        let output = cast.graph.outputs[0];
5102        cast.graph.value_mut(output).dtype = DataType::Int64;
5103        let node = cast.graph.nodes.keys().next().unwrap();
5104        cast.graph.node_mut(node).attributes.insert(
5105            "to".into(),
5106            Attribute::Int(i64::from(DataType::Int64.to_onnx())),
5107        );
5108        assert!(cast.validate().is_valid());
5109
5110        cast.graph.node_mut(node).attributes.remove("to");
5111        assert!(cast.validate().violations.iter().any(|violation| {
5112            violation.rule_id == "schema.node_conforms"
5113                && violation.message.contains("required attribute 'to'")
5114        }));
5115    }
5116
5117    #[test]
5118    fn round_six_schemas_accept_official_minimal_forms() {
5119        for (op_type, inputs, int64_inputs) in [
5120            ("Tile", 2, vec![1]),
5121            ("Pad", 2, vec![1]),
5122            ("ScatterND", 3, vec![1]),
5123            ("ScatterElements", 3, vec![1]),
5124            ("ConstantOfShape", 1, vec![0]),
5125        ] {
5126            let mut model = one_node_model(op_type, inputs, 1);
5127            model.graph.opset_imports.insert(String::new(), 25);
5128            for index in int64_inputs {
5129                let value = model.graph.inputs[index];
5130                model.graph.value_mut(value).dtype = DataType::Int64;
5131            }
5132            let result = model.validate();
5133            assert!(
5134                result.is_valid(),
5135                "{op_type}: official minimal form should pass: {:?}",
5136                result.violations
5137            );
5138        }
5139
5140        let mut pad_with_axes = one_node_model("Pad", 4, 1);
5141        pad_with_axes.graph.opset_imports.insert(String::new(), 25);
5142        let pads = pad_with_axes.graph.inputs[1];
5143        let axes = pad_with_axes.graph.inputs[3];
5144        pad_with_axes.graph.value_mut(pads).dtype = DataType::Int64;
5145        pad_with_axes.graph.value_mut(axes).dtype = DataType::Int32;
5146        let node = pad_with_axes.graph.nodes.keys().next().unwrap();
5147        pad_with_axes.graph.replace_input(node, 2, None);
5148        assert!(
5149            pad_with_axes.validate().is_valid(),
5150            "optional constant_value may be omitted while axes is present"
5151        );
5152    }
5153
5154    #[test]
5155    fn round_seven_schemas_accept_official_minimal_forms() {
5156        for op_type in ["MaxPool", "AveragePool"] {
5157            let mut model = one_node_model(op_type, 1, 1);
5158            model.graph.opset_imports.insert(String::new(), 25);
5159            let node = model.graph.nodes.keys().next().unwrap();
5160            model
5161                .graph
5162                .node_mut(node)
5163                .attributes
5164                .insert("kernel_shape".into(), Attribute::Ints(vec![1]));
5165            assert!(
5166                model.validate().is_valid(),
5167                "{op_type}: only kernel_shape is required"
5168            );
5169        }
5170
5171        for op_type in ["GlobalAveragePool", "GlobalMaxPool"] {
5172            let mut model = one_node_model(op_type, 1, 1);
5173            model.graph.opset_imports.insert(String::new(), 25);
5174            assert!(model.validate().is_valid(), "{op_type}");
5175        }
5176
5177        let mut max_pool_indices = one_node_model("MaxPool", 1, 2);
5178        max_pool_indices
5179            .graph
5180            .opset_imports
5181            .insert(String::new(), 25);
5182        let node = max_pool_indices.graph.nodes.keys().next().unwrap();
5183        max_pool_indices
5184            .graph
5185            .node_mut(node)
5186            .attributes
5187            .insert("kernel_shape".into(), Attribute::Ints(vec![1]));
5188        let indices = max_pool_indices.graph.outputs[1];
5189        max_pool_indices.graph.value_mut(indices).dtype = DataType::Int64;
5190        assert!(max_pool_indices.validate().is_valid());
5191
5192        let mut resize = one_node_model("Resize", 1, 1);
5193        resize.graph.opset_imports.insert(String::new(), 25);
5194        assert!(
5195            resize.validate().is_valid(),
5196            "the official checker schema does not require either optional input"
5197        );
5198
5199        let mut quantize = one_node_model("QuantizeLinear", 2, 1);
5200        quantize.graph.opset_imports.insert(String::new(), 25);
5201        let output = quantize.graph.outputs[0];
5202        quantize.graph.value_mut(output).dtype = DataType::Uint8;
5203        assert!(
5204            quantize.validate().is_valid(),
5205            "zero_point must remain optional"
5206        );
5207
5208        let mut dequantize = one_node_model("DequantizeLinear", 2, 1);
5209        dequantize.graph.opset_imports.insert(String::new(), 25);
5210        let input = dequantize.graph.inputs[0];
5211        dequantize.graph.value_mut(input).dtype = DataType::Uint8;
5212        assert!(
5213            dequantize.validate().is_valid(),
5214            "zero_point must remain optional"
5215        );
5216
5217        let mut dynamic = one_node_model("DynamicQuantizeLinear", 1, 3);
5218        dynamic.graph.opset_imports.insert(String::new(), 25);
5219        for output in [dynamic.graph.outputs[0], dynamic.graph.outputs[2]] {
5220            dynamic.graph.value_mut(output).dtype = DataType::Uint8;
5221        }
5222        assert!(dynamic.validate().is_valid());
5223    }
5224
5225    #[test]
5226    fn common_builtin_arity_boundaries_pass() {
5227        for (op_type, inputs, outputs) in [
5228            ("MatMul", 2, 1),
5229            ("Gemm", 2, 1),
5230            ("Gemm", 3, 1),
5231            ("Add", 2, 1),
5232            ("Relu", 1, 1),
5233            ("Conv", 2, 1),
5234            ("Conv", 3, 1),
5235            ("Mul", 2, 1),
5236            ("Identity", 1, 1),
5237        ] {
5238            let result = one_node_model(op_type, inputs, outputs).validate();
5239            assert!(
5240                result.is_valid(),
5241                "{op_type}({inputs}, {outputs}): {:?}",
5242                result.violations
5243            );
5244        }
5245    }
5246
5247    #[test]
5248    fn schema_rule_checks_arity_required_and_typed_attributes() {
5249        let mut model = one_node_model("Gemm", 1, 2);
5250        let node_id = model.graph.nodes.iter().next().unwrap().0;
5251        let node = model.graph.node_mut(node_id);
5252        node.attributes.insert("alpha".into(), Attribute::Int(1));
5253        node.attributes.insert("unknown".into(), Attribute::Int(1));
5254        let result = model.validate();
5255        let messages = result
5256            .violations
5257            .iter()
5258            .filter(|violation| violation.rule_id == "schema.node_conforms")
5259            .map(|violation| violation.message.as_str())
5260            .collect::<Vec<_>>();
5261        assert!(messages.iter().any(|message| message.contains("1 inputs")));
5262        assert!(messages.iter().any(|message| message.contains("2 outputs")));
5263        assert!(
5264            messages
5265                .iter()
5266                .any(|message| message.contains("requires Float"))
5267        );
5268        assert!(
5269            messages
5270                .iter()
5271                .any(|message| message.contains("not declared"))
5272        );
5273    }
5274
5275    #[test]
5276    fn schema_rule_checks_required_attributes_and_opset_range() {
5277        let yaml = r#"
5278domain: ""
5279name: NeedsAxis
5280since_version: 13
5281until_version: 20
5282attributes:
5283  - { name: axis, type: int, required: true }
5284inputs: [{ name: X, type_str: T }]
5285outputs: [{ name: Y, type_str: T }]
5286"#;
5287        let mut schemas = SchemaRegistry::new();
5288        schemas.load_yaml(yaml).unwrap();
5289        let mut model = one_node_model("NeedsAxis", 1, 1);
5290        model.graph.opset_imports.insert(String::new(), 13);
5291        let checker = super::super::OnnxChecker::with_schema_registry(schemas.clone());
5292        let result = checker.check(&model);
5293        assert!(result.violations.iter().any(|violation| {
5294            violation.rule_id == "schema.node_conforms"
5295                && violation.message.contains("required attribute 'axis'")
5296        }));
5297
5298        model.graph.opset_imports.insert(String::new(), 21);
5299        let result = super::super::OnnxChecker::with_schema_registry(schemas).check(&model);
5300        assert!(result.violations.iter().any(|violation| {
5301            violation.rule_id == "schema.node_conforms"
5302                && violation.message.contains("no schema valid at opset 21")
5303        }));
5304    }
5305
5306    #[test]
5307    fn schema_rule_reports_unregistered_operator() {
5308        let result = one_node_model("NotARealOp", 1, 1).validate();
5309        assert!(result.violations.iter().any(|violation| {
5310            violation.rule_id == "schema.node_conforms"
5311                && violation
5312                    .message
5313                    .contains("not present in the schema registry")
5314        }));
5315    }
5316}