Skip to main content

onnx_runtime_session/
lib.rs

1//! # `onnx-runtime-session`
2//!
3//! The user-facing session and inference API for the ORT 2.0 runtime
4//! (see `docs/ORT2.md` §20). Design goal: **zero-config by default** — the user
5//! never has to know what an execution provider is; the runtime auto-detects
6//! hardware and picks a strategy.
7//!
8//! **Phase 1 skeleton:** the intent-based [`SessionBuilder`] and
9//! [`InferenceSession`] surfaces are defined; `build`/`run` bodies are
10//! `todo!()` pending the sequential executor (Phase 1 task `ort2-session`).
11//!
12//! ```ignore
13//! let mut session = onnx_runtime_session::load("model.onnx")?;
14//! let outputs = session.run(&[("input_ids", &tensor)])?;
15//! ```
16
17use std::collections::HashMap;
18use std::path::{Path, PathBuf};
19
20use onnx_runtime_ir::{DataType, DeviceType, Shape};
21
22pub use epcontext::{
23    CompiledPartition, EpContextPlacement, dump_session_ep_context, load_ep_context_nodes,
24};
25pub use onnx_runtime_loader::{EpContextDumpConfig, EpContextPartition, Model as EncoderModel};
26pub use error::SessionError;
27pub use executor::CacheStats;
28pub use tensor::Tensor;
29
30mod epcontext;
31mod executor;
32mod tensor;
33
34mod error {
35    /// Errors produced by the session layer.
36    #[derive(Debug, thiserror::Error)]
37    pub enum SessionError {
38        #[error("session not initialized")]
39        NotInitialized,
40
41        #[error("input not found: {name}")]
42        InputNotFound { name: String },
43
44        #[error("unknown session option: {key}")]
45        UnknownOption { key: String },
46
47        #[error("invalid value {value:?} for session option {key:?}: expected one of {expected}")]
48        InvalidOption {
49            key: String,
50            value: String,
51            expected: String,
52        },
53
54        #[error("no model source: set a path or bytes on the builder")]
55        NoModelSource,
56
57        #[error("op type not supported by any available EP: {op_type}")]
58        UnsupportedOp { op_type: String },
59
60        #[error("value has a non-static (symbolic) shape and no binding to resolve it: {value}")]
61        DynamicShape { value: String },
62
63        #[error(
64            "symbol {symbol} bound to conflicting sizes {first} and {second} across bound inputs"
65        )]
66        SymbolConflict {
67            symbol: String,
68            first: usize,
69            second: usize,
70        },
71
72        #[error("input {name}: rank mismatch (graph declares rank {expected}, got {got})")]
73        RankMismatch {
74            name: String,
75            expected: usize,
76            got: usize,
77        },
78
79        #[error("no inferred shape for value {value} produced by op {op}")]
80        UnresolvedShape { value: String, op: String },
81
82        #[error("shape element count overflows usize for value {value} (dims {dims:?})")]
83        ShapeOverflow { value: String, dims: Vec<usize> },
84
85        #[error(
86            "op {op} produced {got} data-dependent output shape(s) but has {expected} output(s)"
87        )]
88        OutputShapeCountMismatch {
89            op: String,
90            expected: usize,
91            got: usize,
92        },
93
94        #[error("input {name}: dtype mismatch (expected {expected}, got {got})")]
95        DtypeMismatch {
96            name: String,
97            expected: String,
98            got: String,
99        },
100
101        #[error("input {name}: shape mismatch (expected {expected:?}, got {got:?})")]
102        ShapeMismatch {
103            name: String,
104            expected: Vec<usize>,
105            got: Vec<usize>,
106        },
107
108        #[error("internal executor error: {0}")]
109        Internal(String),
110
111        #[error(
112            "EPContext reference node (main_context=0) has no matching primary \
113             (source={source_key:?}, partition_name={partition_name:?})"
114        )]
115        DanglingEpContext {
116            source_key: Option<String>,
117            partition_name: Option<String>,
118        },
119
120        #[error(transparent)]
121        Load(#[from] onnx_runtime_loader::LoaderError),
122
123        #[error(transparent)]
124        Ep(#[from] onnx_runtime_ep_api::EpError),
125
126        #[error(transparent)]
127        Ir(#[from] onnx_runtime_ir::IrError),
128
129        #[error(transparent)]
130        Graph(#[from] onnx_runtime_ir::GraphError),
131
132        #[error(transparent)]
133        Optimize(#[from] onnx_runtime_optimizer::OptimizerError),
134
135        #[error(transparent)]
136        ShapeInfer(#[from] onnx_runtime_shape_inference::ShapeInferError),
137    }
138
139    /// Session `Result` alias.
140    pub type Result<T> = std::result::Result<T, SessionError>;
141}
142
143use error::Result;
144
145/// Metadata describing a model input or output (§20.2).
146#[derive(Clone, Debug)]
147pub struct IoMeta {
148    pub name: String,
149    pub dtype: DataType,
150    pub shape: Shape,
151}
152
153/// Intent-based device preference (§20.4). The runtime maps this to concrete
154/// EPs during `build`.
155#[derive(Clone, Debug, Default, PartialEq, Eq)]
156pub enum DevicePreference {
157    /// Pick the best available device automatically.
158    #[default]
159    Auto,
160    /// Prefer CPU execution.
161    Cpu,
162    /// Prefer a GPU / accelerator, optionally by ordinal.
163    Gpu { index: Option<u32> },
164    /// Pin to a specific device class + ordinal.
165    Explicit { device_type: DeviceType, index: u32 },
166}
167
168/// A shape to pre-compile kernels for at session init (§11.3).
169#[derive(Clone, Debug)]
170pub struct WarmupShape {
171    pub input_name: String,
172    pub shape: Vec<usize>,
173}
174
175/// Graph-optimization level for the session's `optimize` pipeline stage
176/// (`docs/ORT2.md` §18). Selected via the generic `"optimization"` session
177/// option (see [`SessionBuilder::option`]).
178///
179/// The default is [`OptimizationLevel::None`]: with optimization off the graph
180/// reaches the executor exactly as the loader produced it, so default runtime
181/// behavior is byte-identical to a build with no optimizer wired in at all.
182///
183/// This is a generic, model-agnostic knob — no level ever special-cases a model
184/// name or op. Higher levels simply enable more of the device-independent pass
185/// pipeline from [`onnx_runtime_optimizer`].
186#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
187pub enum OptimizationLevel {
188    /// No passes — the `optimize` stage is a no-op (default).
189    #[default]
190    None,
191    /// Structure-preserving passes only: constant folding then dead-node
192    /// elimination. No operator fusion, so the op set the executor sees is a
193    /// subset of the loaded graph's.
194    Basic,
195    /// The full device-independent pipeline: constant folding, dead-node
196    /// elimination, and operator fusion (which can introduce fused
197    /// `com.microsoft` contrib ops such as `LayerNormalization`).
198    All,
199}
200
201impl OptimizationLevel {
202    /// Parse the `"optimization"` option value. Accepts `"none"`, `"basic"`,
203    /// and `"all"` (case-insensitive).
204    fn parse(value: &str) -> Option<Self> {
205        match value.trim().to_ascii_lowercase().as_str() {
206            "none" | "off" | "0" => Some(Self::None),
207            "basic" => Some(Self::Basic),
208            "all" => Some(Self::All),
209            _ => None,
210        }
211    }
212
213    /// The optimizer passes this level enables, in pipeline order. Empty for
214    /// [`OptimizationLevel::None`].
215    fn passes(self) -> Vec<Box<dyn onnx_runtime_optimizer::OptimizationPass>> {
216        use onnx_runtime_optimizer::{ConstantFolding, DeadNodeElimination, OpFusion};
217        match self {
218            Self::None => Vec::new(),
219            Self::Basic => vec![Box::new(ConstantFolding), Box::new(DeadNodeElimination)],
220            Self::All => vec![
221                Box::new(ConstantFolding),
222                Box::new(DeadNodeElimination),
223                Box::new(OpFusion::new()),
224            ],
225        }
226    }
227}
228
229/// Builder for advanced session configuration (§20.6).
230#[derive(Default)]
231pub struct SessionBuilder {
232    model_path: Option<PathBuf>,
233    model_bytes: Option<Vec<u8>>,
234    device: DevicePreference,
235    memory_limit: Option<usize>,
236    enable_profiling: bool,
237    warmup_shapes: Vec<WarmupShape>,
238    options: HashMap<String, String>,
239}
240
241impl SessionBuilder {
242    pub fn new() -> Self {
243        Self::default()
244    }
245
246    pub fn model(mut self, path: impl AsRef<Path>) -> Self {
247        self.model_path = Some(path.as_ref().to_path_buf());
248        self
249    }
250
251    pub fn model_bytes(mut self, bytes: &[u8]) -> Self {
252        self.model_bytes = Some(bytes.to_vec());
253        self
254    }
255
256    pub fn device(mut self, pref: DevicePreference) -> Self {
257        self.device = pref;
258        self
259    }
260
261    pub fn memory_limit(mut self, bytes: usize) -> Self {
262        self.memory_limit = Some(bytes);
263        self
264    }
265
266    pub fn profiling(mut self, enable: bool) -> Self {
267        self.enable_profiling = enable;
268        self
269    }
270
271    pub fn warmup(mut self, shapes: Vec<WarmupShape>) -> Self {
272        self.warmup_shapes = shapes;
273        self
274    }
275
276    /// Set a namespaced option. Unknown keys — and unknown values for a known
277    /// key — are rejected at [`Self::build`].
278    ///
279    /// # Recognized options
280    ///
281    /// | Key                     | Values                       | Default  | Effect |
282    /// |-------------------------|------------------------------|----------|--------|
283    /// | `"optimization"`        | `"none"`, `"basic"`, `"all"` | `"none"` | Graph optimization level (see [`OptimizationLevel`]). |
284    /// | `"ep.context_enable"`   | `"0"`/`"1"`/`"false"`/`"true"` | `"0"`  | Dump a `*_ctx.onnx` EPContext model after compile (§21.4 / §55.4). |
285    /// | `"ep.context_file_path"`| any path                     | `<orig>_ctx.onnx` | Output path for the generated context model. |
286    /// | `"ep.context_embed_mode"`| `"0"` (external) / `"1"` (embed) | `"1"` | How the compiled blob is stored in each EPContext node. |
287    ///
288    /// `"optimization"` = `"none"` (the default) leaves the loaded graph
289    /// untouched, so behavior is byte-identical to a runtime with no optimizer.
290    /// `"basic"` runs constant folding + dead-node elimination; `"all"` adds
291    /// operator fusion. When any pass runs, the session re-runs shape inference
292    /// on the rewritten graph before compiling so fused/introduced nodes get
293    /// inferred shapes.
294    pub fn option(mut self, key: &str, value: &str) -> Self {
295        self.options.insert(key.to_string(), value.to_string());
296        self
297    }
298
299    /// Parse every set session option in a single pass, rejecting any unknown
300    /// key or unparseable value up front (no silent compat shim — an
301    /// unrecognized key is a typo, never a no-op). Returns the resolved
302    /// [`OptimizationLevel`] and the EPContext dump config (§21.4 / §55.5)
303    /// driven by the `ep.context_*` keys.
304    ///
305    /// # Recognized keys
306    ///
307    /// * `"optimization"` → [`OptimizationLevel`] (`none` / `basic` / `all`).
308    /// * `"ep.context_enable"` → [`EpContextDumpConfig::enable`]
309    ///   (`1`/`0`/`true`/`false`, case-insensitive).
310    /// * `"ep.context_file_path"` → [`EpContextDumpConfig::file_path`] (an empty
311    ///   value clears it back to the `<orig>_ctx.onnx` default).
312    /// * `"ep.context_embed_mode"` → [`EpContextDumpConfig::embed_mode`]
313    ///   (`0` external file / `1` embed; any other value is rejected).
314    fn parse_options(
315        options: &HashMap<String, String>,
316    ) -> Result<(OptimizationLevel, EpContextDumpConfig)> {
317        let mut level = OptimizationLevel::None;
318        let mut ctx = EpContextDumpConfig::default();
319        for (key, value) in options {
320            match key.as_str() {
321                "optimization" => {
322                    level = OptimizationLevel::parse(value).ok_or_else(|| {
323                        SessionError::InvalidOption {
324                            key: key.clone(),
325                            value: value.clone(),
326                            expected: "none, basic, all".to_string(),
327                        }
328                    })?;
329                }
330                "ep.context_enable" => {
331                    ctx.enable = parse_bool_option(key, value)?;
332                }
333                "ep.context_file_path" => {
334                    // Empty/unset ⇒ None (fall back to `<orig>_ctx.onnx`).
335                    ctx.file_path = if value.trim().is_empty() {
336                        None
337                    } else {
338                        Some(PathBuf::from(value))
339                    };
340                }
341                "ep.context_embed_mode" => {
342                    ctx.embed_mode = parse_embed_mode(key, value)?;
343                }
344                // No compat shim: an unrecognized key is a typo, not a silent
345                // no-op.
346                _ => return Err(SessionError::UnknownOption { key: key.clone() }),
347            }
348        }
349        Ok((level, ctx))
350    }
351
352    /// Build the session: load → detect device → optimize → compile → allocate.
353    ///
354    /// The `optimize` stage is driven by the `"optimization"` session option and
355    /// defaults to [`OptimizationLevel::None`] (a no-op), so the default path is
356    /// byte-identical to loading straight into the executor. When optimization
357    /// is enabled the pipeline is:
358    ///
359    /// ```text
360    /// load (+ loader shape inference)
361    ///   → run optimizer passes (constant-fold / DCE / fusion)
362    ///   → re-run shape inference on the rewritten graph
363    ///   → compile (kernel per node) → allocate
364    /// ```
365    ///
366    /// The re-inference step is essential: fusion can replace a multi-op
367    /// decomposition (e.g. the 9-op LayerNorm) with a single fused node whose
368    /// output has no inferred shape yet, and the compile/execute stages require
369    /// every value to carry a resolved shape.
370    ///
371    /// Device selection is CPU-only (`auto_detect` yields the CPU EP), and
372    /// "compile" resolves a kernel per node into the shape-keyed cache.
373    pub fn build(self) -> Result<InferenceSession> {
374        let (level, ep_context_config) = Self::parse_options(&self.options)?;
375
376        // `memory_limit`, `enable_profiling`, and non-CPU `device` preferences
377        // are accepted but not yet acted on in Phase 1 (CPU-only executor).
378        let _ = (self.device, self.memory_limit, self.enable_profiling);
379
380        let (mut graph, weights, model_dir) = match (self.model_path, self.model_bytes) {
381            (Some(path), _) => {
382                // The EPContext load path resolves `embed_mode=0` external blob
383                // paths relative to the model file's directory (§55.3), so
384                // retain it (same base dir the loader used for external data).
385                let model_dir = path
386                    .parent()
387                    .map(Path::to_path_buf)
388                    .unwrap_or_else(|| PathBuf::from("."));
389                let (g, w) = onnx_runtime_loader::load_model_with_weights(path)?;
390                (g, w, model_dir)
391            }
392            (None, Some(bytes)) => {
393                let (g, w) = onnx_runtime_loader::load_model_bytes_with_weights(&bytes, ".")?;
394                (g, w, PathBuf::from("."))
395            }
396            (None, None) => return Err(SessionError::NoModelSource),
397        };
398
399        // Optimize stage. Off by default; only runs when a level is selected.
400        optimize_graph(&mut graph, level)?;
401
402        let mut session =
403            InferenceSession::from_parts(graph, weights, &model_dir, ep_context_config)?;
404        if !self.warmup_shapes.is_empty() {
405            session.warmup(&self.warmup_shapes)?;
406        }
407        Ok(session)
408    }
409}
410
411/// Parse a boolean-ish session-option value (§21.4). Accepts `1`/`0` and
412/// `true`/`false` (case-insensitive), mirroring how ORT's C API treats its
413/// `int`-typed `ep.context_enable` flag while also allowing the textual form.
414/// Any other value is a typo, surfaced as [`SessionError::InvalidOption`].
415fn parse_bool_option(key: &str, value: &str) -> Result<bool> {
416    match value.trim().to_ascii_lowercase().as_str() {
417        "1" | "true" => Ok(true),
418        "0" | "false" => Ok(false),
419        _ => Err(SessionError::InvalidOption {
420            key: key.to_string(),
421            value: value.to_string(),
422            expected: "0, 1, true, false".to_string(),
423        }),
424    }
425}
426
427/// Parse the `ep.context_embed_mode` option (§21.4): `0` = external sidecar
428/// file, `1` = embed the blob inline. Any other value is rejected with
429/// [`SessionError::InvalidOption`] (mirroring [`OptimizationLevel::parse`]'s
430/// fail-closed rejection rather than silently clamping).
431fn parse_embed_mode(key: &str, value: &str) -> Result<u8> {
432    match value.trim() {
433        "0" => Ok(0),
434        "1" => Ok(1),
435        _ => Err(SessionError::InvalidOption {
436            key: key.to_string(),
437            value: value.to_string(),
438            expected: "0, 1".to_string(),
439        }),
440    }
441}
442
443/// Run the optimizer passes selected by `level`, then re-run shape inference so
444/// any node fusion introduced (whose outputs the loader never saw) gets a fully
445/// inferred shape/dtype before compile.
446///
447/// A no-op when `level` is [`OptimizationLevel::None`] — the graph is returned
448/// untouched and no re-inference runs, keeping the default path byte-identical.
449fn optimize_graph(graph: &mut onnx_runtime_ir::Graph, level: OptimizationLevel) -> Result<()> {
450    let passes = level.passes();
451    if passes.is_empty() {
452        return Ok(());
453    }
454
455    onnx_runtime_optimizer::run_passes(
456        graph,
457        &passes,
458        &onnx_runtime_optimizer::PassContext::new(),
459    )?;
460
461    // Fusion emits fused ops in the `com.microsoft` contrib domain; make sure
462    // that domain is imported so shape-inference and kernel dispatch pick the
463    // contrib-registered rules (they register from opset 1, but recording the
464    // import keeps the graph self-consistent and future-proofs versioned rules).
465    graph
466        .opset_imports
467        .entry(onnx_runtime_optimizer::CONTRIB_DOMAIN.to_string())
468        .or_insert(1);
469
470    // Re-infer shapes over the rewritten graph: fused nodes' outputs (and any
471    // value whose producer changed) must be re-resolved before compile.
472    let registry = onnx_runtime_shape_inference::InferenceRegistry::default_registry();
473    let opset_imports = graph.opset_imports.clone();
474    registry.infer_graph(
475        graph,
476        &opset_imports,
477        onnx_runtime_shape_inference::MergePolicy::Permissive,
478    )?;
479
480    Ok(())
481}
482
483/// A loaded model ready to run inference (§20.2).
484pub struct InferenceSession {
485    inputs: Vec<IoMeta>,
486    outputs: Vec<IoMeta>,
487    exec: executor::Executor,
488    /// EPContext dump config parsed from the `ep.context_*` session options
489    /// (§21.4). Drives [`InferenceSession::export_ep_context`]; disabled by
490    /// default so an ordinary session never touches the dump path.
491    ep_context_config: EpContextDumpConfig,
492}
493
494fn io_meta(graph: &onnx_runtime_ir::Graph, values: &[onnx_runtime_ir::ValueId]) -> Vec<IoMeta> {
495    values
496        .iter()
497        .map(|&vid| {
498            let v = graph.value(vid);
499            IoMeta {
500                name: v.name.clone().unwrap_or_default(),
501                dtype: v.dtype,
502                shape: v.shape.clone(),
503            }
504        })
505        .collect()
506}
507
508impl InferenceSession {
509    /// Primary entry point: load a model with auto device detection.
510    pub fn load(path: impl AsRef<Path>) -> Result<Self> {
511        Self::builder().model(path).build()
512    }
513
514    /// Load a model from an in-memory buffer.
515    pub fn load_bytes(bytes: &[u8]) -> Result<Self> {
516        Self::builder().model_bytes(bytes).build()
517    }
518
519    /// Build a session directly from an in-memory IR [`Graph`](onnx_runtime_ir::Graph).
520    ///
521    /// Initializer bytes are read from the graph's inline [`WeightRef`]s, so no
522    /// on-disk model or weight store is required. Useful for programmatically
523    /// constructed graphs and tests.
524    pub fn from_graph(graph: onnx_runtime_ir::Graph) -> Result<Self> {
525        // No on-disk model: `embed_mode=0` external EPContext blobs resolve
526        // relative to the current directory (consistent with the loader's
527        // in-memory `base_dir` default).
528        Self::from_parts(
529            graph,
530            std::sync::Arc::new(onnx_runtime_loader::WeightStore::new()),
531            Path::new("."),
532            EpContextDumpConfig::default(),
533        )
534    }
535
536    fn from_parts(
537        graph: onnx_runtime_ir::Graph,
538        weights: std::sync::Arc<onnx_runtime_loader::WeightStore>,
539        model_dir: &Path,
540        ep_context_config: EpContextDumpConfig,
541    ) -> Result<Self> {
542        let inputs = io_meta(&graph, &graph.inputs);
543        let outputs = io_meta(&graph, &graph.outputs);
544        let ep = executor::auto_detect_cpu_ep()?;
545
546        // EPContext consume path (§55.3): restore any pre-compiled EP contexts
547        // before building the executor. Dispatch is a pure `source`-key lookup
548        // over the session's registered EPs (Phase 1: the CPU EP only, which
549        // declares no `source` keys — so a model that carries EPContext nodes
550        // for an unloaded compiled EP fails with a clear `NoEpForContext`). The
551        // executor then bypasses these nodes (they are pre-compiled, never run
552        // as ordinary kernels).
553        let eps: [(
554            onnx_runtime_ep_api::EpId,
555            &dyn onnx_runtime_ep_api::ExecutionProvider,
556        ); 1] = [(onnx_runtime_ep_api::EpId(0), ep.as_ref())];
557        epcontext::load_ep_context_nodes(&graph, model_dir, &eps)?;
558
559        let exec = executor::Executor::build(graph, weights, ep)?;
560        Ok(Self {
561            inputs,
562            outputs,
563            exec,
564            ep_context_config,
565        })
566    }
567
568    /// Start a configuration builder.
569    pub fn builder() -> SessionBuilder {
570        SessionBuilder::new()
571    }
572
573    /// Run inference with named inputs, returning the graph outputs in order.
574    pub fn run(&mut self, inputs: &[(&str, &Tensor)]) -> Result<Vec<Tensor>> {
575        self.exec.run(inputs)
576    }
577
578    /// Input metadata.
579    pub fn inputs(&self) -> &[IoMeta] {
580        &self.inputs
581    }
582
583    /// Output metadata.
584    pub fn outputs(&self) -> &[IoMeta] {
585        &self.outputs
586    }
587
588    /// Kernel-cache statistics (§11.1); useful to observe warmup/run reuse.
589    pub fn cache_stats(&self) -> CacheStats {
590        self.exec.cache_stats()
591    }
592
593    /// Pre-compile kernels for common shapes to avoid first-inference latency
594    /// (§11.3). Phase-1 minimal: the compiled plan's shapes already key the
595    /// cache, so this repopulates it for the plan; `shapes` are validated to
596    /// name real inputs.
597    pub fn warmup(&mut self, shapes: &[WarmupShape]) -> Result<()> {
598        for ws in shapes {
599            if !self.inputs.iter().any(|m| m.name == ws.input_name) {
600                return Err(SessionError::InputNotFound {
601                    name: ws.input_name.clone(),
602                });
603            }
604        }
605        self.exec.warmup()
606    }
607
608    /// The EPContext dump configuration parsed from the `ep.context_*` session
609    /// options (§21.4). Disabled by default.
610    pub fn ep_context_config(&self) -> &EpContextDumpConfig {
611        &self.ep_context_config
612    }
613
614    /// The session's (post-optimize) compiled graph.
615    ///
616    /// This is the graph the executor runs and the same one
617    /// [`Self::export_ep_context`] serialises — a caller identifying the
618    /// [`NodeId`](onnx_runtime_ir::NodeId)s of a compiled partition (the
619    /// [`CompiledPartition::covered_nodes`]) must read them from here so they
620    /// reference the exact nodes the exporter will splice out. This is the
621    /// compiler-integration seam: a real compiling EP inspects this graph to
622    /// choose the subgraphs it claims.
623    pub fn graph(&self) -> &onnx_runtime_ir::Graph {
624        self.exec.graph()
625    }
626
627    /// Export a `com.microsoft::EPContext` context-cache model for this session
628    /// (§55.4 dump path), driven by the `ep.context_*` session options
629    /// ([`Self::ep_context_config`]).
630    ///
631    /// `orig_path` is the source model path the default output location
632    /// (`<orig>_ctx.onnx`) is derived from when `ep.context_file_path` is unset.
633    /// `partitions` are the EP-compiled partitions to serialise — each names the
634    /// [`ExecutionProvider`](onnx_runtime_ep_api::ExecutionProvider) that
635    /// compiled it, so the driver pulls the blob + SDK version via
636    /// [`save_context`](onnx_runtime_ep_api::ExecutionProvider::save_context) and
637    /// the `source` key via
638    /// [`context_source_keys`](onnx_runtime_ep_api::ExecutionProvider::context_source_keys)
639    /// (§55.6 — nothing is hardcoded).
640    ///
641    /// When `ep.context_enable` is `false` (the default) this is a **no-op**: no
642    /// EP `save_context` is called and no files are written; it returns the path
643    /// it *would* have written to.
644    ///
645    /// # Compiler-integration seam
646    ///
647    /// The Phase-1 CPU EP has **no compile step**, so no real EP yet yields
648    /// [`CompiledPartition`]s — `partitions` is therefore supplied by the
649    /// caller (proven end-to-end with a mock compiling EP in the crate tests).
650    /// TODO(compiler): when a real compiling EP lands, collect its partitions
651    /// from the compile/placement stage and call this internally at build time
652    /// so a session created with `ep.context_enable=1` dumps automatically.
653    pub fn export_ep_context(
654        &self,
655        orig_path: &Path,
656        partitions: &[CompiledPartition],
657    ) -> Result<PathBuf> {
658        let model = EncoderModel::new(self.exec.graph()).with_weights(self.exec.weights().as_ref());
659        dump_session_ep_context(&model, orig_path, partitions, &self.ep_context_config)
660    }
661}
662
663/// Load a model. Auto-detects the best available hardware (§20.2).
664///
665/// This is the primary entry point — no configuration required.
666pub fn load(path: impl AsRef<Path>) -> Result<InferenceSession> {
667    InferenceSession::load(path)
668}
669
670#[cfg(test)]
671mod option_tests {
672    use super::*;
673
674    fn opts(pairs: &[(&str, &str)]) -> HashMap<String, String> {
675        pairs
676            .iter()
677            .map(|(k, v)| (k.to_string(), v.to_string()))
678            .collect()
679    }
680
681    fn level_of(pairs: &[(&str, &str)]) -> Result<OptimizationLevel> {
682        SessionBuilder::parse_options(&opts(pairs)).map(|(level, _)| level)
683    }
684
685    fn ctx_of(pairs: &[(&str, &str)]) -> Result<EpContextDumpConfig> {
686        SessionBuilder::parse_options(&opts(pairs)).map(|(_, ctx)| ctx)
687    }
688
689    #[test]
690    fn optimization_defaults_to_none_when_unset() {
691        assert_eq!(level_of(&[]).unwrap(), OptimizationLevel::None);
692    }
693
694    #[test]
695    fn optimization_parses_known_values() {
696        for (v, want) in [
697            ("none", OptimizationLevel::None),
698            ("off", OptimizationLevel::None),
699            ("BASIC", OptimizationLevel::Basic),
700            ("All", OptimizationLevel::All),
701        ] {
702            assert_eq!(level_of(&[("optimization", v)]).unwrap(), want, "value {v:?}");
703        }
704    }
705
706    #[test]
707    fn unknown_option_key_is_rejected() {
708        let err = level_of(&[("optimisation", "all")]).unwrap_err();
709        assert!(matches!(err, SessionError::UnknownOption { key } if key == "optimisation"));
710    }
711
712    #[test]
713    fn invalid_optimization_value_is_rejected() {
714        let err = level_of(&[("optimization", "aggressive")]).unwrap_err();
715        assert!(matches!(
716            err,
717            SessionError::InvalidOption { key, value, .. } if key == "optimization" && value == "aggressive"
718        ));
719    }
720
721    #[test]
722    fn none_level_selects_no_passes() {
723        assert!(OptimizationLevel::None.passes().is_empty());
724        assert_eq!(OptimizationLevel::Basic.passes().len(), 2);
725        assert_eq!(OptimizationLevel::All.passes().len(), 3);
726    }
727
728    // ── EPContext dump options (§21.4 / §55.5) ────────────────────────────────
729
730    #[test]
731    fn ep_context_defaults_to_disabled() {
732        let ctx = ctx_of(&[]).unwrap();
733        assert_eq!(ctx, EpContextDumpConfig::default());
734        assert!(!ctx.enable);
735        assert_eq!(ctx.file_path, None);
736        assert_eq!(ctx.embed_mode, 1);
737    }
738
739    #[test]
740    fn ep_context_enable_parses_bool_forms() {
741        for (v, want) in [
742            ("1", true),
743            ("0", false),
744            ("true", true),
745            ("TRUE", true),
746            ("false", false),
747            ("False", false),
748        ] {
749            let ctx = ctx_of(&[("ep.context_enable", v)]).unwrap();
750            assert_eq!(ctx.enable, want, "value {v:?}");
751        }
752    }
753
754    #[test]
755    fn ep_context_enable_rejects_garbage() {
756        let err = ctx_of(&[("ep.context_enable", "yes")]).unwrap_err();
757        assert!(matches!(
758            err,
759            SessionError::InvalidOption { key, value, .. }
760                if key == "ep.context_enable" && value == "yes"
761        ));
762    }
763
764    #[test]
765    fn ep_context_file_path_parses_and_empty_clears() {
766        let ctx = ctx_of(&[("ep.context_file_path", "/out/net_ctx.onnx")]).unwrap();
767        assert_eq!(ctx.file_path, Some(PathBuf::from("/out/net_ctx.onnx")));
768
769        // Empty value falls back to the `<orig>_ctx.onnx` default (None).
770        let ctx = ctx_of(&[("ep.context_file_path", "")]).unwrap();
771        assert_eq!(ctx.file_path, None);
772    }
773
774    #[test]
775    fn ep_context_embed_mode_parses_and_rejects() {
776        assert_eq!(ctx_of(&[("ep.context_embed_mode", "0")]).unwrap().embed_mode, 0);
777        assert_eq!(ctx_of(&[("ep.context_embed_mode", "1")]).unwrap().embed_mode, 1);
778
779        let err = ctx_of(&[("ep.context_embed_mode", "2")]).unwrap_err();
780        assert!(matches!(
781            err,
782            SessionError::InvalidOption { key, value, expected }
783                if key == "ep.context_embed_mode" && value == "2" && expected == "0, 1"
784        ));
785    }
786
787    #[test]
788    fn ep_context_options_combine_with_optimization() {
789        let (level, ctx) = SessionBuilder::parse_options(&opts(&[
790            ("optimization", "all"),
791            ("ep.context_enable", "1"),
792            ("ep.context_file_path", "/tmp/out_ctx.onnx"),
793            ("ep.context_embed_mode", "0"),
794        ]))
795        .unwrap();
796        assert_eq!(level, OptimizationLevel::All);
797        assert!(ctx.enable);
798        assert_eq!(ctx.file_path, Some(PathBuf::from("/tmp/out_ctx.onnx")));
799        assert_eq!(ctx.embed_mode, 0);
800    }
801}