Skip to main content

onnx_runtime_loader/
lib.rs

1//! # `onnx-runtime-loader`
2//!
3//! Loads ONNX models from disk into the [`onnx_runtime_ir::Graph`] IR
4//! (see `docs/architecture/ORT2.md` §19).
5//!
6//! Pipeline ([`load_model`] / [`load_model_with_weights`]):
7//! 1. [`proto`] — decode the ONNX protobuf (`prost` types generated from the
8//!    vendored `onnx.proto3`) into a `ModelProto`.
9//! 2. [`graph_builder`] — build an [`onnx_runtime_ir::Graph`] (nodes, values,
10//!    symbolic dim interning, opset imports), upholding the §3.5 invariants.
11//! 3. [`weights`] — resolve inline and external initializer data (external
12//!    files are memory-mapped into a [`WeightStore`]).
13//! 4. Static/symbolic shape inference via
14//!    [`onnx-runtime-shape-inference`](onnx_runtime_shape_inference): the loader
15//!    owns the "loader = shape-inference" seam, so after the [`Graph`] is built
16//!    (with initializers applied) it runs the extensible per-op registry to
17//!    populate every value's shape and dtype. Values that cannot be resolved
18//!    statically (genuinely data-dependent extents) are left symbolic for the
19//!    session to resolve just-in-time.
20//!
21//! ## Obtaining weight bytes at session time
22//!
23//! Use [`load_model_with_weights`] (or [`load_model_bytes_with_weights`]) to
24//! receive both the [`Graph`] and an [`Arc<WeightStore>`]. Then, given any
25//! [`onnx_runtime_ir::WeightRef`] stored in `graph.initializers`, call
26//! [`WeightStore::bytes`] to get the raw little-endian byte slice:
27//!
28//! ```ignore
29//! let (graph, store) = load_model_with_weights("model.onnx")?;
30//! for (vid, weight_ref) in &graph.initializers {
31//!     let bytes: &[u8] = store.bytes(weight_ref).expect("weight bytes live");
32//!     // ... hand bytes to a kernel
33//! }
34//! ```
35//!
36//! The `Arc` keeps all memory maps alive as long as any clone of it exists, so
37//! kernel dispatch can store `Arc<WeightStore>` alongside the `Graph` without
38//! lifetime coupling.
39
40use std::path::Path;
41use std::sync::Arc;
42
43use onnx_runtime_ir::{Graph, WeightRef};
44use onnx_runtime_shape_inference::{InferenceRegistry, MergePolicy};
45use onnx_runtime_tracer::{Args, SpanGuard};
46
47use crate::graph_builder::BuiltGraph;
48
49pub mod encoder;
50pub mod epcontext;
51pub mod function_inline;
52pub(crate) mod graph_builder;
53pub mod proto;
54pub mod weights;
55pub mod writer;
56
57mod pathsafe;
58
59pub use encoder::{
60    DEFAULT_IR_VERSION, DEFAULT_OPSET_VERSION, Model, ModelMetadata, encode_model,
61    encode_model_proto, write_model,
62};
63pub use epcontext::{
64    EmbedMode, EpContextBlob, EpContextNode, ep_context_node_ids, ep_context_nodes,
65    is_ep_context_op, resolve_ep_context,
66};
67pub use error::LoaderError;
68pub use weights::{
69    ExpertQuantization, ExpertStorageOrder, ExpertTensorLayout, ExpertWeightRegion,
70    NonPageableReason, Pageability, WeightRegionCatalog, WeightStore, qmoe_expert_tensor_layout,
71};
72pub use writer::{EpContextDumpConfig, EpContextPartition, dump_ep_context};
73
74fn trace_span(name: &'static str, cat: &'static str) -> Option<SpanGuard> {
75    onnx_runtime_tracer::global_context()
76        .filter(|trace| trace.is_enabled())
77        .map(|trace| trace.span(name, cat))
78}
79
80mod error;
81
82/// Load a model from a filesystem path, producing a fully-built [`Graph`].
83///
84/// Runs the full pipeline: parse → build → load weights → shape inference.
85/// External initializer data is resolved relative to the model file's
86/// directory.
87///
88/// # Note on external weights
89///
90/// The returned `Graph` stores [`onnx_runtime_ir::WeightRef::External`]
91/// descriptors (path / offset / length) for weights held in external data
92/// files, but the memory maps that back those bytes are **dropped** when this
93/// function returns. Callers that need to read external weight bytes must
94/// either re-map the files themselves or use [`load_model_with_weights`] which
95/// keeps the maps alive via the returned [`Arc<WeightStore>`].
96pub fn load_model(path: impl AsRef<Path>) -> Result<Graph, LoaderError> {
97    Ok(load_model_with_weights(path)?.0)
98}
99
100/// Load a model from an in-memory protobuf buffer, producing a [`Graph`].
101///
102/// External initializer data (if any) is resolved relative to the current
103/// working directory.
104///
105/// # Note on external weights
106///
107/// Same caveat as [`load_model`]: external weight bytes are not accessible
108/// from the returned `Graph` alone. Use [`load_model_bytes_with_weights`] to
109/// keep them live.
110pub fn load_model_bytes(bytes: &[u8]) -> Result<Graph, LoaderError> {
111    Ok(load_model_bytes_with_weights(bytes, Path::new("."))?.0)
112}
113
114/// Load a model from a filesystem path, returning both the [`Graph`] and the
115/// live [`WeightStore`] that backs all initializer data.
116///
117/// The [`Arc<WeightStore>`] keeps every external-data memory map alive for as
118/// long as any clone of the `Arc` exists. At session time, given a
119/// [`onnx_runtime_ir::WeightRef`] from `graph.initializers`, call
120/// [`WeightStore::bytes`] to obtain the raw little-endian byte slice — this
121/// works for both [`WeightRef::Inline`] and [`WeightRef::External`] weights.
122///
123/// External initializer data is resolved relative to the model file's
124/// directory.
125pub fn load_model_with_weights(
126    path: impl AsRef<Path>,
127) -> Result<(Graph, Arc<WeightStore>), LoaderError> {
128    let path = path.as_ref();
129    let bytes = read_model_binary(path)?;
130    let model_dir = path.parent().unwrap_or_else(|| Path::new("."));
131    build_from_bytes_with_weights(&bytes, model_dir, None)
132}
133
134/// Read a model file into the binary protobuf bytes of its `ModelProto`.
135///
136/// Detection is by filename suffix: a path ending in `.textproto` is parsed as
137/// ONNX protobuf **TextFormat** and converted to the binary wire encoding (see
138/// [`proto::textproto_to_binary`]); any other path is read verbatim as an
139/// already-binary `.onnx` model. This is the single seam that lets every
140/// path-based loader entry accept git-friendly textproto fixtures while keeping
141/// binary `.onnx` loading unchanged.
142///
143/// Path-based callers still pass this file's parent directory to the weight
144/// loader, so TextFormat graphs may reference external initializer data just
145/// like binary ONNX graphs.
146pub fn read_model_binary(path: impl AsRef<Path>) -> Result<Vec<u8>, LoaderError> {
147    let path = path.as_ref();
148    let mut span = trace_span("load.read_model_binary", "load");
149    let raw = std::fs::read(path).map_err(|source| LoaderError::Io {
150        path: path.to_path_buf(),
151        source,
152    })?;
153    let raw_len = raw.len();
154    if is_textproto_path(path) {
155        let text = String::from_utf8(raw)
156            .map_err(|e| LoaderError::TextProtoParse(format!("model is not valid UTF-8: {e}")))?;
157        let binary = proto::textproto_to_binary(&text)?;
158        if let Some(span) = span.as_mut() {
159            span.set_args(
160                Args::new()
161                    .bytes(binary.len() as u64)
162                    .with("raw_bytes", raw_len as u64)
163                    .with("textproto", true)
164                    .with("path", path.display().to_string()),
165            );
166        }
167        Ok(binary)
168    } else {
169        if let Some(span) = span.as_mut() {
170            span.set_args(
171                Args::new()
172                    .bytes(raw_len as u64)
173                    .with("textproto", false)
174                    .with("path", path.display().to_string()),
175            );
176        }
177        Ok(raw)
178    }
179}
180
181/// Whether `path` names an ONNX protobuf TextFormat fixture (`*.textproto`).
182pub fn is_textproto_path(path: impl AsRef<Path>) -> bool {
183    path.as_ref()
184        .extension()
185        .is_some_and(|ext| ext.eq_ignore_ascii_case("textproto"))
186}
187
188/// Load a model from an in-memory protobuf buffer, returning both the
189/// [`Graph`] and the live [`WeightStore`] that backs all initializer data.
190///
191/// External initializer data (if any) is resolved relative to `base_dir`.
192/// The [`Arc<WeightStore>`] keeps every memory map alive for as long as any
193/// clone of the `Arc` exists.
194pub fn load_model_bytes_with_weights(
195    bytes: &[u8],
196    base_dir: impl AsRef<Path>,
197) -> Result<(Graph, Arc<WeightStore>), LoaderError> {
198    build_from_bytes_with_weights(bytes, base_dir.as_ref(), None)
199}
200
201/// Like [`load_model_bytes_with_weights`], but a matched function-call node is
202/// **kept as an op** (not inlined) whenever `keep_as_op` returns `true` for it —
203/// the general "keep-as-op iff a kernel claims it, else inline" policy used to
204/// let a registered fused kernel dispatch on a call that would otherwise expand
205/// into its function body. Passing `None` (or using the non-filtered entry) is
206/// byte-identical to inlining every function.
207pub fn load_model_bytes_with_weights_filtered(
208    bytes: &[u8],
209    base_dir: impl AsRef<Path>,
210    keep_as_op: &function_inline::KeepAsOp<'_>,
211) -> Result<(Graph, Arc<WeightStore>), LoaderError> {
212    build_from_bytes_with_weights(bytes, base_dir.as_ref(), Some(keep_as_op))
213}
214
215fn build_from_bytes_with_weights(
216    bytes: &[u8],
217    model_dir: &Path,
218    keep_as_op: Option<&function_inline::KeepAsOp<'_>>,
219) -> Result<(Graph, Arc<WeightStore>), LoaderError> {
220    let model = parse_model(bytes)?;
221    validate_proto(&model)?;
222    let BuiltGraph {
223        mut graph,
224        name_map,
225    } = build_graph(&model, keep_as_op)?;
226
227    // Fail-fast legality check that needs no weights: reject illegal opset
228    // imports before we touch the (potentially large) weight files.
229    validate_opset_imports(&graph)?;
230
231    let store = load_weights(&model, model_dir, &name_map)?;
232    attach_weights(&mut graph, &store);
233
234    validate_ir(&graph)?;
235    validate_loaded_model(&graph)?;
236    infer_shapes(&mut graph)?;
237
238    Ok((graph, Arc::new(store)))
239}
240
241fn parse_model(bytes: &[u8]) -> Result<proto::onnx::ModelProto, LoaderError> {
242    let mut span = trace_span("load.parse_model", "load");
243    let model = proto::decode_model(bytes)?;
244    if let Some(span) = span.as_mut() {
245        span.set_args(Args::new().bytes(bytes.len() as u64));
246    }
247    Ok(model)
248}
249
250fn validate_proto(model: &proto::onnx::ModelProto) -> Result<(), LoaderError> {
251    let mut span = trace_span("load.validate_model_proto", "load");
252    validate_model_proto(model)?;
253    if let Some(span) = span.as_mut() {
254        span.set_args(
255            Args::new()
256                .with("graph_count", if model.graph.is_some() { 1_u64 } else { 0 })
257                .with("metadata_props", model.metadata_props.len() as u64),
258        );
259    }
260    Ok(())
261}
262
263fn build_graph(
264    model: &proto::onnx::ModelProto,
265    keep_as_op: Option<&function_inline::KeepAsOp<'_>>,
266) -> Result<BuiltGraph, LoaderError> {
267    let mut span = trace_span("load.build_graph", "load");
268    let built = graph_builder::build_graph(model, keep_as_op)?;
269    if let Some(span) = span.as_mut() {
270        span.set_args(
271            Args::new()
272                .with("nodes", built.graph.num_nodes() as u64)
273                .with("values", built.graph.values.len() as u64)
274                .with("inputs", built.graph.inputs.len() as u64)
275                .with("outputs", built.graph.outputs.len() as u64)
276                .with("initializers", built.graph.initializers.len() as u64),
277        );
278    }
279    Ok(built)
280}
281
282fn load_weights(
283    model: &proto::onnx::ModelProto,
284    model_dir: &Path,
285    name_map: &std::collections::HashMap<String, onnx_runtime_ir::ValueId>,
286) -> Result<WeightStore, LoaderError> {
287    let mut span = trace_span("load.external_weights", "load");
288    let store = weights::load_weights(model, model_dir, name_map)?;
289    if let Some(span) = span.as_mut() {
290        let mut inline_count = 0_u64;
291        let mut inline_bytes = 0_u64;
292        let mut external_count = 0_u64;
293        let mut external_bytes = 0_u64;
294        for weight in store.weights.values() {
295            match weight {
296                WeightRef::Inline(tensor) => {
297                    inline_count += 1;
298                    inline_bytes += tensor.data.len() as u64;
299                }
300                WeightRef::External { length, .. } => {
301                    external_count += 1;
302                    external_bytes += *length as u64;
303                }
304            }
305        }
306        span.set_args(
307            Args::new()
308                .with("initializers", store.weights.len() as u64)
309                .with("inline_initializers", inline_count)
310                .with("inline_bytes", inline_bytes)
311                .with("external_initializers", external_count)
312                .with("external_bytes", external_bytes)
313                .with("model_dir", model_dir.display().to_string()),
314        );
315    }
316    Ok(store)
317}
318
319fn attach_weights(graph: &mut Graph, store: &WeightStore) {
320    // Copy descriptors into the graph; the store's mmaps stay alive via Arc.
321    for (&value_id, weight) in &store.weights {
322        graph.set_initializer(value_id, weight.clone());
323    }
324}
325
326fn validate_ir(graph: &Graph) -> Result<(), LoaderError> {
327    // Structural IR validation runs here — *after* initializers are attached —
328    // rather than inside `graph_builder::build_graph`. A top-level initializer
329    // is only recorded in `graph.initializers` by the weight-loading path above,
330    // so validating earlier would mis-flag a legal initializer that is also a
331    // graph output (constant pass-through) or a pre-IR-4 graph input that is
332    // also an initializer as a producer-less `MissingProducer`. Validating the
333    // fully-assembled graph recognizes those values as initializer sources.
334    let mut span = trace_span("load.validate_graph", "load");
335    graph
336        .validate()
337        .map_err(|errors| LoaderError::GraphBuild(format!("{errors:?}")))?;
338    if let Some(span) = span.as_mut() {
339        span.set_args(
340            Args::new()
341                .with("nodes", graph.num_nodes() as u64)
342                .with("values", graph.values.len() as u64)
343                .with("initializers", graph.initializers.len() as u64),
344        );
345    }
346    Ok(())
347}
348
349fn validate_loaded_model(graph: &Graph) -> Result<(), LoaderError> {
350    // Full fail-fast validation once initializers are attached (so
351    // initializer-backed values are recognized as sourced). Rejects
352    // statically-knowable unsupported/illegal constructs before shape
353    // inference or execution — see [`validate_model`].
354    let mut span = trace_span("load.validate_model", "load");
355    validate_model(graph)?;
356    if let Some(span) = span.as_mut() {
357        span.set_args(Args::new().with("nodes", graph.num_nodes() as u64));
358    }
359    Ok(())
360}
361
362fn infer_shapes(graph: &mut Graph) -> Result<(), LoaderError> {
363    // Static/symbolic shape inference (the loader owns this seam). Run the
364    // extensible per-op registry over the fully-built graph — inputs,
365    // initializers, and node outputs — to populate every value's shape and
366    // dtype. `Permissive`: prefer the more specific dim on a benign
367    // disagreement and keep going, and reconcile graph outputs with their
368    // declared shapes rather than clobbering them. Values that stay symbolic
369    // (genuinely data-dependent extents) are left for the session's JIT
370    // fallback to resolve at run time.
371    let registry = InferenceRegistry::default_registry();
372    let opset_imports = graph.opset_imports.clone();
373    let mut span = trace_span("load.shape_inference", "load");
374    registry.infer_graph(graph, &opset_imports, MergePolicy::Permissive)?;
375    if let Some(span) = span.as_mut() {
376        span.set_args(
377            Args::new()
378                .with("nodes", graph.num_nodes() as u64)
379                .with("values", graph.values.len() as u64)
380                .with("opset_domains", graph.opset_imports.len() as u64),
381        );
382    }
383    Ok(())
384}
385
386/// Fail-fast, load-time validation of everything statically knowable to be
387/// illegal or unsupported (RULES #1: fail at *load*, never via a silent
388/// sentinel at run time).
389///
390/// This is the single cohesive entry point wired into **both** load paths — the
391/// disk/bytes loader ([`build_from_bytes_with_weights`]) and the session's
392/// programmatic entry ([`onnx_runtime_session`]'s `from_parts`/`from_graph`) —
393/// so the checks cannot drift between the two. It runs, in order:
394///
395/// Protobuf-only invariants (`ir_version`, raw SSA names, `ref_attr_name`,
396/// subgraph shadows, and output names) run earlier in
397/// [`validate_model_proto`], before graph construction coalesces names or drops
398/// protobuf-only fields. This IR-level phase then runs:
399///
400/// 1. [`validate_opset_imports`] — every node's domain must declare an opset.
401/// 2. [`validate_einsum_nodes`] — resolve `Einsum-12`/`Einsum-28` from the
402///    imported opset and validate every statically known equation/type/shape.
403/// 3. [`validate_no_control_flow`] — allow the implemented subgraph-bearing ops
404///    (`If`/`Loop`/`Scan`) and reject any other op carrying a `GraphProto`
405///    attribute the executor cannot run.
406/// 4. [`validate_no_dangling_refs`] — every consumed tensor must be sourced
407///    (graph input, initializer, or an upstream node output).
408/// 5. [`validate_no_initializer_producer`] — an initializer must be a constant
409///    source; reject any initializer value that is also a node output (shares a
410///    `ValueId` with a producer), which the IR structural check does not cover.
411///
412/// Each rejection names the offending node/op/tensor and explains what is
413/// expected. No sentinel defaults, no silent skips.
414///
415/// Structural invariants that the IR builder already enforces via
416/// [`onnx_runtime_ir::Graph::validate`] at build time — duplicate output
417/// names, dangling value ids, producer/consumer link consistency, and data
418/// dependency cycles — are intentionally *not* re-checked here to avoid drift;
419/// this function adds the checks that path does not cover.
420pub fn validate_model(graph: &Graph) -> Result<(), LoaderError> {
421    validate_opset_imports(graph)?;
422    validate_einsum_nodes(graph)?;
423    validate_no_control_flow(graph)?;
424    validate_no_dangling_refs(graph)?;
425    validate_no_initializer_producer(graph)?;
426    Ok(())
427}
428
429/// Resolve and validate every default-domain `Einsum` against the model's
430/// effective imported opset before shape inference or placement.
431pub fn validate_einsum_nodes(graph: &Graph) -> Result<(), LoaderError> {
432    use onnx_runtime_ir::{Attribute, EinsumInput, EinsumPlan, EinsumSchema, EinsumShapePlan};
433
434    fn check_graph(
435        graph: &Graph,
436        imports: &std::collections::HashMap<String, u64>,
437    ) -> Result<(), LoaderError> {
438        for (_, node) in graph.nodes.iter() {
439            if !node.is_default_domain() || node.op_type != "Einsum" {
440                continue;
441            }
442            let imported_opset = node
443                .local_opset()
444                .or_else(|| imports.get("").copied())
445                .unwrap_or(1);
446            let equation = match node.attr("equation") {
447                Some(Attribute::String(bytes)) => {
448                    std::str::from_utf8(bytes).map_err(|error| LoaderError::InvalidEinsum {
449                        node: node_label(node),
450                        detail: format!(
451                            "attribute `equation` is not valid UTF-8 at byte offset {}",
452                            error.valid_up_to()
453                        ),
454                    })?
455                }
456                _ => {
457                    return Err(LoaderError::InvalidEinsum {
458                        node: node_label(node),
459                        detail: "missing required STRING attribute `equation`".to_string(),
460                    });
461                }
462            };
463            if node.outputs.len() != 1 {
464                return Err(LoaderError::InvalidEinsum {
465                    node: node_label(node),
466                    detail: format!(
467                        "equation `{equation}` requires exactly 1 output, but the node declares {} outputs",
468                        node.outputs.len()
469                    ),
470                });
471            }
472            let output_id = node.outputs[0];
473            let output = graph.values.get(output_id).ok_or_else(|| LoaderError::InvalidEinsum {
474                node: node_label(node),
475                detail: format!(
476                    "equation `{equation}` declares 1 output, but output #0 references missing value {output_id:?}"
477                ),
478            })?;
479            if output.name.as_deref().is_none_or(str::is_empty) {
480                return Err(LoaderError::InvalidEinsum {
481                    node: node_label(node),
482                    detail: format!(
483                        "equation `{equation}` declares 1 output, but required output #0 has an empty or omitted name"
484                    ),
485                });
486            }
487            let mut metadata = Vec::with_capacity(node.inputs.len());
488            for (input, slot) in node.inputs.iter().enumerate() {
489                let value_id = slot.ok_or_else(|| LoaderError::InvalidEinsum {
490                    node: node_label(node),
491                    detail: format!("input #{input} is omitted from a variadic required operand"),
492                })?;
493                let value =
494                    graph
495                        .values
496                        .get(value_id)
497                        .ok_or_else(|| LoaderError::InvalidEinsum {
498                            node: node_label(node),
499                            detail: format!("input #{input} references missing value {value_id:?}"),
500                        })?;
501                metadata.push(EinsumInput::from_optional(
502                    graph.value_type_is_known(value_id).then_some(value.dtype),
503                    graph
504                        .value_shape_is_known(value_id)
505                        .then_some(value.shape.as_slice()),
506                ));
507            }
508            let plan = match EinsumPlan::build_for_opset(equation, &metadata, imported_opset) {
509                Ok(plan) => Some(plan),
510                Err(error) if error.is_incomplete_metadata() => {
511                    if let Some(shapes) = metadata
512                        .iter()
513                        .map(|input| input.shape())
514                        .collect::<Option<Vec<_>>>()
515                    {
516                        EinsumShapePlan::build_for_opset(equation, &shapes, imported_opset)
517                            .map_err(|error| LoaderError::InvalidEinsum {
518                                node: node_label(node),
519                                detail: error.to_string(),
520                            })?;
521                    }
522                    None
523                }
524                Err(error) => {
525                    return Err(LoaderError::InvalidEinsum {
526                        node: node_label(node),
527                        detail: error.to_string(),
528                    });
529                }
530            };
531            if graph.value_type_is_known(output_id) {
532                let schema = EinsumSchema::resolve(imported_opset).map_err(|error| {
533                    LoaderError::InvalidEinsum {
534                        node: node_label(node),
535                        detail: error.to_string(),
536                    }
537                })?;
538                if !schema.supports_dtype(output.dtype) {
539                    return Err(LoaderError::InvalidEinsum {
540                        node: node_label(node),
541                        detail: format!(
542                            "output dtype {:?} is not admitted by {schema}; cast the output and \
543                             every operand to one homogeneous schema-supported dtype",
544                            output.dtype
545                        ),
546                    });
547                }
548                let input_dtype = plan
549                    .as_ref()
550                    .map(EinsumPlan::dtype)
551                    .or_else(|| metadata.iter().find_map(|input| input.dtype()));
552                if let Some(input_dtype) = input_dtype
553                    && output.dtype != input_dtype
554                {
555                    return Err(LoaderError::InvalidEinsum {
556                        node: node_label(node),
557                        detail: format!(
558                            "output dtype {:?} does not match known homogeneous input dtype {:?}",
559                            output.dtype, input_dtype
560                        ),
561                    });
562                }
563            }
564        }
565        for subgraph in graph.subgraphs.values() {
566            check_graph(subgraph, imports)?;
567        }
568        Ok(())
569    }
570
571    check_graph(graph, &graph.opset_imports)
572}
573
574/// Validate ONNX model metadata and protobuf-level graph invariants that are
575/// intentionally not preserved by the runtime IR (notably `ir_version` and
576/// `AttributeProto::ref_attr_name`).
577///
578/// This runs before graph construction, ensuring invalid names cannot be
579/// coalesced into a single IR value and attribute references cannot be dropped.
580pub fn validate_model_proto(model: &proto::onnx::ModelProto) -> Result<(), LoaderError> {
581    use std::collections::HashSet;
582
583    use proto::onnx::GraphProto;
584
585    // Lower sanity bound only: `ir_version` is a required ONNX field and IR
586    // versions start at 1, so reject an absent (0) or negative version.
587    if model.ir_version < 1 {
588        return Err(LoaderError::InvalidIrVersion {
589            ir_version: model.ir_version,
590        });
591    }
592    // No upper bound. Per the maintainer directive, new ONNX IR versions are
593    // effectively always backward-compatible (they add fields/metadata rather
594    // than breaking existing model semantics), so gating on a version ceiling
595    // only produces false-positive rejections of otherwise-valid newer models.
596    // If a genuinely unsupported construct ever ships, gate on that specific
597    // FEATURE at load time — never on the IR version number.
598    if model.ir_version >= 3 && model.opset_import.is_empty() {
599        return Err(LoaderError::MissingModelOpsetImport {
600            ir_version: model.ir_version,
601        });
602    }
603
604    fn node_description(node: &proto::onnx::NodeProto, index: usize) -> String {
605        if node.name.is_empty() {
606            format!("<unnamed node #{index}>")
607        } else {
608            format!("{:?}", node.name)
609        }
610    }
611
612    fn check_graph(graph: &GraphProto) -> Result<(), LoaderError> {
613        let mut producers = std::collections::HashMap::new();
614        for input in &graph.input {
615            if !input.name.is_empty() {
616                producers.insert(input.name.clone(), "graph input".to_string());
617            }
618        }
619        for (index, node) in graph.node.iter().enumerate() {
620            let node_description = node_description(node, index);
621            for output in &node.output {
622                if output.is_empty() {
623                    continue;
624                }
625                let producer = format!("output of {node_description}");
626                if let Some(first) = producers.insert(output.clone(), producer.clone()) {
627                    return Err(LoaderError::DuplicateValueProducer {
628                        tensor: output.clone(),
629                        first,
630                        second: producer,
631                    });
632                }
633            }
634            for attribute in &node.attribute {
635                if !attribute.ref_attr_name.is_empty() {
636                    return Err(LoaderError::RefAttributeOutsideFunction {
637                        op_type: node.op_type.clone(),
638                        node: node_description.clone(),
639                        domain: display_domain(&node.domain),
640                        attr: attribute.name.clone(),
641                        ref_attr_name: attribute.ref_attr_name.clone(),
642                    });
643                }
644            }
645        }
646
647        let sources: HashSet<&str> = graph
648            .input
649            .iter()
650            .map(|input| input.name.as_str())
651            .chain(
652                graph
653                    .initializer
654                    .iter()
655                    .map(|initializer| initializer.name.as_str()),
656            )
657            .chain(
658                graph
659                    .node
660                    .iter()
661                    .flat_map(|node| node.output.iter().map(String::as_str)),
662            )
663            .collect();
664        for output in &graph.output {
665            if !output.name.is_empty() && !sources.contains(output.name.as_str()) {
666                return Err(LoaderError::GraphOutputMissingProducer {
667                    tensor: output.name.clone(),
668                });
669            }
670        }
671
672        let outer_initializers: HashSet<&str> = graph
673            .initializer
674            .iter()
675            .map(|initializer| initializer.name.as_str())
676            .collect();
677        for node in &graph.node {
678            for attribute in &node.attribute {
679                let subgraphs = attribute.g.iter().chain(attribute.graphs.iter());
680                for subgraph in subgraphs {
681                    if let Some(input) = subgraph
682                        .input
683                        .iter()
684                        .find(|input| outer_initializers.contains(input.name.as_str()))
685                    {
686                        return Err(LoaderError::SubgraphInputShadowsInitializer {
687                            tensor: input.name.clone(),
688                        });
689                    }
690                    check_graph(subgraph)?;
691                }
692            }
693        }
694        Ok(())
695    }
696
697    if let Some(graph) = &model.graph {
698        check_graph(graph)?;
699    }
700    Ok(())
701}
702
703/// Human-readable node label for diagnostics: the quoted ONNX node name, or a
704/// synthetic `<unnamed node #id>` when the model left it blank.
705fn node_label(node: &onnx_runtime_ir::Node) -> String {
706    if node.name.is_empty() {
707        format!("<unnamed node #{}>", node.id.0)
708    } else {
709        format!("{:?}", node.name)
710    }
711}
712
713/// Canonical display domain for a node (`""` renders as `ai.onnx`).
714fn display_domain(domain: &str) -> String {
715    if domain.is_empty() {
716        "ai.onnx".to_string()
717    } else {
718        domain.to_string()
719    }
720}
721
722/// Reject subgraph-bearing (control-flow) ops the runtime cannot execute.
723///
724/// The CPU executor implements the three standard subgraph-bearing control-flow
725/// ops — `If`, `Loop`, and `Scan` (default `ai.onnx` domain) — by recursively
726/// executing their nested [`onnx_runtime_ir::Attribute::Graph`]/`Graphs` bodies.
727/// Any *other* op that smuggles a subgraph attribute (a control-flow construct
728/// this runtime does not implement, or a custom op hiding a nested graph) is
729/// still rejected fast: the executor has no path to run it, so a silent skip or
730/// a late panic would be worse than a clear load-time error.
731///
732/// The check descends into every nested subgraph as well, so an unimplemented
733/// control-flow op buried inside an `If`/`Loop`/`Scan` body is caught at load
734/// rather than surfacing only when that branch/iteration executes.
735pub fn validate_no_control_flow(graph: &Graph) -> Result<(), LoaderError> {
736    use onnx_runtime_ir::Attribute;
737
738    /// The standard subgraph-bearing ops the CPU executor can run recursively.
739    ///
740    /// Operates on loaded IR, where the default domain is canonically `""`.
741    fn is_implemented_control_flow(node: &onnx_runtime_ir::Node) -> bool {
742        node.is_default_domain() && matches!(node.op_type.as_str(), "If" | "Loop" | "Scan")
743    }
744
745    fn check_graph(graph: &Graph) -> Result<(), LoaderError> {
746        for (_, node) in graph.nodes.iter() {
747            // Report attributes in a deterministic order for stable diagnostics.
748            let mut subgraph_attrs: Vec<&String> = node
749                .attributes
750                .iter()
751                .filter(|(_, v)| matches!(v, Attribute::Graph(_) | Attribute::Graphs(_)))
752                .map(|(k, _)| k)
753                .collect();
754            subgraph_attrs.sort();
755            if let Some(attr) = subgraph_attrs.first() {
756                // A subgraph body is fine when its owner is an implemented
757                // control-flow op; otherwise fail fast.
758                if !is_implemented_control_flow(node) {
759                    return Err(LoaderError::UnsupportedControlFlow {
760                        op_type: node.op_type.clone(),
761                        node: node_label(node),
762                        domain: display_domain(&node.domain),
763                        attr: (*attr).clone(),
764                    });
765                }
766            }
767        }
768        // Descend into nested bodies so an unimplemented construct inside an
769        // implemented op's subgraph is still caught at load time.
770        for subgraph in graph.subgraphs.values() {
771            check_graph(subgraph)?;
772        }
773        Ok(())
774    }
775
776    check_graph(graph)
777}
778
779/// Reject graphs with a node input that has no source.
780///
781/// The graph builder materializes an unresolved input name as a fresh named
782/// value with no producer (see `graph_builder::get_or_create`); such a value is
783/// legal only if it is a graph input or an initializer. Any other producer-less
784/// consumed value is a dangling reference — a structurally malformed graph that
785/// [`onnx_runtime_ir::Graph::validate`] does not catch (it only requires graph
786/// *outputs* to be sourced, not node inputs). We reject it at load, naming the
787/// offending node and tensor.
788///
789/// Must run after initializers are attached to `graph.initializers` so
790/// initializer-backed inputs are recognized as sourced.
791pub fn validate_no_dangling_refs(graph: &Graph) -> Result<(), LoaderError> {
792    use std::collections::HashSet;
793
794    let graph_inputs: HashSet<_> = graph.inputs.iter().copied().collect();
795
796    for (_, node) in graph.nodes.iter() {
797        for vid in node.input_values() {
798            let Some(value) = graph.values.get(vid) else {
799                // A dangling value id is caught by IR-level structural
800                // validation; nothing to report here.
801                continue;
802            };
803            let is_sourced = value.producer.is_some()
804                || graph_inputs.contains(&vid)
805                || graph.initializers.contains_key(&vid);
806            if !is_sourced {
807                let tensor = value
808                    .name
809                    .clone()
810                    .unwrap_or_else(|| format!("<anonymous value #{}>", vid.0));
811                return Err(LoaderError::DanglingTensorRef {
812                    op_type: node.op_type.clone(),
813                    node: node_label(node),
814                    domain: display_domain(&node.domain),
815                    tensor,
816                });
817            }
818        }
819    }
820    Ok(())
821}
822
823/// Reject graphs where an initializer value is also produced by a node.
824///
825/// The graph builder maps tensor *names* → [`onnx_runtime_ir::ValueId`] for both
826/// node inputs and node outputs (see `graph_builder::get_or_create`). If a node
827/// output name collides with an initializer name, the node output reuses the
828/// initializer's `ValueId` and `connect_edges` then sets `producer = Some(node)`
829/// on that shared value. [`onnx_runtime_ir::Graph::validate`] rejects a *graph
830/// input* with a producer but has no equivalent check for an *initializer*, so
831/// such a malformed graph passes structural validation.
832///
833/// This matters for memory-safety: the session's weight-streaming path borrows
834/// an initializer's read-only mmap bytes zero-copy. A producer-backed
835/// initializer would let a kernel write through that read-only storage
836/// (SIGSEGV on external data, aliasing UB inline). The executor already refuses
837/// to borrow producer-backed initializers, but rejecting the graph here fails
838/// fast and cleanly regardless of the execution path. We name the tensor and
839/// the offending producing node.
840pub fn validate_no_initializer_producer(graph: &Graph) -> Result<(), LoaderError> {
841    for &vid in graph.initializers.keys() {
842        let Some(value) = graph.values.get(vid) else {
843            continue;
844        };
845        if let Some(producer) = value.producer {
846            let tensor = value
847                .name
848                .clone()
849                .unwrap_or_else(|| format!("<anonymous value #{}>", vid.0));
850            let node = if graph.nodes.contains(producer) {
851                node_label(graph.node(producer))
852            } else {
853                format!("<node #{}>", producer.0)
854            };
855            return Err(LoaderError::InitializerHasProducer { tensor, node });
856        }
857    }
858    Ok(())
859}
860///
861/// ONNX treats `""` and `"ai.onnx"` as equivalent spellings of the default
862/// domain. Model-level imports also govern nodes nested in subgraphs.
863pub fn validate_opset_imports(graph: &Graph) -> Result<(), LoaderError> {
864    // Loaded IR is canonical: the default domain is `""` for both node domains
865    // and opset-import keys, so a direct lookup suffices (no dual-spelling
866    // fallback needed — see `onnx_runtime_ir::normalize_domain`).
867    fn has_import(imports: &std::collections::HashMap<String, u64>, domain: &str) -> bool {
868        imports.contains_key(domain)
869    }
870
871    fn validate_graph(
872        graph: &Graph,
873        imports: &std::collections::HashMap<String, u64>,
874    ) -> Result<(), LoaderError> {
875        for (_, node) in graph.nodes.iter() {
876            if !has_import(imports, &node.domain) {
877                let domain = if node.domain.is_empty() {
878                    "ai.onnx".to_string()
879                } else {
880                    node.domain.clone()
881                };
882                let node_name = if node.name.is_empty() {
883                    format!("<unnamed node #{}>", node.id.0)
884                } else {
885                    format!("{:?}", node.name)
886                };
887                return Err(LoaderError::MissingOpsetImport {
888                    op_type: node.op_type.clone(),
889                    node: node_name,
890                    domain,
891                });
892            }
893        }
894        for subgraph in graph.subgraphs.values() {
895            validate_graph(subgraph, imports)?;
896        }
897        Ok(())
898    }
899
900    validate_graph(graph, &graph.opset_imports)
901}