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/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;
44use onnx_runtime_shape_inference::{InferenceRegistry, MergePolicy};
45
46use crate::graph_builder::BuiltGraph;
47
48pub mod encoder;
49pub mod epcontext;
50pub mod function_inline;
51pub(crate) mod graph_builder;
52pub mod proto;
53pub mod weights;
54pub mod writer;
55
56mod pathsafe;
57
58pub use encoder::{
59    DEFAULT_IR_VERSION, Model, ModelMetadata, encode_model, encode_model_proto, write_model,
60};
61pub use epcontext::{
62    EmbedMode, EpContextBlob, EpContextNode, ep_context_node_ids, ep_context_nodes,
63    is_ep_context_op, resolve_ep_context,
64};
65pub use error::LoaderError;
66pub use weights::{
67    ExpertQuantization, ExpertStorageOrder, ExpertTensorLayout, ExpertWeightRegion,
68    NonPageableReason, Pageability, WeightRegionCatalog, WeightStore,
69};
70pub use writer::{EpContextDumpConfig, EpContextPartition, dump_ep_context};
71
72mod error {
73    use std::path::PathBuf;
74
75    /// Errors produced while loading an ONNX model.
76    #[derive(Debug, thiserror::Error)]
77    pub enum LoaderError {
78        #[error("failed to read model file {path}: {source}")]
79        Io {
80            path: PathBuf,
81            #[source]
82            source: std::io::Error,
83        },
84
85        #[error("failed to parse ONNX protobuf: {0}")]
86        ProtobufParse(String),
87
88        #[error("failed to parse ONNX protobuf TextFormat: {0}")]
89        TextProtoParse(String),
90
91        #[error("unsupported opset: domain={domain}, version={version}")]
92        UnsupportedOpset { domain: String, version: u64 },
93
94        #[error(
95            "illegal ONNX model: operator {domain}::{op_type} at node {node} uses domain \
96             '{domain}' but no corresponding opset_import is declared. RULES #1: the model must \
97             declare an opset_import for domain '{domain}'; if you built this graph \
98             programmatically, add it before loading; if this is a file, the model is \
99             malformed/invalid per the ONNX spec"
100        )]
101        MissingOpsetImport {
102            op_type: String,
103            node: String,
104            domain: String,
105        },
106
107        #[error(
108            "unsupported ONNX model: operator {domain}::{op_type} at node {node} carries a \
109             subgraph attribute '{attr}' (control-flow / nested-graph op) that this runtime cannot \
110             execute. RULES #1: ep-cpu recursively executes the standard control-flow ops \
111             If/Loop/Scan (ai.onnx), but not {op_type}, so the model cannot be run as-is. \
112             Expected: express control flow with If/Loop/Scan, lower/unroll {op_type} into \
113             supported ops, or register a kernel able to execute its subgraph body"
114        )]
115        UnsupportedControlFlow {
116            op_type: String,
117            node: String,
118            domain: String,
119            attr: String,
120        },
121
122        #[error(
123            "illegal ONNX model: operator {domain}::{op_type} at node {node} consumes tensor \
124             '{tensor}', but no producer exists — it is not a graph input, not an initializer, and \
125             not produced by any upstream node. RULES #1: every consumed tensor must be sourced; \
126             the graph is structurally malformed. Expected: add '{tensor}' as a graph input or \
127             initializer, or add a node that produces it; if this is a file, the model is invalid \
128             per the ONNX spec"
129        )]
130        DanglingTensorRef {
131            op_type: String,
132            node: String,
133            domain: String,
134            tensor: String,
135        },
136
137        #[error(
138            "illegal ONNX model: tensor '{tensor}' is declared as an initializer but is also \
139             produced as an output of node {node} — an initializer must be a constant source with \
140             no producer. RULES #1: initializer names must be unique and must not collide with any \
141             node output name; a producer-backed initializer would let a kernel write through \
142             read-only weight storage. Expected: rename the node output or the initializer so they \
143             no longer share a name; if this is a file, the model is malformed per the ONNX spec"
144        )]
145        InitializerHasProducer { tensor: String, node: String },
146
147        #[error(
148            "illegal ONNX model: value '{tensor}' has multiple producers ({first} and {second}). \
149             RULES #1: ONNX graphs are in SSA form, so a value name may be assigned only once. \
150             Expected: give each graph input and node output a unique name"
151        )]
152        DuplicateValueProducer {
153            tensor: String,
154            first: String,
155            second: String,
156        },
157
158        #[error(
159            "illegal ONNX model: operator {domain}::{op_type} at node {node} has attribute \
160             '{attr}' referring to function attribute '{ref_attr_name}' outside a FunctionProto. \
161             RULES #1: ref_attr_name is only bound while inlining a FunctionProto; it has no \
162             executable value in a main graph or control-flow subgraph. Expected: replace it with \
163             a concrete attribute value or move the node into a FunctionProto"
164        )]
165        RefAttributeOutsideFunction {
166            op_type: String,
167            node: String,
168            domain: String,
169            attr: String,
170            ref_attr_name: String,
171        },
172
173        #[error(
174            "illegal ONNX model: ir_version {ir_version} is invalid. RULES #1: ir_version is \
175             required and ONNX IR versions start at 1. Expected: emit a model with ir_version >= 1"
176        )]
177        InvalidIrVersion { ir_version: i64 },
178
179        #[error(
180            "illegal ONNX model: ir_version {ir_version} requires at least one opset_import \
181             (ONNX IR>=3). Expected: add an opset_import for every operator domain used by the \
182             model"
183        )]
184        MissingModelOpsetImport { ir_version: i64 },
185
186        #[error(
187            "illegal ONNX model: initializer '{tensor}' in an outer graph is shadowed by a \
188             subgraph input of the same name. RULES #1: this runtime does not permit ambiguous \
189             initializer/subgraph binding. Expected: rename the subgraph formal input or the \
190             outer initializer"
191        )]
192        SubgraphInputShadowsInitializer { tensor: String },
193
194        #[error(
195            "illegal ONNX model: graph output '{tensor}' has no producer in its graph. RULES #1: \
196             every output must be a graph input, initializer, or node output in the same scope. \
197             Expected: produce '{tensor}' locally or declare it as an input/initializer"
198        )]
199        GraphOutputMissingProducer { tensor: String },
200
201        #[error("external data file not found: {path}")]
202        ExternalDataNotFound { path: PathBuf },
203
204        #[error("external data path rejected ({reason}): {path}")]
205        ExternalDataPath { path: String, reason: &'static str },
206
207        #[error("weight mmap failed: {0}")]
208        Mmap(String),
209
210        #[error("EPContext node error: {0}")]
211        EpContext(String),
212
213        #[error("EPContext external path rejected ({reason}): {path}")]
214        EpContextPath { path: String, reason: &'static str },
215
216        #[error("graph construction failed: {0}")]
217        GraphBuild(String),
218
219        #[error(
220            "illegal ONNX model: model-local function {function} is recursive (call chain: \
221             {chain}). RULES #1: ONNX function bodies may reference other model-local functions \
222             but MUST NOT be recursive — inlining cannot terminate. Expected: break the cycle so \
223             no function transitively calls itself"
224        )]
225        RecursiveFunction { function: String, chain: String },
226
227        #[error(
228            "illegal ONNX model: call to model-local function {function} at node {node} passes \
229             {actual} {kind}(s) but the function declares only {formal}. RULES #1: a function \
230             call may omit trailing optional {kind}s but must not supply more than are declared. \
231             Expected: remove the extra {kind}(s) or fix the function signature"
232        )]
233        FunctionArityMismatch {
234            function: String,
235            node: String,
236            kind: &'static str,
237            formal: usize,
238            actual: usize,
239        },
240
241        #[error(
242            "illegal ONNX model: call to model-local function {function} at node {node} is missing \
243             required attribute '{attribute}', and the function declares no default for it. \
244             RULES #1: an attribute listed in FunctionProto.attribute has no default and must be \
245             supplied at every call site. Expected: set '{attribute}' on the call node, or give \
246             the function a default via attribute_proto"
247        )]
248        MissingRequiredFunctionAttribute {
249            function: String,
250            node: String,
251            attribute: String,
252        },
253
254        #[error("unsupported ONNX data_type {raw} at {context}")]
255        UnsupportedDataType { raw: i32, context: String },
256
257        #[error("shape inference failed: {0}")]
258        ShapeInference(#[from] onnx_runtime_shape_inference::ShapeInferError),
259
260        #[error(transparent)]
261        Ir(#[from] onnx_runtime_ir::IrError),
262    }
263}
264
265/// Load a model from a filesystem path, producing a fully-built [`Graph`].
266///
267/// Runs the full pipeline: parse → build → load weights → shape inference.
268/// External initializer data is resolved relative to the model file's
269/// directory.
270///
271/// # Note on external weights
272///
273/// The returned `Graph` stores [`onnx_runtime_ir::WeightRef::External`]
274/// descriptors (path / offset / length) for weights held in external data
275/// files, but the memory maps that back those bytes are **dropped** when this
276/// function returns. Callers that need to read external weight bytes must
277/// either re-map the files themselves or use [`load_model_with_weights`] which
278/// keeps the maps alive via the returned [`Arc<WeightStore>`].
279pub fn load_model(path: impl AsRef<Path>) -> Result<Graph, LoaderError> {
280    Ok(load_model_with_weights(path)?.0)
281}
282
283/// Load a model from an in-memory protobuf buffer, producing a [`Graph`].
284///
285/// External initializer data (if any) is resolved relative to the current
286/// working directory.
287///
288/// # Note on external weights
289///
290/// Same caveat as [`load_model`]: external weight bytes are not accessible
291/// from the returned `Graph` alone. Use [`load_model_bytes_with_weights`] to
292/// keep them live.
293pub fn load_model_bytes(bytes: &[u8]) -> Result<Graph, LoaderError> {
294    Ok(load_model_bytes_with_weights(bytes, Path::new("."))?.0)
295}
296
297/// Load a model from a filesystem path, returning both the [`Graph`] and the
298/// live [`WeightStore`] that backs all initializer data.
299///
300/// The [`Arc<WeightStore>`] keeps every external-data memory map alive for as
301/// long as any clone of the `Arc` exists. At session time, given a
302/// [`onnx_runtime_ir::WeightRef`] from `graph.initializers`, call
303/// [`WeightStore::bytes`] to obtain the raw little-endian byte slice — this
304/// works for both [`WeightRef::Inline`] and [`WeightRef::External`] weights.
305///
306/// External initializer data is resolved relative to the model file's
307/// directory.
308pub fn load_model_with_weights(
309    path: impl AsRef<Path>,
310) -> Result<(Graph, Arc<WeightStore>), LoaderError> {
311    let path = path.as_ref();
312    let bytes = read_model_binary(path)?;
313    let model_dir = path.parent().unwrap_or_else(|| Path::new("."));
314    build_from_bytes_with_weights(&bytes, model_dir)
315}
316
317/// Read a model file into the binary protobuf bytes of its `ModelProto`.
318///
319/// Detection is by filename suffix: a path ending in `.textproto` is parsed as
320/// ONNX protobuf **TextFormat** and converted to the binary wire encoding (see
321/// [`proto::textproto_to_binary`]); any other path is read verbatim as an
322/// already-binary `.onnx` model. This is the single seam that lets every
323/// path-based loader entry accept git-friendly textproto fixtures while keeping
324/// binary `.onnx` loading unchanged.
325///
326/// Note: textproto has no model-directory context for external weights, so
327/// textproto fixtures must inline all initializer data.
328pub fn read_model_binary(path: impl AsRef<Path>) -> Result<Vec<u8>, LoaderError> {
329    let path = path.as_ref();
330    let raw = std::fs::read(path).map_err(|source| LoaderError::Io {
331        path: path.to_path_buf(),
332        source,
333    })?;
334    if is_textproto_path(path) {
335        let text = String::from_utf8(raw)
336            .map_err(|e| LoaderError::TextProtoParse(format!("model is not valid UTF-8: {e}")))?;
337        proto::textproto_to_binary(&text)
338    } else {
339        Ok(raw)
340    }
341}
342
343/// Whether `path` names an ONNX protobuf TextFormat fixture (`*.textproto`).
344pub fn is_textproto_path(path: impl AsRef<Path>) -> bool {
345    path.as_ref()
346        .extension()
347        .is_some_and(|ext| ext.eq_ignore_ascii_case("textproto"))
348}
349
350/// Load a model from an in-memory protobuf buffer, returning both the
351/// [`Graph`] and the live [`WeightStore`] that backs all initializer data.
352///
353/// External initializer data (if any) is resolved relative to `base_dir`.
354/// The [`Arc<WeightStore>`] keeps every memory map alive for as long as any
355/// clone of the `Arc` exists.
356pub fn load_model_bytes_with_weights(
357    bytes: &[u8],
358    base_dir: impl AsRef<Path>,
359) -> Result<(Graph, Arc<WeightStore>), LoaderError> {
360    build_from_bytes_with_weights(bytes, base_dir.as_ref())
361}
362
363fn build_from_bytes_with_weights(
364    bytes: &[u8],
365    model_dir: &Path,
366) -> Result<(Graph, Arc<WeightStore>), LoaderError> {
367    let model = proto::decode_model(bytes)?;
368    validate_model_proto(&model)?;
369    let BuiltGraph {
370        mut graph,
371        name_map,
372    } = graph_builder::build_graph(&model)?;
373
374    // Fail-fast legality check that needs no weights: reject illegal opset
375    // imports before we touch the (potentially large) weight files.
376    validate_opset_imports(&graph)?;
377
378    let store = weights::load_weights(&model, model_dir, &name_map)?;
379    // Copy descriptors into the graph; the store's mmaps stay alive via Arc.
380    for (&vid, weight) in &store.weights {
381        graph.set_initializer(vid, weight.clone());
382    }
383
384    // Structural IR validation runs here — *after* initializers are attached —
385    // rather than inside `graph_builder::build_graph`. A top-level initializer
386    // is only recorded in `graph.initializers` by the weight-loading path above,
387    // so validating earlier would mis-flag a legal initializer that is also a
388    // graph output (constant pass-through) or a pre-IR-4 graph input that is
389    // also an initializer as a producer-less `MissingProducer`. Validating the
390    // fully-assembled graph recognizes those values as initializer sources.
391    graph
392        .validate()
393        .map_err(|errs| LoaderError::GraphBuild(format!("{errs:?}")))?;
394
395    // Full fail-fast validation once initializers are attached (so
396    // initializer-backed values are recognized as sourced). Rejects
397    // statically-knowable unsupported/illegal constructs before shape
398    // inference or execution — see [`validate_model`].
399    validate_model(&graph)?;
400
401    // Static/symbolic shape inference (the loader owns this seam). Run the
402    // extensible per-op registry over the fully-built graph — inputs,
403    // initializers, and node outputs — to populate every value's shape and
404    // dtype. `Permissive`: prefer the more specific dim on a benign
405    // disagreement and keep going, and reconcile graph outputs with their
406    // declared shapes rather than clobbering them. Values that stay symbolic
407    // (genuinely data-dependent extents) are left for the session's JIT
408    // fallback to resolve at run time.
409    let registry = InferenceRegistry::default_registry();
410    let opset_imports = graph.opset_imports.clone();
411    registry.infer_graph(&mut graph, &opset_imports, MergePolicy::Permissive)?;
412
413    Ok((graph, Arc::new(store)))
414}
415
416/// Fail-fast, load-time validation of everything statically knowable to be
417/// illegal or unsupported (RULES #1: fail at *load*, never via a silent
418/// sentinel at run time).
419///
420/// This is the single cohesive entry point wired into **both** load paths — the
421/// disk/bytes loader ([`build_from_bytes_with_weights`]) and the session's
422/// programmatic entry ([`onnx_runtime_session`]'s `from_parts`/`from_graph`) —
423/// so the checks cannot drift between the two. It runs, in order:
424///
425/// Protobuf-only invariants (`ir_version`, raw SSA names, `ref_attr_name`,
426/// subgraph shadows, and output names) run earlier in
427/// [`validate_model_proto`], before graph construction coalesces names or drops
428/// protobuf-only fields. This IR-level phase then runs:
429///
430/// 1. [`validate_opset_imports`] — every node's domain must declare an opset.
431/// 2. [`validate_no_control_flow`] — allow the implemented subgraph-bearing ops
432///    (`If`/`Loop`/`Scan`) and reject any other op carrying a `GraphProto`
433///    attribute the executor cannot run.
434/// 3. [`validate_no_dangling_refs`] — every consumed tensor must be sourced
435///    (graph input, initializer, or an upstream node output).
436/// 4. [`validate_no_initializer_producer`] — an initializer must be a constant
437///    source; reject any initializer value that is also a node output (shares a
438///    `ValueId` with a producer), which the IR structural check does not cover.
439///
440/// Each rejection names the offending node/op/tensor and explains what is
441/// expected. No sentinel defaults, no silent skips.
442///
443/// Structural invariants that the IR builder already enforces via
444/// [`onnx_runtime_ir::Graph::validate`] at build time — duplicate output
445/// names, dangling value ids, producer/consumer link consistency, and data
446/// dependency cycles — are intentionally *not* re-checked here to avoid drift;
447/// this function adds the checks that path does not cover.
448pub fn validate_model(graph: &Graph) -> Result<(), LoaderError> {
449    validate_opset_imports(graph)?;
450    validate_no_control_flow(graph)?;
451    validate_no_dangling_refs(graph)?;
452    validate_no_initializer_producer(graph)?;
453    Ok(())
454}
455
456/// Validate ONNX model metadata and protobuf-level graph invariants that are
457/// intentionally not preserved by the runtime IR (notably `ir_version` and
458/// `AttributeProto::ref_attr_name`).
459///
460/// This runs before graph construction, ensuring invalid names cannot be
461/// coalesced into a single IR value and attribute references cannot be dropped.
462pub fn validate_model_proto(model: &proto::onnx::ModelProto) -> Result<(), LoaderError> {
463    use std::collections::HashSet;
464
465    use proto::onnx::GraphProto;
466
467    // Lower sanity bound only: `ir_version` is a required ONNX field and IR
468    // versions start at 1, so reject an absent (0) or negative version.
469    if model.ir_version < 1 {
470        return Err(LoaderError::InvalidIrVersion {
471            ir_version: model.ir_version,
472        });
473    }
474    // No upper bound. Per the maintainer directive, new ONNX IR versions are
475    // effectively always backward-compatible (they add fields/metadata rather
476    // than breaking existing model semantics), so gating on a version ceiling
477    // only produces false-positive rejections of otherwise-valid newer models.
478    // If a genuinely unsupported construct ever ships, gate on that specific
479    // FEATURE at load time — never on the IR version number.
480    if model.ir_version >= 3 && model.opset_import.is_empty() {
481        return Err(LoaderError::MissingModelOpsetImport {
482            ir_version: model.ir_version,
483        });
484    }
485
486    fn node_description(node: &proto::onnx::NodeProto, index: usize) -> String {
487        if node.name.is_empty() {
488            format!("<unnamed node #{index}>")
489        } else {
490            format!("{:?}", node.name)
491        }
492    }
493
494    fn check_graph(graph: &GraphProto) -> Result<(), LoaderError> {
495        let mut producers = std::collections::HashMap::new();
496        for input in &graph.input {
497            if !input.name.is_empty() {
498                producers.insert(input.name.clone(), "graph input".to_string());
499            }
500        }
501        for (index, node) in graph.node.iter().enumerate() {
502            let node_description = node_description(node, index);
503            for output in &node.output {
504                if output.is_empty() {
505                    continue;
506                }
507                let producer = format!("output of {node_description}");
508                if let Some(first) = producers.insert(output.clone(), producer.clone()) {
509                    return Err(LoaderError::DuplicateValueProducer {
510                        tensor: output.clone(),
511                        first,
512                        second: producer,
513                    });
514                }
515            }
516            for attribute in &node.attribute {
517                if !attribute.ref_attr_name.is_empty() {
518                    return Err(LoaderError::RefAttributeOutsideFunction {
519                        op_type: node.op_type.clone(),
520                        node: node_description.clone(),
521                        domain: display_domain(&node.domain),
522                        attr: attribute.name.clone(),
523                        ref_attr_name: attribute.ref_attr_name.clone(),
524                    });
525                }
526            }
527        }
528
529        let sources: HashSet<&str> = graph
530            .input
531            .iter()
532            .map(|input| input.name.as_str())
533            .chain(
534                graph
535                    .initializer
536                    .iter()
537                    .map(|initializer| initializer.name.as_str()),
538            )
539            .chain(
540                graph
541                    .node
542                    .iter()
543                    .flat_map(|node| node.output.iter().map(String::as_str)),
544            )
545            .collect();
546        for output in &graph.output {
547            if !output.name.is_empty() && !sources.contains(output.name.as_str()) {
548                return Err(LoaderError::GraphOutputMissingProducer {
549                    tensor: output.name.clone(),
550                });
551            }
552        }
553
554        let outer_initializers: HashSet<&str> = graph
555            .initializer
556            .iter()
557            .map(|initializer| initializer.name.as_str())
558            .collect();
559        for node in &graph.node {
560            for attribute in &node.attribute {
561                let subgraphs = attribute.g.iter().chain(attribute.graphs.iter());
562                for subgraph in subgraphs {
563                    if let Some(input) = subgraph
564                        .input
565                        .iter()
566                        .find(|input| outer_initializers.contains(input.name.as_str()))
567                    {
568                        return Err(LoaderError::SubgraphInputShadowsInitializer {
569                            tensor: input.name.clone(),
570                        });
571                    }
572                    check_graph(subgraph)?;
573                }
574            }
575        }
576        Ok(())
577    }
578
579    if let Some(graph) = &model.graph {
580        check_graph(graph)?;
581    }
582    Ok(())
583}
584
585/// Human-readable node label for diagnostics: the quoted ONNX node name, or a
586/// synthetic `<unnamed node #id>` when the model left it blank.
587fn node_label(node: &onnx_runtime_ir::Node) -> String {
588    if node.name.is_empty() {
589        format!("<unnamed node #{}>", node.id.0)
590    } else {
591        format!("{:?}", node.name)
592    }
593}
594
595/// Canonical display domain for a node (`""` renders as `ai.onnx`).
596fn display_domain(domain: &str) -> String {
597    if domain.is_empty() {
598        "ai.onnx".to_string()
599    } else {
600        domain.to_string()
601    }
602}
603
604/// Reject subgraph-bearing (control-flow) ops the runtime cannot execute.
605///
606/// The CPU executor implements the three standard subgraph-bearing control-flow
607/// ops — `If`, `Loop`, and `Scan` (default `ai.onnx` domain) — by recursively
608/// executing their nested [`onnx_runtime_ir::Attribute::Graph`]/`Graphs` bodies.
609/// Any *other* op that smuggles a subgraph attribute (a control-flow construct
610/// this runtime does not implement, or a custom op hiding a nested graph) is
611/// still rejected fast: the executor has no path to run it, so a silent skip or
612/// a late panic would be worse than a clear load-time error.
613///
614/// The check descends into every nested subgraph as well, so an unimplemented
615/// control-flow op buried inside an `If`/`Loop`/`Scan` body is caught at load
616/// rather than surfacing only when that branch/iteration executes.
617pub fn validate_no_control_flow(graph: &Graph) -> Result<(), LoaderError> {
618    use onnx_runtime_ir::Attribute;
619
620    /// The standard subgraph-bearing ops the CPU executor can run recursively.
621    ///
622    /// Operates on loaded IR, where the default domain is canonically `""`.
623    fn is_implemented_control_flow(node: &onnx_runtime_ir::Node) -> bool {
624        node.is_default_domain() && matches!(node.op_type.as_str(), "If" | "Loop" | "Scan")
625    }
626
627    fn check_graph(graph: &Graph) -> Result<(), LoaderError> {
628        for (_, node) in graph.nodes.iter() {
629            // Report attributes in a deterministic order for stable diagnostics.
630            let mut subgraph_attrs: Vec<&String> = node
631                .attributes
632                .iter()
633                .filter(|(_, v)| matches!(v, Attribute::Graph(_) | Attribute::Graphs(_)))
634                .map(|(k, _)| k)
635                .collect();
636            subgraph_attrs.sort();
637            if let Some(attr) = subgraph_attrs.first() {
638                // A subgraph body is fine when its owner is an implemented
639                // control-flow op; otherwise fail fast.
640                if !is_implemented_control_flow(node) {
641                    return Err(LoaderError::UnsupportedControlFlow {
642                        op_type: node.op_type.clone(),
643                        node: node_label(node),
644                        domain: display_domain(&node.domain),
645                        attr: (*attr).clone(),
646                    });
647                }
648            }
649        }
650        // Descend into nested bodies so an unimplemented construct inside an
651        // implemented op's subgraph is still caught at load time.
652        for subgraph in graph.subgraphs.values() {
653            check_graph(subgraph)?;
654        }
655        Ok(())
656    }
657
658    check_graph(graph)
659}
660
661/// Reject graphs with a node input that has no source.
662///
663/// The graph builder materializes an unresolved input name as a fresh named
664/// value with no producer (see `graph_builder::get_or_create`); such a value is
665/// legal only if it is a graph input or an initializer. Any other producer-less
666/// consumed value is a dangling reference — a structurally malformed graph that
667/// [`onnx_runtime_ir::Graph::validate`] does not catch (it only requires graph
668/// *outputs* to be sourced, not node inputs). We reject it at load, naming the
669/// offending node and tensor.
670///
671/// Must run after initializers are attached to `graph.initializers` so
672/// initializer-backed inputs are recognized as sourced.
673pub fn validate_no_dangling_refs(graph: &Graph) -> Result<(), LoaderError> {
674    use std::collections::HashSet;
675
676    let graph_inputs: HashSet<_> = graph.inputs.iter().copied().collect();
677
678    for (_, node) in graph.nodes.iter() {
679        for vid in node.input_values() {
680            let Some(value) = graph.values.get(vid) else {
681                // A dangling value id is caught by IR-level structural
682                // validation; nothing to report here.
683                continue;
684            };
685            let is_sourced = value.producer.is_some()
686                || graph_inputs.contains(&vid)
687                || graph.initializers.contains_key(&vid);
688            if !is_sourced {
689                let tensor = value
690                    .name
691                    .clone()
692                    .unwrap_or_else(|| format!("<anonymous value #{}>", vid.0));
693                return Err(LoaderError::DanglingTensorRef {
694                    op_type: node.op_type.clone(),
695                    node: node_label(node),
696                    domain: display_domain(&node.domain),
697                    tensor,
698                });
699            }
700        }
701    }
702    Ok(())
703}
704
705/// Reject graphs where an initializer value is also produced by a node.
706///
707/// The graph builder maps tensor *names* → [`onnx_runtime_ir::ValueId`] for both
708/// node inputs and node outputs (see `graph_builder::get_or_create`). If a node
709/// output name collides with an initializer name, the node output reuses the
710/// initializer's `ValueId` and `connect_edges` then sets `producer = Some(node)`
711/// on that shared value. [`onnx_runtime_ir::Graph::validate`] rejects a *graph
712/// input* with a producer but has no equivalent check for an *initializer*, so
713/// such a malformed graph passes structural validation.
714///
715/// This matters for memory-safety: the session's weight-streaming path borrows
716/// an initializer's read-only mmap bytes zero-copy. A producer-backed
717/// initializer would let a kernel write through that read-only storage
718/// (SIGSEGV on external data, aliasing UB inline). The executor already refuses
719/// to borrow producer-backed initializers, but rejecting the graph here fails
720/// fast and cleanly regardless of the execution path. We name the tensor and
721/// the offending producing node.
722pub fn validate_no_initializer_producer(graph: &Graph) -> Result<(), LoaderError> {
723    for &vid in graph.initializers.keys() {
724        let Some(value) = graph.values.get(vid) else {
725            continue;
726        };
727        if let Some(producer) = value.producer {
728            let tensor = value
729                .name
730                .clone()
731                .unwrap_or_else(|| format!("<anonymous value #{}>", vid.0));
732            let node = if graph.nodes.contains(producer) {
733                node_label(graph.node(producer))
734            } else {
735                format!("<node #{}>", producer.0)
736            };
737            return Err(LoaderError::InitializerHasProducer { tensor, node });
738        }
739    }
740    Ok(())
741}
742///
743/// ONNX treats `""` and `"ai.onnx"` as equivalent spellings of the default
744/// domain. Model-level imports also govern nodes nested in subgraphs.
745pub fn validate_opset_imports(graph: &Graph) -> Result<(), LoaderError> {
746    // Loaded IR is canonical: the default domain is `""` for both node domains
747    // and opset-import keys, so a direct lookup suffices (no dual-spelling
748    // fallback needed — see `onnx_runtime_ir::normalize_domain`).
749    fn has_import(imports: &std::collections::HashMap<String, u64>, domain: &str) -> bool {
750        imports.contains_key(domain)
751    }
752
753    fn validate_graph(
754        graph: &Graph,
755        imports: &std::collections::HashMap<String, u64>,
756    ) -> Result<(), LoaderError> {
757        for (_, node) in graph.nodes.iter() {
758            if !has_import(imports, &node.domain) {
759                let domain = if node.domain.is_empty() {
760                    "ai.onnx".to_string()
761                } else {
762                    node.domain.clone()
763                };
764                let node_name = if node.name.is_empty() {
765                    format!("<unnamed node #{}>", node.id.0)
766                } else {
767                    format!("{:?}", node.name)
768                };
769                return Err(LoaderError::MissingOpsetImport {
770                    op_type: node.op_type.clone(),
771                    node: node_name,
772                    domain,
773                });
774            }
775        }
776        for subgraph in graph.subgraphs.values() {
777            validate_graph(subgraph, imports)?;
778        }
779        Ok(())
780    }
781
782    validate_graph(graph, &graph.opset_imports)
783}