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